Compare commits

...

39 Commits

Author SHA1 Message Date
9dbe172165 Fix QC Kak Inno Tgl 4 & 5 Desember
Fix QC Kak Ayu Tgl 4 & 5 Desember
Fix QC Pak Jun Tgl 5 Desember
2025-12-09 12:00:27 +08:00
cc318d4d54 Fix QC Kak Inno Tgl 4 & 5 Desember
Fix QC Kak Ayu Tgl 4 & 5 Desember
Fix QC Pak Jun Tgl 5 Desember
2025-12-09 10:28:17 +08:00
dcb8017594 Fix undefined ke detail berita terbaru 2025-12-05 17:42:04 +08:00
ec3ad12531 Fix Notifikasi saat ada berita atau pengumuman baru, notifikasi baru muncul. Ga setiap masuk landing page ada notifikasi 2025-12-05 14:30:53 +08:00
dad44c0537 Fix Menu Gallery : Gallery Foto
Fix detail berita
2025-12-05 10:56:03 +08:00
867dce42f0 Fix Error Build Staging 2025-12-04 11:58:47 +08:00
7bb17ddf22 Menambahkan menu dokter dan tenaga medis, admin bisa create, edit, delet dokter
Menambahkan menu tarif dan layanan, admin bisa create, edit, delete tarif dan layanan
Dibagian fasilitas kesehatan admin bisa multiselect bagian dokter dan tarif layanan
Di tampilan user juga sudah disesuaikan dengan datanya bisa muncul lebih dari 1 dokter dan 1 tarif layanan
2025-12-03 17:24:03 +08:00
a4069d3cba Fix UI Sosial Media Landing Page in User 2025-12-02 16:45:55 +08:00
ffe5e6dd9f Fix menu admin landing page, submenu sosial media 2025-12-02 16:06:14 +08:00
dcf195f54f Tambahan filter data sesuai tahun, di landing page apbdes 2025-12-01 17:11:24 +08:00
c03a6b3aed Tambah Term of Service di Registrasi 2025-12-01 14:01:03 +08:00
1bb9f239db Tambah Term of Service di Registrasi 2025-12-01 13:50:25 +08:00
a213ff7d37 Tambah Term of Service di Registrasi 2025-12-01 12:10:22 +08:00
0018bdc251 Fix Ganti Role, ganti role menunya sudah menyesuaikan 2025-11-28 15:03:18 +08:00
83fb39a957 Fix Ganti Role, ganti role menunya sudah menyesuaikan 2025-11-28 15:00:09 +08:00
7238692dd0 Push WebDesaDarmasabaSatging 2025-11-28 13:56:40 +08:00
8b50139d79 Push Staging 2025-11-28 12:03:07 +08:00
066180fc0e Fix registrasi, waitong-room, & tampilan layout sesuai id 2025-11-28 11:13:20 +08:00
67f29aabef Balik ke awal 2025-11-27 18:53:33 +08:00
dbf7c34228 Fix eror registrasi 2 2025-11-27 17:08:17 +08:00
036fc86fed Fix eror registrasi 1 2025-11-27 16:45:47 +08:00
2cecec733e Tambah cookies di bagian verifikasi, agar kedeteksi user sudah regis apa belom 2025-11-27 14:46:49 +08:00
c64a2e5457 Fix Seeder User, dan role 2025-11-27 12:18:15 +08:00
757911d7dd Fix Seeder 2025-11-26 15:32:49 +08:00
54232e4465 Menambahkan seed user
Fix Infinite reload di page ikm dan landing page
2025-11-26 15:01:34 +08:00
29a9a59bca saat tampilan user sudah diubah dan login ulan sudah menyesuaikan untuk menunya 2025-11-26 11:01:23 +08:00
2fb3666e57 User yang sudah registrasi sudah langsung diarahkan ke layout sesuai dengan roleIdnya
Superadmin sudah bisa menambah atau mengurangkan menu pad user yang diinginkan
Next-------------------------------
Ada bug saat tampilan menu sudah di edit superamin berhasil namun saat user logout tampilan menunya balik ke sebelumnya
2025-11-26 10:14:05 +08:00
e30b27f7a4 Fix Search 2025-11-25 17:30:41 +08:00
e941ed3893 Sudah fix menunya, superadmin bisa memilihkan menu untuk user 2025-11-25 16:21:15 +08:00
ace5aff1b6 Fix Kondisi Verify Otp Registrasi dan Login
Next mau fix eror saat user sudah terdaftar tetapi di redirect ke login, seharusnya redirect sesuai roleIdnya
2025-11-25 15:03:27 +08:00
716db0adca Fix Middleware
Fix Layout sesuai role, dan superadmin bisa menambahkan menu ke user jika diperlukan
Penambahan menu di user & role : menu access
2025-11-24 16:02:13 +08:00
a291bdfb51 Tampilan Layout sudah sesuai dengan roleIdnya
Sudah sessionnya
Sudah disesuaikan juga semisal superadmin ngubah role admin, maka admin tersebut akan logOut dan diarahkan ke halama login
sudah bisa logOut
2025-11-21 17:26:38 +08:00
0dff8f3254 Nico 20 Nov 25
Dibagian layout admin sudah disesuaikan dengan rolenya : supadmin, admin desa, admin kesehatan, admin pendidikan
Fix API User & Role Admin
2025-11-20 16:42:36 +08:00
78b8aa74cd Saat user baru registrasi maka akan diarahkan ke page waiting-room dan menunggu validasi admin 2025-11-20 14:07:26 +08:00
a0537810e8 Login, Register, Verifkasi Code Admin V1 2025-11-20 02:42:39 +08:00
b3c169a2d4 Fix create admin & progress bar persentase 2025-11-18 17:23:38 +08:00
2608a5ffdd Fix Edit di Admin APbdes, dan fix data real di apbdes user 2025-11-18 16:26:09 +08:00
6c32f3ebdb Fix Route APBdes 2025-11-18 14:27:53 +08:00
0feeb4de93 Fix SDGs Desa Barchart sudah responsive, tabel dan bar progress di menu apbdes sudah sesuai dengan data 2025-11-18 11:56:16 +08:00
169 changed files with 14476 additions and 5115 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -54,6 +54,7 @@
"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",

View File

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

View File

@@ -1,23 +1,10 @@
[ [
{ {
"id": "user-1", "id": "cmie1o0zh0002vn132vtzg7hh",
"nama": "Admin Desa", "username": "SuperAdmin-Nico",
"nomor": "089647037426", "nomor": "6289647037426",
"roleId": "role-1", "roleId": 0,
"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
} }
] ]

File diff suppressed because it is too large Load Diff

View File

@@ -136,6 +136,7 @@ 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
@@ -184,18 +185,46 @@ model SdgsDesa {
//========================================= APBDes ========================================= // //========================================= APBDes ========================================= //
model APBDes { model APBDes {
id String @id @default(cuid()) id String @id @default(cuid())
name String tahun Int?
jumlah String name String? // misalnya: "APBDes Tahun 2025"
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 @default(now()) deletedAt DateTime? // opsional, tidak perlu 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())
@@ -754,24 +783,22 @@ 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(fields: [dokterdanTenagaMedisId], references: [id]) dokterdantenagamedis DokterdanTenagaMedis[] @relation("Dokter")
dokterdanTenagaMedisId String fasilitaspendukung FasilitasPendukung @relation(fields: [fasilitasPendukungId], references: [id])
fasilitaspendukung FasilitasPendukung @relation(fields: [fasilitasPendukungId], references: [id]) fasilitasPendukungId String
fasilitasPendukungId String prosedurpendaftaran ProsedurPendaftaran @relation(fields: [prosedurPendaftaranId], references: [id])
prosedurpendaftaran ProsedurPendaftaran @relation(fields: [prosedurPendaftaranId], references: [id]) prosedurPendaftaranId String
prosedurPendaftaranId String tarifdanlayanan TarifDanLayanan[] @relation("Tarif")
tarifdanlayanan TarifDanLayanan @relation(fields: [tarifDanLayananId], references: [id])
tarifDanLayananId String
} }
model InformasiUmum { model InformasiUmum {
@@ -797,15 +824,20 @@ 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
createdAt DateTime @default(now()) jadwalLibur String?
updatedAt DateTime @updatedAt jamBukaOperasional String?
deletedAt DateTime @default(now()) jamTutupOperasional String?
isActive Boolean @default(true) jamBukaLibur String?
FasilitasKesehatan FasilitasKesehatan[] jamTutupLibur String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
isActive Boolean @default(true)
FasilitasKesehatan FasilitasKesehatan[] @relation("Dokter")
} }
model FasilitasPendukung { model FasilitasPendukung {
@@ -836,7 +868,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[] FasilitasKesehatan FasilitasKesehatan[] @relation("Tarif")
} }
// ========================================= JADWAL KEGIATAN ========================================= // // ========================================= JADWAL KEGIATAN ========================================= //
@@ -1942,23 +1974,28 @@ model KeunggulanProgram {
} }
model BeasiswaPendaftar { model BeasiswaPendaftar {
id String @id @default(cuid()) id String @id @default(cuid())
namaLengkap String namaLengkap String
nik String @unique nis String?
kelas String?
jenisKelamin JenisKelamin
alamatDomisili String?
tempatLahir String tempatLahir String
tanggalLahir DateTime tanggalLahir DateTime
jenisKelamin JenisKelamin namaOrtu String?
kewarganegaraan String nik String @unique
agama Agama pekerjaanOrtu String?
alamatKTP String penghasilan String?
alamatDomisili String?
noHp String noHp String
email String @unique kewarganegaraan String?
statusPernikahan StatusPernikahan agama Agama?
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 {
@@ -2130,25 +2167,28 @@ 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
role Role @relation(fields: [roleId], references: [id]) roleId String @default("2")
roleId String @default("1") isActive Boolean @default(false)
instansi String? sessionInvalid Boolean @default(false)
UserSession UserSession? // Nama instansi (Puskesmas, Sekolah, dll) lastLogin DateTime?
isActive Boolean @default(true) createdAt DateTime @default(now())
lastLogin DateTime? updatedAt DateTime @default(now()) @updatedAt
createdAt DateTime @default(now()) permissions Json?
updatedAt DateTime @updatedAt sessions UserSession[] // ✅ Relasi one-to-many
deletedAt DateTime? role Role @relation(fields: [roleId], references: [id])
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 // Menyimpan permission dalam format JSON permissions Json?
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -2167,26 +2207,32 @@ model KodeOtp {
otp Int otp Int
} }
// Tabel untuk menyimpan permission model UserSession {
model Permission { id String @id @default(cuid())
id String @id @default(cuid()) token String @db.Text // ✅ JWT bisa panjang
name String @unique expiresAt DateTime // ✅ Ubah jadi expiresAt (konsisten)
description String? active Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @default(now()) @updatedAt
@@map("permissions") user User @relation(fields: [userId], references: [id], onDelete: Cascade)
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 UserSession { model UserMenuAccess {
id String @id @default(cuid()) id String @id @default(cuid())
token String userId String
expires DateTime? menuId String // ID menu (misal: "Landing Page", "Kesehatan")
active Boolean @default(true) createdAt DateTime @default(now())
createdAt DateTime @default(now()) updatedAt DateTime @updatedAt
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,3 +1,4 @@
/* 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";
@@ -54,73 +55,110 @@ 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) {
await safeSeedUnique("role", { id: r.id }, { try {
name: r.name, // ✅ Destructure to remove permissions if exists
description: r.description, const { permissions, ...roleData } = r as any;
permissions: r.permissions,
isActive: r.isActive, await safeSeedUnique(
}); "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");
console.log("✅ Roles seeded"); // =========== USER ===========
// =========== USERS ===========
console.log("🔄 Seeding users..."); console.log("🔄 Seeding users...");
for (const u of users) { for (const u of users) {
// First verify the role exists try {
const roleExists = await prisma.role.findUnique({ // Verify role exists first
where: { id: u.roleId }, const roleExists = await prisma.role.findUnique({
}); where: { id: u.roleId.toString() },
select: { id: true }, // Only select id to minimize query
if (!roleExists) {
console.error(`❌ Role with id ${u.roleId} not found for user ${u.nama}`);
continue;
}
await safeSeedUnique("user", { id: u.id }, {
username: u.nama,
nomor: u.nomor,
roleId: u.roleId,
isActive: u.isActive,
}); });
if (!roleExists) {
console.error(
`❌ Role with id ${u.roleId} not found for user ${u.username}`
);
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);
}
}
} }
console.log("✅ Users seeded"); console.log("✅ Users seeding completed");
// =========== FILE STORAGE =========== // =========== FILE STORAGE ===========
console.log("🔄 Seeding file storage..."); console.log("🔄 Seeding file storage...");
for (const f of fileStorage) { for (const f of fileStorage) {
await prisma.fileStorage.upsert({ try {
where: { id: f.id }, await prisma.fileStorage.upsert({
update: { where: { id: f.id },
name: f.name, update: {
realName: f.realName, name: f.name,
path: f.path, realName: f.realName,
mimeType: f.mimeType, path: f.path,
link: f.link, mimeType: f.mimeType,
category: f.category, link: f.link,
}, category: f.category,
create: { },
id: f.id, create: {
name: f.name, id: f.id,
realName: f.realName, name: f.name,
path: f.path, realName: f.realName,
mimeType: f.mimeType, path: f.path,
link: f.link, mimeType: f.mimeType,
category: f.category, link: f.link,
}, 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 ===========
@@ -539,15 +577,40 @@ 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) {
await prisma.pegawaiPPID.upsert({ if (emails.has(p.email)) {
where: { id: p.id }, console.warn(`⚠️ Duplicate email found in pegawaiPPID: ${p.email}`);
update: p, }
create: p, emails.add(p.email);
});
} }
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 ===========
@@ -811,7 +874,9 @@ import { safeSeedUnique } from "./safeseedUnique";
const flattenedPosisiBumdes = posisiOrganisasi.flat(); const flattenedPosisiBumdes = posisiOrganisasi.flat();
// ✅ Urutkan berdasarkan hierarki // ✅ Urutkan berdasarkan hierarki
const sortedPosisiBumdes = flattenedPosisiBumdes.sort((a, b) => a.hierarki - b.hierarki); const sortedPosisiBumdes = flattenedPosisiBumdes.sort(
(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})`);
@@ -891,7 +956,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) {
@@ -1183,7 +1248,6 @@ import { safeSeedUnique } from "./safeseedUnique";
// seed assets // seed assets
await seedAssets(); await seedAssets();
})() })()
.then(() => prisma.$disconnect()) .then(() => prisma.$disconnect())
.catch((e) => { .catch((e) => {

BIN
public/mangupuraaward.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

View File

@@ -0,0 +1,76 @@
'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

@@ -0,0 +1,56 @@
'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

@@ -9,29 +9,30 @@ 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 harus diisi"), fasilitas: z.string().min(1),
alamat: z.string().min(1, "Alamat harus diisi"), alamat: z.string().min(1),
jamOperasional: z.string().min(1, "Jam operasional harus diisi"), jamOperasional: z.string().min(1),
}), }),
layananUnggulan: z.object({ layananUnggulan: z.object({
content: z.string().min(1, "Layanan unggulan harus diisi"), content: z.string().min(1),
}),
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, "Fasilitas pendukung harus diisi"), content: z.string().min(1),
}), }),
prosedurPendaftaran: z.object({ prosedurPendaftaran: z.object({
content: z.string().min(1, "Prosedur pendaftaran harus diisi"), content: z.string().min(1),
}),
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
@@ -45,21 +46,34 @@ const defaultForm = {
layananUnggulan: { layananUnggulan: {
content: "", content: "",
}, },
dokterdanTenagaMedis: {
name: "", dokterdanTenagaMedis: [] as string[], // ← array kosong
specialist: "", tarifDanLayanan: [] as string[], // ← array kosong
jadwal: "",
},
fasilitasPendukung: { fasilitasPendukung: {
content: "", content: "",
}, },
prosedurPendaftaran: { prosedurPendaftaran: {
content: "", content: "",
}, },
tarifDanLayanan: { };
layanan: "",
tarif: "", type DokterItem = {
}, 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({
@@ -186,33 +200,26 @@ 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;
fasilitasKesehatan.edit.id = data.id; this.form = {
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,
}, },
tarifDanLayanan: { // map relasi -> array of IDs
layanan: data.tarifdanlayanan.layanan, layananUnggulan: {
tarif: data.tarifdanlayanan.tarif, content: data.layananunggulan.content,
}, },
dokterdanTenagaMedis: data.dokterdantenagamedis?.map((v: DokterItem) => v.id) ?? [],
tarifDanLayanan: data.tarifdanlayanan?.map((v: TarifItem) => v.id) ?? [],
}; };
}, },
async submit() { async submit() {
@@ -238,22 +245,15 @@ const fasilitasKesehatan = proxy({
layananUnggulan: { layananUnggulan: {
content: fasilitasKesehatan.edit.form.layananUnggulan.content, content: fasilitasKesehatan.edit.form.layananUnggulan.content,
}, },
dokterdanTenagaMedis: { dokterdanTenagaMedis:
name: fasilitasKesehatan.edit.form.dokterdanTenagaMedis.name, fasilitasKesehatan.edit.form.dokterdanTenagaMedis,
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: { tarifDanLayanan: fasilitasKesehatan.edit.form.tarifDanLayanan,
layanan: fasilitasKesehatan.edit.form.tarifDanLayanan.layanan,
tarif: fasilitasKesehatan.edit.form.tarifDanLayanan.tarif,
},
}; };
const res = await fetch( const res = await fetch(
@@ -320,12 +320,26 @@ 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({
@@ -463,6 +477,11 @@ 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 {
@@ -487,6 +506,11 @@ 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);
@@ -567,9 +591,255 @@ 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

@@ -5,58 +5,117 @@ import { toast } from "react-toastify";
import { proxy } from "valtio"; import { proxy } from "valtio";
import { z } from "zod"; import { z } from "zod";
const templateapbDesaForm = z.object({ // --- Zod Schema ---
name: z.string().min(1, "Judul minimal 1 karakter"), const ApbdesItemSchema = z.object({
jumlah: z.string().min(1, "Deskripsi minimal 1 karakter"), kode: z.string().min(1, "Kode wajib diisi"),
imageId: z.string().min(1, "File minimal 1"), uraian: z.string().min(1, "Uraian wajib diisi"),
fileId: z.string().min(1, "File minimal 1"), anggaran: z.number().min(0),
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 defaultapbdesForm = { const ApbdesFormSchema = z.object({
name: "", tahun: z.number().int().min(2000, "Tahun tidak valid"),
jumlah: "", imageId: z.string().min(1, "Gambar wajib diunggah"),
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,
async create() {
const cek = templateapbDesaForm.safeParse(apbdes.create.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
return toast.error(err);
}
try {
apbdes.create.loading = true;
const res = await ApiFetch.api.landingpage.apbdes["create"].post({
...apbdes.create.form,
});
if (res.status === 200) { 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() {
const parsed = ApbdesFormSchema.safeParse(this.form);
if (!parsed.success) {
const errors = parsed.error.issues.map((issue) => `${issue.path.join(".")} - ${issue.message}`);
toast.error(`Validasi gagal:\n${errors.join("\n")}`);
return;
}
try {
this.loading = true;
const res = await ApiFetch.api.landingpage.apbdes["create"].post(parsed.data);
if (res.data?.success) {
toast.success("APBDes berhasil dibuat");
apbdes.findMany.load(); apbdes.findMany.load();
return toast.success("Data berhasil ditambahkan"); this.reset();
} else {
toast.error(res.data?.message || "Gagal membuat APBDes");
} }
return toast.error("Gagal menambahkan data"); } catch (error: any) {
} catch (error) { console.error("Create APBDes error:", error);
console.log(error); toast.error(error?.message || "Terjadi kesalahan saat membuat APBDes");
toast.error("Gagal menambahkan data");
} finally { } finally {
apbdes.create.loading = false; this.loading = false;
} }
}, },
}, },
findMany: { findMany: {
data: null as data: null as
| Prisma.APBDesGetPayload<{ | Prisma.APBDesGetPayload<{
include: { include: { image: true; file: true; items: true };
image: true;
file: true;
};
}>[] }>[]
| null, | null,
page: 1, page: 1,
@@ -64,194 +123,202 @@ const apbdes = proxy({
total: 0, total: 0,
loading: false, loading: false,
search: "", search: "",
load: async (page = 1, limit = 10, search = "") => { // Change to arrow function
apbdes.findMany.loading = true; // Use the full path to access the property load: async (page = 1, limit = 10, search = "") => {
apbdes.findMany.loading = true;
apbdes.findMany.page = page; apbdes.findMany.page = page;
apbdes.findMany.search = search; apbdes.findMany.search = search;
try { try {
const query: any = { page, limit }; const query: Record<string, string> = { page: String(page), limit: String(limit) };
if (search) query.search = search; if (search) query.search = search;
const res = await ApiFetch.api.landingpage.apbdes[ const res = await ApiFetch.api.landingpage.apbdes["findMany"].get({ query });
"findMany"
].get({ if (res.data?.success) {
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.total || 0; apbdes.findMany.total = res.data.meta?.total || 0;
apbdes.findMany.totalPages = res.data.totalPages || 1; apbdes.findMany.totalPages = res.data.meta?.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("Error loading pegawai:", error); console.error("FindMany error:", 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 Prisma.APBDesGetPayload<{ data: null as
include: { | Prisma.APBDesGetPayload<{
image: true; include: { image: true; file: true; items: true };
file: true; }>
}; | null,
}> | null, loading: false,
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 {
const res = await fetch(`/api/landingpage/apbdes/${id}`); // Pastikan URL-nya benar
if (res.ok) { const url = `/api/landingpage/apbdes/${id}`;
const data = await res.json(); console.log("🌐 Fetching:", url);
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 {
console.error("Failed to fetch data", res.status, res.statusText); this.data = null;
apbdes.findUnique.data = null; this.error = res.message || "Gagal memuat detail APBDes";
toast.error(this.error);
} }
} catch (error) { } catch (error) {
console.error("Error fetching data:", error); console.error("❌ FindUnique error:", error);
apbdes.findUnique.data = null; this.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 {
apbdes.delete.loading = true; this.loading = true;
const res = await (ApiFetch.api.landingpage.apbdes as any)["del"][id].delete();
const response = await fetch(`/api/landingpage/apbdes/del/${id}`, { if (res.data?.success) {
method: "DELETE", toast.success("APBDes berhasil dihapus");
headers: { apbdes.findMany.load();
"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(result?.message || "Gagal menghapus apbdes"); toast.error(res.data?.message || "Gagal menghapus APBDes");
} }
} catch (error) { } catch (error: any) {
console.error("Gagal delete:", error); console.error("Delete error:", error);
toast.error("Terjadi kesalahan saat menghapus apbdes"); toast.error(error?.message || "Terjadi kesalahan saat menghapus");
} finally { } finally {
apbdes.delete.loading = false; this.loading = false;
} }
}, },
}, },
edit: { edit: {
id: "", id: "",
form: { ...defaultapbdesForm }, form: { ...defaultApbdesForm },
loading: false, loading: false,
async load(id: string) { async load(id: string) {
if (!id) { if (!id) return toast.warn("ID tidak valid");
toast.warn("ID tidak valid");
return null;
}
try { try {
apbdes.edit.loading = true; this.loading = true;
const res = await (ApiFetch.api.landingpage.apbdes as any)[id].get();
const response = await fetch(`/api/landingpage/apbdes/${id}`, { if (res.data?.success) {
method: "GET", const data = res.data.data;
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 = {
name: data.name, tahun: data.tahun || new Date().getFullYear(),
jumlah: data.jumlah, imageId: data.imageId || "",
imageId: data.imageId, fileId: data.fileId || "",
fileId: data.fileId, items: (data.items || []).map((item: any) => ({
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(result?.message || "Gagal memuat data"); throw new Error(res.data?.message || "Gagal memuat data");
} }
} catch (error) { } catch (error: any) {
console.error("Error loading apbdes:", error); console.error("Edit load error:", error);
toast.error( toast.error(error.message || "Gagal memuat data untuk diedit");
error instanceof Error ? error.message : "Gagal memuat data"
);
return null;
} finally { } finally {
apbdes.edit.loading = false; this.loading = false;
} }
}, },
async update() { async update() {
const cek = templateapbDesaForm.safeParse(apbdes.edit.form); const parsed = ApbdesFormSchema.safeParse(this.form);
if (!cek.success) { if (!parsed.success) {
const err = `[${cek.error.issues const errors = parsed.error.issues.map((issue) => `${issue.path.join(".")} - ${issue.message}`);
.map((v) => `${v.path.join(".")}`) toast.error(`Validasi gagal:\n${errors.join("\n")}`);
.join("\n")}] required`; return false;
return toast.error(err);
} }
try { try {
apbdes.edit.loading = true; this.loading = true;
const response = await fetch(`/api/landingpage/apbdes/${this.id}`, { // Include the ID in the request body
method: "PUT", const requestData = {
headers: { ...parsed.data,
"Content-Type": "application/json", id: this.id, // Add the ID to the request body
}, };
body: JSON.stringify({
name: this.form.name, const res = await (ApiFetch.api.landingpage.apbdes as any)[this.id].put(requestData);
jumlah: this.form.jumlah,
imageId: this.form.imageId, if (res.data?.success) {
fileId: this.form.fileId, toast.success("APBDes berhasil diperbarui");
}), 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(result.message || "Gagal mengupdate apbdes"); throw new Error(res.data?.message || "Gagal memperbarui APBDes");
} }
} catch (error) { } catch (error: any) {
console.error("Error updating apbdes:", error); console.error("Update error:", error);
toast.error( toast.error(error.message || "Gagal memperbarui APBDes");
error instanceof Error ? error.message : "Gagal mengupdate apbdes"
);
return false; return false;
} finally { } finally {
apbdes.edit.loading = false; this.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() {
apbdes.edit.id = ""; this.id = "";
apbdes.edit.form = { ...defaultapbdesForm }; this.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() {
@@ -71,20 +71,21 @@ const programInovasi = 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 = "") => {
programInovasi.findMany.loading = true; // Use the full path to access the property // Change to arrow function
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;
@@ -389,7 +390,10 @@ 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(`/api/landingpage/pejabatdesa/${encodeURIComponent(this.id)}`, window.location.origin); const url = new URL(
`/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: {
@@ -438,16 +442,19 @@ 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().min(1, "Gambar wajib dipilih"), imageId: z.string().nullable().optional(),
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; imageId: string | null; // boleh null
iconUrl: string; iconUrl: string;
icon: string | null; // boleh null
}; };
const mediaSosial = proxy({ const mediaSosial = proxy({
create: { create: {
form: {} as MediaSosialForm, form: {} as MediaSosialForm,
@@ -455,9 +462,10 @@ 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 || "", imageId: mediaSosial.create.form.imageId ?? null, // FIXED
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);
@@ -492,20 +500,19 @@ const mediaSosial = 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 = "") => {
mediaSosial.findMany.loading = true; // Use the full path to access the property // Change to arrow function
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[ const res = await ApiFetch.api.landingpage.mediasosial["findMany"].get({
"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;
@@ -537,7 +544,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}`);
@@ -586,66 +593,72 @@ 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}`);
} }
mediaSosial.update.loading = true; // ✅ Tambahkan ini di awal const result = await response.json();
try { if (result?.success) {
const response = await fetch(`/api/landingpage/mediasosial/${id}`, { const data = result.data;
method: "GET", this.id = data.id;
headers: { this.form = {
"Content-Type": "application/json", name: data.name || "",
}, 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 {
const result = await response.json(); throw new Error(
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);
async update() { toast.error("Terjadi kesalahan saat mengambil data media sosial");
const cek = templateMediaSosial.safeParse(mediaSosial.update.form); } finally {
if (!cek.success) { mediaSosial.update.loading = false; // ✅ Supaya berhenti loading walau error
const err = `[${cek.error.issues }
.map((v) => `${v.path.join(".")}`) },
.join("\n")}] required`;
toast.error(err); async update() {
return false; const cek = templateMediaSosial.safeParse(mediaSosial.update.form);
} if (!cek.success) {
const err = `[${cek.error.issues
try { .map((v) => `${v.path.join(".")}`)
mediaSosial.update.loading = true; .join("\n")}] required`;
toast.error(err);
const response = await fetch(`/api/landingpage/mediasosial/${this.id}`, { return false;
}
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",
@@ -654,38 +667,40 @@ 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) {
if (result.success) { const errorData = await response.json().catch(() => ({}));
toast.success("Berhasil update media sosial"); throw new Error(
await mediaSosial.findMany.load(); // refresh list errorData.message || `HTTP error! status: ${response.status}`
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

@@ -9,34 +9,32 @@ 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"),
nik: z.string().min(1, "NIK harus diisi"), nis: z.string().min(1, "NIS 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"),
jenisKelamin: z.string().min(1, "Jenis kelamin harus diisi"), namaOrtu: z.string().min(1, "Nama ortu harus diisi"),
kewarganegaraan: z.string().min(1, "Kewarganegaraan harus diisi"), nik: z.string().min(1, "NIK harus diisi"),
agama: z.string().min(1, "Agama harus diisi"), pekerjaanOrtu: z.string().min(1, "Pekerjaan ortu harus diisi"),
alamatKTP: z.string().min(1, "Alamat KTP harus diisi"), penghasilan: z.string().min(1, "Penghasilan ortu 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: "",
nik: "", nis: "",
kelas: "",
jenisKelamin: "",
alamatDomisili: "",
tempatLahir: "", tempatLahir: "",
tanggalLahir: "", tanggalLahir: "",
jenisKelamin: "", namaOrtu: "",
kewarganegaraan: "", nik: "",
agama: "", pekerjaanOrtu: "",
alamatKTP: "", penghasilan: "",
alamatDomisili: "",
noHp: "", noHp: "",
email: "",
statusPernikahan: "",
ukuranBaju: "",
}; };
const beasiswaPendaftar = proxy({ const beasiswaPendaftar = proxy({
@@ -200,18 +198,17 @@ const beasiswaPendaftar = proxy({
this.id = data.id; this.id = data.id;
this.form = { this.form = {
namaLengkap: data.namaLengkap, namaLengkap: data.namaLengkap,
nik: data.nik, nis: data.nis,
kelas: data.kelas,
jenisKelamin: data.jenisKelamin,
alamatDomisili: data.alamatDomisili,
tempatLahir: data.tempatLahir, tempatLahir: data.tempatLahir,
tanggalLahir: data.tanggalLahir, tanggalLahir: data.tanggalLahir,
jenisKelamin: data.jenisKelamin, namaOrtu: data.namaOrtu,
kewarganegaraan: data.kewarganegaraan, nik: data.nik,
agama: data.agama, pekerjaanOrtu: data.pekerjaanOrtu,
alamatKTP: data.alamatKTP, penghasilan: data.penghasilan,
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 {
@@ -249,17 +246,17 @@ const beasiswaPendaftar = proxy({
}, },
body: JSON.stringify({ body: JSON.stringify({
namaLengkap: this.form.namaLengkap, namaLengkap: this.form.namaLengkap,
nik: this.form.nik, nis: this.form.nis,
tanggalLahir: this.form.tanggalLahir, kelas: this.form.kelas,
jenisKelamin: this.form.jenisKelamin, jenisKelamin: this.form.jenisKelamin,
kewarganegaraan: this.form.kewarganegaraan,
agama: this.form.agama,
alamatKTP: this.form.alamatKTP,
alamatDomisili: this.form.alamatDomisili, alamatDomisili: this.form.alamatDomisili,
tempatLahir: this.form.tempatLahir,
tanggalLahir: this.form.tanggalLahir,
namaOrtu: this.form.namaOrtu,
nik: this.form.nik,
pekerjaanOrtu: this.form.pekerjaanOrtu,
penghasilan: this.form.penghasilan,
noHp: this.form.noHp, noHp: this.form.noHp,
email: this.form.email,
statusPernikahan: this.form.statusPernikahan,
ukuranBaju: this.form.ukuranBaju,
}), }),
} }
); );

View File

@@ -6,145 +6,176 @@ 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.string().min(3, "NIK minimal 3 karakter"), nik: z
notelp: z.string().min(3, "Nomor Telepon minimal 3 karakter"), .string()
.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 = await ApiFetch.api.ppid.permohonaninformasipublik.jenisInformasi["find-many"].get(); const res =
if (res.status === 200) { await ApiFetch.api.ppid.permohonaninformasipublik.jenisInformasi[
jenisInformasiDiminta.findMany.data = res.data?.data ?? []; "find-many"
} ].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<{ omit: { isActive: true } }>[], | Prisma.CaraMemperolehInformasiGetPayload<{
async load() { omit: { isActive: true };
const res = await ApiFetch.api.ppid.permohonaninformasipublik.memperolehInformasi["find-many"].get(); }>[],
if (res.status === 200) { async load() {
caraMemperolehInformasi.findMany.data = res.data?.data ?? []; const res =
} 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<{ omit: { isActive: true } }>[], | Prisma.CaraMemperolehSalinanInformasiGetPayload<{
async load() { omit: { isActive: true };
const res = await ApiFetch.api.ppid.permohonaninformasipublik.salinanInformasi["find-many"].get(); }>[],
if (res.status === 200) { async load() {
caraMemperolehSalinanInformasi.findMany.data = res.data?.data ?? []; const res =
} await ApiFetch.api.ppid.permohonaninformasipublik.salinanInformasi[
} "find-many"
} ].get();
}) if (res.status === 200) {
console.log(caraMemperolehSalinanInformasi) caraMemperolehSalinanInformasi.findMany.data = res.data?.data ?? [];
}
},
},
});
console.log(caraMemperolehSalinanInformasi);
type PermohonanInformasiPublikForm = Prisma.PermohonanInformasiPublikGetPayload<{ type PermohonanInformasiPublikForm =
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(statepermohonanInformasiPublik.create.form); const cek = templateForm.safeParse(
if(!cek.success) { statepermohonanInformasiPublik.create.form
const err = `[${cek.error.issues );
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`; if (!cek.success) {
return toast.error(err); toast.error(cek.error.issues.map((i) => i.message).join("\n"));
} return false; // ⬅️ tambahkan return false
try { }
statepermohonanInformasiPublik.create.loading = true;
const res = await ApiFetch.api.ppid.permohonaninformasipublik["create"].post(statepermohonanInformasiPublik.create.form); try {
if (res.status === 200) { statepermohonanInformasiPublik.create.loading = true;
statepermohonanInformasiPublik.findMany.load(); const res = await ApiFetch.api.ppid.permohonaninformasipublik[
return toast.success("Sukses menambahkan"); "create"
} ].post(statepermohonanInformasiPublik.create.form);
return toast.error("failed create");
} catch (error) { if (res.data?.success === false) {
console.log((error as Error).message); toast.error(res.data?.message);
} finally { return false; // ⬅️ gagal
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: { },
data: null as findMany: {
| Prisma.PermohonanInformasiPublikGetPayload<{ include: { data: null as
caraMemperolehSalinanInformasi: true, | Prisma.PermohonanInformasiPublikGetPayload<{
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: {
jenisInformasiDiminta: true, caraMemperolehSalinanInformasi: true;
caraMemperolehInformasi: true, jenisInformasiDiminta: true;
caraMemperolehSalinanInformasi: true, caraMemperolehInformasi: true;
}; };
}> | null, }>[]
async load(id: string) { | null,
try { async load() {
const res = await fetch(`/api/ppid/permohonaninformasipublik/${id}`); const res = await ApiFetch.api.ppid.permohonaninformasipublik[
if (res.ok) { "find-many"
const data = await res.json(); ].get();
statepermohonanInformasiPublik.findUnique.data = data.data ?? null; if (res.status === 200) {
} else { statepermohonanInformasiPublik.findMany.data = res.data?.data ?? [];
console.error("Failed to fetch program inovasi:", res.statusText); }
statepermohonanInformasiPublik.findUnique.data = null; },
} },
} catch (error) { findUnique: {
console.error("Error fetching program inovasi:", error); data: null as Prisma.PermohonanInformasiPublikGetPayload<{
statepermohonanInformasiPublik.findUnique.data = null; 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

@@ -5,82 +5,99 @@ 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.string().min(3, "Nomor Telepon minimal 3 karakter"), notelp: z
alasan: z.string().min(3, "Alasan minimal 3 karakter"), .string()
}) .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 = Prisma.FormulirPermohonanKeberatanGetPayload<{ type PermohonanKeberatanInformasiForm =
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(permohonanKeberatanInformasi.create.form); const cek = templateForm.safeParse(
if(!cek.success) { permohonanKeberatanInformasi.create.form
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 {
permohonanKeberatanInformasi.create.loading = true;
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik["create"].post(permohonanKeberatanInformasi.create.form);
if (res.status === 200) {
permohonanKeberatanInformasi.findMany.load();
return toast.success("Sukses menambahkan");
}
return toast.error("failed create");
} catch (error) {
console.log((error as Error).message);
} finally {
permohonanKeberatanInformasi.create.loading = false;
}
},
},
findMany: {
data: null as
| Prisma.FormulirPermohonanKeberatanGetPayload<{omit: {isActive: true}}>[]
| null,
async load() {
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik["find-many"].get();
if (res.status === 200) {
permohonanKeberatanInformasi.findMany.data = res.data?.data ?? [];
}
}
},
findUnique: {
data: null as Prisma.FormulirPermohonanKeberatanGetPayload<{
omit: {
isActive: true;
};
}> | null,
async load(id: string) {
try {
const res = await fetch(`/api/ppid/permohonankeberataninformasipublik/${id}`);
if (res.ok) {
const data = await res.json();
permohonanKeberatanInformasi.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch permohonan keberatan informasi:", res.statusText);
permohonanKeberatanInformasi.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching permohonan keberatan informasi:", error);
permohonanKeberatanInformasi.findUnique.data = null;
}
},
} }
try {
permohonanKeberatanInformasi.create.loading = true;
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik[
"create"
].post(permohonanKeberatanInformasi.create.form);
if (res.data?.success === false) {
toast.error(res.data?.message);
return false; // ⬅️ gagal
}
toast.success("Sukses menambahkan");
return true; // ⬅️ sukses
} catch {
toast.error("Terjadi kesalahan server");
return false;
} finally {
permohonanKeberatanInformasi.create.loading = false;
}
},
},
findMany: {
data: null as
| Prisma.FormulirPermohonanKeberatanGetPayload<{
omit: { isActive: true };
}>[]
| null,
async load() {
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik[
"find-many"
].get();
if (res.status === 200) {
permohonanKeberatanInformasi.findMany.data = res.data?.data ?? [];
}
},
},
findUnique: {
data: null as Prisma.FormulirPermohonanKeberatanGetPayload<{
omit: {
isActive: true;
};
}> | null,
async load(id: string) {
try {
const res = await fetch(
`/api/ppid/permohonankeberataninformasipublik/${id}`
);
if (res.ok) {
const data = await res.json();
permohonanKeberatanInformasi.findUnique.data = data.data ?? null;
} else {
console.error(
"Failed to fetch permohonan keberatan informasi:",
res.statusText
);
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

@@ -90,42 +90,96 @@ const userState = proxy({
} }
}, },
}, },
updateActive: { deleteUser: {
loading: false, loading: false,
async submit(id: string, isActive: boolean) {
this.loading = true; async delete(id: string) {
if (!id) return toast.warn("ID tidak valid");
try { try {
const res = await fetch(`/api/user/updt`, { userState.deleteUser.loading = true;
method: "PUT",
headers: { "Content-Type": "application/json" }, const response = await fetch(`/api/user/delUser/${id}`, {
body: JSON.stringify({ id, isActive }), method: "DELETE",
headers: {
"Content-Type": "application/json",
},
}); });
const data = await res.json(); const result = await response.json();
if (res.status === 200 && data.success) {
toast.success(data.message); if (response.ok && result?.success) {
userState.findMany.load(userState.findMany.page, 10, userState.findMany.search); toast.success(result.message || "User berhasil dihapus permanen");
await userState.findMany.load(); // refresh list user setelah delete
} else { } else {
toast.error(data.message || "Gagal update status user"); toast.error(result?.message || "Gagal menghapus user");
} }
} catch (e) { } catch (error) {
console.error(e); console.error("Gagal delete user:", error);
toast.error("Gagal update status user"); toast.error("Terjadi kesalahan saat menghapus user");
} finally { } finally {
this.loading = false; userState.deleteUser.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({
@@ -237,7 +291,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",
@@ -245,31 +299,25 @@ 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;
this.form = { // langsung set melalui root state, bukan this
roleState.update.id = data.id;
roleState.update.form = {
name: data.name, name: data.name,
permissions: data.permissions,
}; };
return data; // Return the loaded data
} else { return data;
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( toast.error("Gagal memuat data");
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) {
@@ -290,7 +338,6 @@ const roleState = proxy({
}, },
body: JSON.stringify({ body: JSON.stringify({
name: this.form.name, name: this.form.name,
permissions: this.form.permissions,
}), }),
}); });

View File

@@ -1,104 +1,103 @@
'use client' 'use client';
import { apiFetchLogin } from '@/app/admin/auth/_lib/api_fetch_auth'; import { apiFetchLogin } from '@/app/api/auth/_lib/api_fetch_auth';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Flex, Image, Paper, Stack, Text, Title } from '@mantine/core'; import { Box, Button, Center, Image, Paper, Stack, 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 [isError, setError] = useState(false) const [loading, setLoading] = 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: nomor }) const response = await apiFetchLogin({ nomor: cleanPhone });
if (response && response.success) {
localStorage.setItem("hipmi_auth_code_id", response.kodeId); console.log(response);
toast.success(response.message);
router.push("/validasi", { scroll: false }); if (!response.success) {
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 {
setLoading(false); // ❌ User baru: langsung ke registrasi (tanpa kodeId)
toast.error(response?.message); router.push('/registrasi');
} }
} catch (error) { } catch (error) {
setLoading(false) console.error('Error Login:', error);
console.log("Error Login", error) toast.error('Terjadi kesalahan saat login');
toast.error("Terjadi kesalahan saat login") } finally {
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']}> <Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '100%', sm: 400 }}>
<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 loading="lazy" src={"/darmasaba-icon.png"} alt="" w={80} /> <Image
loading="lazy"
src="/darmasaba-icon.png"
alt="Logo"
w={80}
h={80}
/>
</Center> </Center>
</Box> </Box>
<Box> <Box w="100%">
{/* <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"
onChange={(val) => { value={phone}
setPhone(val); onChange={(val) => setPhone(val)}
}}
/> />
{isError ? ( <Box py={20}>
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 ? true : false} loading={loading}
>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>
@@ -108,4 +107,4 @@ function Login() {
); );
} }
export default Login; export default Login;

View File

@@ -1,113 +1,153 @@
/* eslint-disable @typescript-eslint/no-unused-expressions */ // app/registrasi/page.tsx
'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 { Box, Button, Center, Checkbox, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import {
Box, Button, Center, Checkbox, Image, Paper, Stack, Text, TextInput, Title,
} from '@mantine/core';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useEffect, 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 Registrasi() { export default function Registrasi() {
const [phone, setPhone] = useState("") const router = useRouter();
const router = useRouter() const [username, setUsername] = useState('');
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)
async function onRegistarsi() { // Ambil data dari localStorage (dari login)
if (value.length < 5) { useEffect(() => {
toast.error("Username minimal 5 karakter!"); const storedNomor = localStorage.getItem('auth_nomor');
if (!storedNomor) {
toast.error('Akses tidak valid');
router.push('/login');
return; return;
} }
setPhone(storedNomor);
if (value.includes(" ")) { }, [router]);
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(' ')) {
if (!phone) { toast.error('Username tidak boleh ada spasi!');
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);
const respone = await apiFetchRegister({ nomor: phone, username: value }); // ✅ Hanya kirim username & nomor → dapat kodeId
const response = await apiFetchRegister({ username, nomor: cleanPhone });
if (respone.success) { if (response.success) {
router.push("/login", { scroll: false }); // Simpan sementara
toast.success(respone.message); localStorage.setItem('auth_kodeId', response.kodeId);
localStorage.setItem('auth_username', username); // simpan username
} else { toast.success('Kode verifikasi dikirim!');
setLoading(false); router.push('/validasi'); // ✅ ke halaman validasi
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']}> <Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '100%', sm: 400 }}>
<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> <Box w="100%">
<TextInput placeholder='Username' <TextInput
label='Username' label="Username"
maxLength={50} placeholder="Username"
value={username}
onChange={(e) => setUsername(e.currentTarget.value)}
error={ error={
value.length > 0 && value.length < 5 username.length > 0 && username.length < 5
? "Minimal 5 karakter !" ? 'Minimal 5 karakter!'
: value.includes(" ") : username.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}>
<Text fz={"sm"} >Nomor Telepon</Text> <Box pt="md">
<Text fz="sm">Nomor Telepon</Text>
<PhoneInput <PhoneInput
countrySelectorStyleProps={{
buttonStyle: {
backgroundColor: colors['blue-button'],
},
}}
inputStyle={{ width: "100%" }}
defaultCountry="id" defaultCountry="id"
onChange={(val) => { value={phone}
setPhone(val); disabled
}}
/> />
</Box> </Box>
<Box pb={10}>
<Box pt="md">
<Checkbox <Checkbox
label="Saya menyetujui syarat dan ketentuan yang berlaku" checked={agree}
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>
@@ -116,6 +156,4 @@ function Registrasi() {
</Box> </Box>
</Stack> </Stack>
); );
} }
export default Registrasi;

View File

@@ -1,31 +1,306 @@
'use client' 'use client';
import colors from '@/con/colors';
import { Box, Button, Paper, PinInput, Stack, Text, Title } from '@mantine/core'; import colors from '@/con/colors';
import { useRouter } from 'next/navigation'; import {
Box,
Button,
Center,
Loader,
Paper,
PinInput,
Stack,
Text,
Title,
} from '@mantine/core';
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']}> <Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '100%', sm: 400 }}>
<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']}>
Kode Verifikasi {isRegistrationFlow ? 'Verifikasi Registrasi' : 'Verifikasi Login'}
</Title> </Title>
<Text ta="center" size="sm" c="dimmed" mt="xs">
Kami telah mengirim kode ke nomor <strong>{nomor}</strong>
</Text>
</Box> </Box>
<Box> <Box w="100%">
<Box mb={10}> <Box mb={20}>
<Text c={colors['blue-button']} ta={"center"} fz={"sm"} fw={'bold'}>Masukkan Kode Verifikasi</Text> <Text c={colors['blue-button']} ta="center" fz="sm" fw="bold">
<PinInput type={/^[0-9]*$/} inputType="tel" inputMode="numeric" /> Masukkan Kode Verifikasi
</Text>
<Center>
<PinInput
length={4}
value={otp}
onChange={setOtp}
onComplete={handleVerify}
inputMode="numeric"
size="lg"
/>
</Center>
</Box> </Box>
<Box py={20} >
<Button onClick={() => router.push("/admin/landing-page/profile/program-inovasi")}> <Button
Page fullWidth
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>
</Box> </Text>
</Box> </Box>
</Stack> </Stack>
</Paper> </Paper>
@@ -33,6 +308,4 @@ function Validasi() {
</Box> </Box>
</Stack> </Stack>
); );
} }
export default Validasi;

View File

@@ -111,7 +111,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"

View File

@@ -0,0 +1,303 @@
/* 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: "sm", md: "lg" }} py="md">
{/* 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

@@ -0,0 +1,175 @@
'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 py={10}>
<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="100%"
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

@@ -0,0 +1,228 @@
'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: 'sm', md: 'lg' }} py="md">
{/* 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,157 +1,163 @@
"use client"; 'use client'
import stateFileStorage from "@/state/state-list-image"; import colors from '@/con/colors';
import { import {
ActionIcon,
Box, Box,
Card, Button,
Flex, Center,
Group, Group,
Image,
Pagination, Pagination,
Paper, Paper,
SimpleGrid, Skeleton,
Stack, Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text, Text,
TextInput,
Title Title
} from "@mantine/core"; } from '@mantine/core';
import { useShallowEffect } from "@mantine/hooks"; import { useShallowEffect } from '@mantine/hooks';
import { IconSearch, IconTrash, IconX } from "@tabler/icons-react"; import { IconDeviceImac, IconPlus, IconSearch } from '@tabler/icons-react';
import { motion } from "framer-motion"; import { useRouter } from 'next/navigation';
import toast from "react-simple-toasts"; import { useState } from 'react';
import { useSnapshot } from "valtio"; import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header';
export default function ListImage() { import stateGallery from '../../../_state/desa/gallery';
const { list, total } = useSnapshot(stateFileStorage);
useShallowEffect(() => {
stateFileStorage.load();
}, []);
let timeOut: NodeJS.Timer;
function Foto() {
const [search, setSearch] = useState("");
return ( return (
<Stack p="lg" gap="lg"> <Box>
<Flex justify="space-between" align="center" wrap="wrap" gap="md"> <HeaderSearch
<Title order={2} fw={700}> title='Foto'
Galeri Foto placeholder='Cari judul atau deskripsi foto...'
</Title> searchIcon={<IconSearch size={20} />}
<TextInput value={search}
radius="xl" onChange={(e) => setSearch(e.currentTarget.value)}
size="md" />
placeholder="Cari foto berdasarkan nama..." <ListFoto search={search} />
leftSection={<IconSearch size={18} />} </Box>
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"
>
{list.map((v, k) => (
<Card
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>
<Box>
<Text size="sm" fw={500} lineClamp={2}>
{v.name}
</Text>
</Box>
<Group justify="space-between" align="center" pt="xs">
<ActionIcon
variant="subtle"
color="red"
radius="md"
onClick={() => {
stateFileStorage
.del({ id: v.id })
.finally(() => toast("Foto berhasil dihapus"));
}}
>
<IconTrash size={18} />
</ActionIcon>
</Group>
</Stack>
</Card>
))}
</SimpleGrid>
) : (
<Stack align="center" justify="center" py="xl" gap="sm">
<Image
src="https://cdn-icons-png.flaticon.com/512/4076/4076549.png"
alt="Kosong"
w={120}
h={120}
fit="contain"
opacity={0.7}
loading="lazy"
/>
<Text c="dimmed" ta="center">
Belum ada foto yang tersedia
</Text>
</Stack>
)}
</Paper>
{total && total > 1 && (
<Flex justify="center">
<Pagination
total={total}
value={stateFileStorage.page} // Changed from page to value
size="md"
radius="md"
withEdges
onChange={(page) => {
stateFileStorage.load({ page });
}}
/>
</Flex>
)}
</Stack>
); );
} }
function ListFoto({ search }: { search: string }) {
const FotoState = useProxy(stateGallery.foto)
const router = useRouter();
const {
data,
page,
totalPages,
loading,
load,
} = FotoState.findMany;
useShallowEffect(() => {
load(page, 10, search)
}, [page, search])
const filteredData = data || []
if (loading || !data) {
return (
<Stack py={10}>
<Skeleton height={600} radius="md" />
</Stack>
)
}
return (
<Box py={10}>
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
<Group justify="space-between" mb="md">
<Title order={4}>Daftar Foto</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/desa/gallery/foto/create')}
>
Tambah Baru
</Button>
</Group>
<Box style={{ overflowX: "auto" }}>
<Table highlightOnHover>
<TableThead>
<TableTr>
<TableTh style={{ width: '25%' }}>Judul Foto</TableTh>
<TableTh style={{ width: '20%' }}>Tanggal</TableTh>
<TableTh style={{ width: '30%' }}>Deskripsi</TableTh>
<TableTh style={{ width: '15%' }}>Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd style={{ width: '25%' }}>
<Box w={200}>
<Text fw={500} truncate="end" lineClamp={1}>{item.name}</Text>
</Box>
</TableTd>
<TableTd style={{ width: '20%' }}>
<Box w={200}>
<Text fz="sm" c="dimmed">
{new Date(item.createdAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</Text>
</Box>
</TableTd>
<TableTd style={{ width: '30%' }}>
<Box w={200}>
<Text fz="sm" truncate="end" lineClamp={1} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
</Box>
</TableTd>
<TableTd style={{ width: '15%' }}>
<Button
variant="light"
color="blue"
onClick={() => router.push(`/admin/desa/gallery/foto/${item.id}`)}
>
<IconDeviceImac size={20} />
<Text ml={5}>Detail</Text>
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={4}>
<Center py={20}>
<Text c="dimmed">Tidak ada foto yang cocok</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
</Paper>
<Center>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10)
window.scrollTo({ top: 0, behavior: 'smooth' })
}}
total={totalPages}
mt="md"
mb="md"
color="blue"
radius="md"
/>
</Center>
</Box>
);
}
export default Foto;

View File

@@ -1,5 +1,3 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react-hooks/exhaustive-deps */
'use client'; 'use client';
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor'; import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
@@ -10,19 +8,22 @@ import {
Button, Button,
Group, Group,
Loader, Loader,
MultiSelect,
Paper, Paper,
Stack, Stack,
Text, Text,
TextInput, TextInput,
Title Title,
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
interface FasilitasKesehatanFormBase { // Tipe form yang SESUAI dengan logika relasi (array ID)
interface EditFasilitasKesehatanForm {
name: string; name: string;
informasiUmum: { informasiUmum: {
fasilitas: string; fasilitas: string;
@@ -30,128 +31,92 @@ interface FasilitasKesehatanFormBase {
jamOperasional: string; jamOperasional: string;
}; };
layananUnggulan: { content: string }; layananUnggulan: { content: string };
dokterdanTenagaMedis: { dokterdanTenagaMedis: string[]; // ← ARRAY ID
name: string;
specialist: string;
jadwal: string;
};
fasilitasPendukung: { content: string }; fasilitasPendukung: { content: string };
prosedurPendaftaran: { content: string }; prosedurPendaftaran: { content: string };
tarifDanLayanan: { tarifDanLayanan: string[]; // ← ARRAY ID
layanan: string;
tarif: string;
};
} }
function EditFasilitasKesehatan() { function EditFasilitasKesehatan() {
const state = useProxy(fasilitasKesehatanState.fasilitasKesehatan); const state = useProxy(fasilitasKesehatanState.fasilitasKesehatan);
const dokterState = useProxy(fasilitasKesehatanState.dokter);
const tarifState = useProxy(fasilitasKesehatanState.tarif);
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams<{ id: string }>();
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<FasilitasKesehatanFormBase>({ const [formData, setFormData] = useState<EditFasilitasKesehatanForm>({
name: '', name: '',
informasiUmum: { fasilitas: '', alamat: '', jamOperasional: '' }, informasiUmum: { fasilitas: '', alamat: '', jamOperasional: '' },
layananUnggulan: { content: '' }, layananUnggulan: { content: '' },
dokterdanTenagaMedis: { name: '', specialist: '', jadwal: '' }, dokterdanTenagaMedis: [],
fasilitasPendukung: { content: '' }, fasilitasPendukung: { content: '' },
prosedurPendaftaran: { content: '' }, prosedurPendaftaran: { content: '' },
tarifDanLayanan: { layanan: '', tarif: '' }, tarifDanLayanan: [],
}); });
const [originalData, setOriginalData] = useState<FasilitasKesehatanFormBase>({ // Load data fasilitas & daftar dokter/tarif
name: '', useShallowEffect(() => {
informasiUmum: { fasilitas: '', alamat: '', jamOperasional: '' }, const loadAll = async () => {
layananUnggulan: { content: '' }, const id = params?.id;
dokterdanTenagaMedis: { name: '', specialist: '', jadwal: '' }, if (!id) return;
fasilitasPendukung: { content: '' },
prosedurPendaftaran: { content: '' },
tarifDanLayanan: { layanan: '', tarif: '' },
});
// Helper untuk update nested state // Load dokter & tarif (untuk opsi MultiSelect)
const updateForm = <K extends keyof FasilitasKesehatanFormBase>( await Promise.all([
key: K, dokterState.findMany.load(),
value: FasilitasKesehatanFormBase[K] tarifState.findMany.load(),
) => setFormData(prev => ({ ...prev, [key]: value })); ]);
const updateNested = < // Load data fasilitas
K extends keyof FasilitasKesehatanFormBase, await state.edit.load(id);
N extends keyof FasilitasKesehatanFormBase[K] const loaded = state.edit.form;
>(key: K, nestedKey: N, value: FasilitasKesehatanFormBase[K][N]) => if (loaded) {
setFormData(prev => ({ setFormData({
...prev, name: loaded.name,
[key]: { ...prev[key] as object, [nestedKey]: value }, informasiUmum: loaded.informasiUmum,
})); layananUnggulan: loaded.layananUnggulan,
dokterdanTenagaMedis: loaded.dokterdanTenagaMedis || [],
fasilitasPendukung: loaded.fasilitasPendukung,
prosedurPendaftaran: loaded.prosedurPendaftaran,
tarifDanLayanan: loaded.tarifDanLayanan || [],
});
}
};
const deepClone = (obj: any): any => { loadAll();
try { }, [params?.id]);
return JSON.parse(JSON.stringify(obj));
} catch (error) { const updateForm = <K extends keyof EditFasilitasKesehatanForm>(
console.warn('Gagal deep clone dengan JSON fallback:', error); field: K,
return obj; // fallback (berisiko shared reference) value: EditFasilitasKesehatanForm[K]
) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleReset = () => {
const loaded = state.edit.form;
if (loaded) {
setFormData({
name: loaded.name,
informasiUmum: loaded.informasiUmum,
layananUnggulan: loaded.layananUnggulan,
dokterdanTenagaMedis: loaded.dokterdanTenagaMedis || [],
fasilitasPendukung: loaded.fasilitasPendukung,
prosedurPendaftaran: loaded.prosedurPendaftaran,
tarifDanLayanan: loaded.tarifDanLayanan || [],
});
toast.info('Form dikembalikan ke data awal');
} }
}; };
// Load data const handleSubmit = async (e: React.FormEvent) => {
useEffect(() => { e.preventDefault();
const load = async () => {
const id = params?.id as string;
if (!id) return;
try {
await state.edit.load(id);
const loadedData = state.edit.form;
if (!loadedData) {
toast.error('Data tidak ditemukan');
return;
}
// Gunakan JSON fallback untuk deep clone
const clonedData = deepClone(loadedData) as FasilitasKesehatanFormBase;
setFormData(clonedData);
setOriginalData(clonedData);
} catch (err) {
console.error(err);
toast.error('Gagal memuat data fasilitas kesehatan');
}
};
load();
}, [params?.id]);
const handleResetForm = () => {
setFormData({
name: originalData.name,
informasiUmum:
{
fasilitas: originalData.informasiUmum.fasilitas,
alamat: originalData.informasiUmum.alamat,
jamOperasional: originalData.informasiUmum.jamOperasional
},
layananUnggulan: { content: originalData.layananUnggulan.content },
dokterdanTenagaMedis: {
name: originalData.dokterdanTenagaMedis.name,
specialist: originalData.dokterdanTenagaMedis.specialist,
jadwal: originalData.dokterdanTenagaMedis.jadwal
},
fasilitasPendukung: { content: originalData.fasilitasPendukung.content },
prosedurPendaftaran: { content: originalData.prosedurPendaftaran.content },
tarifDanLayanan: {
layanan: originalData.tarifDanLayanan.layanan,
tarif: originalData.tarifDanLayanan.tarif
},
});
toast.info("Form dikembalikan ke data awal");
};
// Submit
const handleSubmit = async () => {
try { try {
setIsSubmitting(true); setIsSubmitting(true);
state.edit.form = { ...state.edit.form, ...formData };
// Update state Valtio
state.edit.form = { ...formData };
const success = await state.edit.submit(); const success = await state.edit.submit();
if (success) { if (success) {
toast.success('Fasilitas kesehatan berhasil diperbarui!'); toast.success('Fasilitas kesehatan berhasil diperbarui!');
@@ -159,14 +124,14 @@ function EditFasilitasKesehatan() {
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
toast.error('Terjadi kesalahan saat memperbarui data fasilitas kesehatan'); toast.error('Gagal memperbarui data');
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md" component="form" onSubmit={handleSubmit}>
{/* Header */} {/* Header */}
<Group mb="md"> <Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
@@ -189,7 +154,7 @@ function EditFasilitasKesehatan() {
<Stack gap="md"> <Stack gap="md">
<TextInput <TextInput
label="Nama Fasilitas Kesehatan" label="Nama Fasilitas Kesehatan"
placeholder="Masukkan nama fasilitas kesehatan" placeholder="Masukkan nama"
value={formData.name} value={formData.name}
onChange={(e) => updateForm('name', e.target.value)} onChange={(e) => updateForm('name', e.target.value)}
required required
@@ -197,118 +162,108 @@ function EditFasilitasKesehatan() {
{/* Informasi Umum */} {/* Informasi Umum */}
<Box> <Box>
<Text fw="bold" mb={5}> <Text fw="bold" mb={5}>Informasi Umum</Text>
Informasi Umum
</Text>
<TextInput <TextInput
label="Fasilitas" label="Fasilitas"
value={formData.informasiUmum.fasilitas} value={formData.informasiUmum.fasilitas}
onChange={(e) => updateNested('informasiUmum', 'fasilitas', e.target.value)} onChange={(e) =>
updateForm('informasiUmum', {
...formData.informasiUmum,
fasilitas: e.target.value,
})
}
/> />
<TextInput <TextInput
label="Alamat" label="Alamat"
value={formData.informasiUmum.alamat} value={formData.informasiUmum.alamat}
onChange={(e) => updateNested('informasiUmum', 'alamat', e.target.value)} onChange={(e) =>
updateForm('informasiUmum', {
...formData.informasiUmum,
alamat: e.target.value,
})
}
/> />
<TextInput <TextInput
label="Jam Operasional" label="Jam Operasional"
value={formData.informasiUmum.jamOperasional} value={formData.informasiUmum.jamOperasional}
onChange={(e) => updateNested('informasiUmum', 'jamOperasional', e.target.value)} onChange={(e) =>
updateForm('informasiUmum', {
...formData.informasiUmum,
jamOperasional: e.target.value,
})
}
/> />
</Box> </Box>
{/* Layanan Unggulan */} {/* Layanan Unggulan */}
<Box> <Box>
<Text fw="bold" mb={5}> <Text fw="bold" mb={5}>Layanan Unggulan</Text>
Layanan Unggulan
</Text>
<EditEditor <EditEditor
value={formData.layananUnggulan.content} value={formData.layananUnggulan.content}
onChange={(v) => updateNested('layananUnggulan', 'content', v)} onChange={(v) => updateForm('layananUnggulan', { content: v })}
/> />
</Box> </Box>
{/* Dokter dan Tenaga Medis */} {/* Dokter & Tenaga Medis — MultiSelect */}
<Box> <MultiSelect
<Text fw="bold" mb={5}> label="Dokter & Tenaga Medis"
Dokter dan Tenaga Medis placeholder="Pilih dokter/tenaga medis"
</Text> data={
<TextInput dokterState.findMany.data?.map((d) => ({
label="Nama Dokter" value: d.id,
value={formData.dokterdanTenagaMedis.name} label: `${d.name} (${d.specialist})`,
onChange={(e) => updateNested('dokterdanTenagaMedis', 'name', e.target.value)} })) || []
/> }
<TextInput value={formData.dokterdanTenagaMedis}
label="Specialist" onChange={(val) => updateForm('dokterdanTenagaMedis', val)}
value={formData.dokterdanTenagaMedis.specialist} searchable
onChange={(e) => clearable
updateNested('dokterdanTenagaMedis', 'specialist', e.target.value) required
} />
/>
<TextInput
label="Jadwal"
value={formData.dokterdanTenagaMedis.jadwal}
onChange={(e) => updateNested('dokterdanTenagaMedis', 'jadwal', e.target.value)}
/>
</Box>
{/* Fasilitas Pendukung */} {/* Fasilitas Pendukung */}
<Box> <Box>
<Text fw="bold" mb={5}> <Text fw="bold" mb={5}>Fasilitas Pendukung</Text>
Fasilitas Pendukung
</Text>
<EditEditor <EditEditor
value={formData.fasilitasPendukung.content} value={formData.fasilitasPendukung.content}
onChange={(v) => updateNested('fasilitasPendukung', 'content', v)} onChange={(v) => updateForm('fasilitasPendukung', { content: v })}
/> />
</Box> </Box>
{/* Prosedur Pendaftaran */} {/* Prosedur Pendaftaran */}
<Box> <Box>
<Text fw="bold" mb={5}> <Text fw="bold" mb={5}>Prosedur Pendaftaran</Text>
Prosedur Pendaftaran
</Text>
<EditEditor <EditEditor
value={formData.prosedurPendaftaran.content} value={formData.prosedurPendaftaran.content}
onChange={(v) => updateNested('prosedurPendaftaran', 'content', v)} onChange={(v) => updateForm('prosedurPendaftaran', { content: v })}
/> />
</Box> </Box>
{/* Tarif dan Layanan */} {/* Tarif & Layanan — MultiSelect */}
<Box> <MultiSelect
<Text fw="bold" mb={5}> label="Tarif & Layanan"
Tarif dan Layanan placeholder="Pilih layanan"
</Text> data={
<TextInput tarifState.findMany.data?.map((t) => ({
label="Tarif" value: t.id,
value={formData.tarifDanLayanan.tarif} label: `${t.layanan} - ${t.tarif}`,
onChange={(e) => updateNested('tarifDanLayanan', 'tarif', e.target.value)} })) || []
/> }
<TextInput value={formData.tarifDanLayanan}
label="Layanan" onChange={(val) => updateForm('tarifDanLayanan', val)}
value={formData.tarifDanLayanan.layanan} searchable
onChange={(e) => updateNested('tarifDanLayanan', 'layanan', e.target.value)} clearable
/> required
</Box> />
{/* Tombol Simpan */} {/* Aksi */}
<Group justify="right"> <Group justify="right">
{/* Tombol Batal */} <Button variant="outline" color="gray" radius="md" onClick={handleReset}>
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal Batal
</Button> </Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} type="submit"
radius="md" radius="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',
@@ -324,4 +279,4 @@ function EditFasilitasKesehatan() {
); );
} }
export default EditFasilitasKesehatan; export default EditFasilitasKesehatan;

View File

@@ -9,6 +9,12 @@ import {
Paper, Paper,
Skeleton, Skeleton,
Stack, Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text Text
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
@@ -108,21 +114,79 @@ function DetailFasilitasKesehatan() {
</Box> </Box>
<Box> <Box>
<Text fz="lg" fw="bold">Dokter & Tenaga Medis</Text> <Text fz="lg" fw="bold" mb="sm">Dokter & Tenaga Medis</Text>
<Text fz="md" fw="bold">Nama</Text> {Array.isArray(data.dokterdantenagamedis) && data.dokterdantenagamedis.length > 0 ? (
<Text fz="md" c="dimmed">{data.dokterdantenagamedis?.name || '-'}</Text> <Box style={{ overflowX: 'auto', width: '100%' }}>
<Text fz="md" fw="bold">Spesialis</Text> <Table striped highlightOnHover withTableBorder>
<Text fz="md" c="dimmed">{data.dokterdantenagamedis?.specialist || '-'}</Text> <TableThead>
<Text fz="md" fw="bold">Jadwal</Text> <TableTr>
<Text fz="md" c="dimmed">{data.dokterdantenagamedis?.jadwal || '-'}</Text> <TableTh style={{ whiteSpace: 'nowrap' }}>Nama</TableTh>
<TableTh style={{ whiteSpace: 'nowrap' }}>Spesialis</TableTh>
<TableTh style={{ whiteSpace: 'nowrap' }}>Jadwal</TableTh>
<TableTh style={{ whiteSpace: 'nowrap' }}>Jam Operasional</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{data.dokterdantenagamedis.map((dokter) => (
<TableTr key={dokter.id}>
<TableTd style={{ whiteSpace: 'normal', wordBreak: 'break-word' }}>
{dokter.name || '-'}
</TableTd>
<TableTd style={{ whiteSpace: 'normal', wordBreak: 'break-word' }}>
{dokter.specialist || '-'}
</TableTd>
<TableTd style={{ whiteSpace: 'normal', wordBreak: 'break-word' }}>
{dokter.jadwal || '-'}
</TableTd>
<TableTd style={{ whiteSpace: 'normal', wordBreak: 'break-word' }}>
{dokter.jamBukaOperasional} {dokter.jamTutupOperasional}
{dokter.jadwalLibur && (
<>
<br />
<Text span c="dimmed" fz="xs">
Libur: {dokter.jadwalLibur} ({dokter.jamBukaLibur}{dokter.jamTutupLibur})
</Text>
</>
)}
</TableTd>
</TableTr>
))}
</TableTbody>
</Table>
</Box>
) : (
<Text c="dimmed">Tidak ada dokter atau tenaga medis terdaftar.</Text>
)}
</Box> </Box>
<Box> <Box mt="xl">
<Text fz="lg" fw="bold">Tarif & Layanan</Text> <Text fz="lg" fw="bold" mb="sm">Tarif & Layanan</Text>
<Text fz="md" fw="bold">Layanan</Text> {Array.isArray(data.tarifdanlayanan) && data.tarifdanlayanan.length > 0 ? (
<Text fz="md" c="dimmed">{data.tarifdanlayanan?.layanan || '-'}</Text> <Box style={{ overflowX: 'auto', width: '100%' }}>
<Text fz="md" fw="bold">Tarif</Text> <Table striped highlightOnHover withTableBorder>
<Text fz="md" c="dimmed">{data.tarifdanlayanan?.tarif || '-'}</Text> <TableThead>
<TableTr>
<TableTh style={{ whiteSpace: 'nowrap' }}>Layanan</TableTh>
<TableTh style={{ whiteSpace: 'nowrap' }}>Tarif</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{data.tarifdanlayanan.map((tarif) => (
<TableTr key={tarif.id}>
<TableTd style={{ whiteSpace: 'normal', wordBreak: 'break-word' }}>
{tarif.layanan || '-'}
</TableTd>
<TableTd style={{ whiteSpace: 'normal', wordBreak: 'break-word' }}>
{tarif.tarif || '-'}
</TableTd>
</TableTr>
))}
</TableTbody>
</Table>
</Box>
) : (
<Text c="dimmed">Tidak ada tarif atau layanan terdaftar.</Text>
)}
</Box> </Box>
{/* Aksi */} {/* Aksi */}

View File

@@ -7,19 +7,20 @@ import {
Button, Button,
Group, Group,
Loader, Loader,
MultiSelect,
Paper, Paper,
Stack, Stack,
Text, Text,
TextInput, TextInput,
Title Title
} from '@mantine/core'; } from '@mantine/core';
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 { useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function CreateFasilitasKesehatan() { function CreateFasilitasKesehatan() {
const stateFasilitasKesehatan = useProxy(fasilitasKesehatanState.fasilitasKesehatan); const stateFasilitasKesehatan = useProxy(fasilitasKesehatanState.fasilitasKesehatan);
const router = useRouter(); const router = useRouter();
@@ -34,23 +35,16 @@ function CreateFasilitasKesehatan() {
jamOperasional: '', jamOperasional: '',
}, },
layananUnggulan: { layananUnggulan: {
content: '', content: ''
},
dokterdanTenagaMedis: {
name: '',
specialist: '',
jadwal: '',
}, },
dokterdanTenagaMedis: [] as string[],
fasilitasPendukung: { fasilitasPendukung: {
content: '', content: '',
}, },
prosedurPendaftaran: { prosedurPendaftaran: {
content: '', content: '',
}, },
tarifDanLayanan: { tarifDanLayanan: [] as string[],
layanan: '',
tarif: '',
},
}; };
}; };
@@ -70,6 +64,11 @@ function CreateFasilitasKesehatan() {
} }
}; };
useShallowEffect(() => {
fasilitasKesehatanState.dokter.findMany.load();
fasilitasKesehatanState.tarif.findMany.load();
}, []);
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md" component="form" onSubmit={handleSubmit}> <Box px={{ base: 'sm', md: 'lg' }} py="md" component="form" onSubmit={handleSubmit}>
{/* Header */} {/* Header */}
@@ -140,31 +139,25 @@ function CreateFasilitasKesehatan() {
/> />
</Box> </Box>
{/* Dokter dan Tenaga Medis */} {/* Dokter dan Tenaga Medis */}
<Box> <MultiSelect
<Text fz="md" fw="bold" mb={5}>Dokter dan Tenaga Medis</Text> label="Dokter & Tenaga Medis"
<TextInput placeholder="Pilih dokter / tenaga medis"
label="Nama Dokter" data={
placeholder="Masukkan nama dokter" fasilitasKesehatanState.dokter.findMany.data?.map((item) => ({
value={stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.name} label: item.name,
onChange={(e) => (stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.name = e.target.value)} value: item.id,
required })) || []
/> }
<TextInput value={stateFasilitasKesehatan.create.form.dokterdanTenagaMedis}
label="Spesialis" onChange={(val: string[]) => {
placeholder="Masukkan spesialis" stateFasilitasKesehatan.create.form.dokterdanTenagaMedis = val;
value={stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.specialist} }}
onChange={(e) => (stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.specialist = e.target.value)} searchable
required clearable
/> required
<TextInput />
label="Jadwal"
placeholder="Masukkan jadwal"
value={stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.jadwal}
onChange={(e) => (stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.jadwal = e.target.value)}
required
/>
</Box>
{/* Fasilitas Pendukung */} {/* Fasilitas Pendukung */}
<Box> <Box>
@@ -175,6 +168,24 @@ function CreateFasilitasKesehatan() {
/> />
</Box> </Box>
<MultiSelect
label="Layanan"
placeholder="Pilih layanan"
data={
fasilitasKesehatanState.tarif.findMany.data?.map((item) => ({
label: item.layanan,
value: item.id,
})) || []
}
value={stateFasilitasKesehatan.create.form.tarifDanLayanan} // string[]
onChange={(val: string[]) => {
stateFasilitasKesehatan.create.form.tarifDanLayanan = val;
}}
searchable
clearable
required
/>
{/* Prosedur Pendaftaran */} {/* Prosedur Pendaftaran */}
<Box> <Box>
<Text fz="md" fw="bold" mb={5}>Prosedur Pendaftaran</Text> <Text fz="md" fw="bold" mb={5}>Prosedur Pendaftaran</Text>
@@ -184,24 +195,6 @@ function CreateFasilitasKesehatan() {
/> />
</Box> </Box>
{/* Tarif dan Layanan */}
<Box>
<Text fz="md" fw="bold" mb={5}>Tarif dan Layanan</Text>
<TextInput
label="Tarif"
placeholder="Masukkan tarif"
value={stateFasilitasKesehatan.create.form.tarifDanLayanan.tarif}
onChange={(e) => (stateFasilitasKesehatan.create.form.tarifDanLayanan.tarif = e.target.value)}
required
/>
<TextInput
label="Layanan"
placeholder="Masukkan layanan"
value={stateFasilitasKesehatan.create.form.tarifDanLayanan.layanan}
onChange={(e) => (stateFasilitasKesehatan.create.form.tarifDanLayanan.layanan = e.target.value)}
required
/>
</Box>
{/* Submit */} {/* Submit */}
<Group justify="right"> <Group justify="right">

View File

@@ -1,11 +1,241 @@
import React from 'react'; /* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan';
import colors from '@/con/colors';
import {
ActionIcon,
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
Title
} from '@mantine/core';
import { TimeInput } from '@mantine/dates';
import { IconArrowBack, IconClock } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
function EditDokterTenagaMedis() {
const state = useProxy(fasilitasKesehatanState.dokter);
const router = useRouter();
const params = useParams();
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState({
name: '',
specialist: '',
jadwal: '',
jadwalLibur: '',
jamBukaOperasional: '',
jamTutupOperasional: '',
jamBukaLibur: '',
jamTutupLibur: '',
});
const [originalData, setOriginalData] = useState({
name: '',
specialist: '',
jadwal: '',
jadwalLibur: '',
jamBukaOperasional: '',
jamTutupOperasional: '',
jamBukaLibur: '',
jamTutupLibur: '',
});
// Load data
useEffect(() => {
const load = async () => {
const id = params?.id as string;
if (!id) return;
try {
await state.update.load(id);
const loadedData = state.update.form;
if (!loadedData) {
toast.error('Data tidak ditemukan');
return;
}
setFormData(loadedData);
setOriginalData(loadedData);
} catch (err) {
console.error(err);
toast.error('Gagal memuat data fasilitas kesehatan');
}
};
load();
}, [params?.id]);
const handleResetForm = () => {
setFormData({
name: originalData.name,
specialist: originalData.specialist,
jadwal: originalData.jadwal,
jadwalLibur: originalData.jadwalLibur,
jamBukaOperasional: originalData.jamBukaOperasional,
jamTutupOperasional: originalData.jamTutupOperasional,
jamBukaLibur: originalData.jamBukaLibur,
jamTutupLibur: originalData.jamTutupLibur,
});
toast.info("Form dikembalikan ke data awal");
};
const refBuka = useRef<HTMLInputElement>(null);
const refTutup = useRef<HTMLInputElement>(null);
const refBukaLibur = useRef<HTMLInputElement>(null);
const refTutupLibur = useRef<HTMLInputElement>(null);
const picker = (ref: any) => (
<ActionIcon variant="subtle" color="gray" onClick={() => ref.current?.showPicker()}>
<IconClock size={16} stroke={1.5} />
</ActionIcon>
);
const handleChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
// Submit
const handleSubmit = async () => {
try {
setIsSubmitting(true);
state.update.form = { ...state.update.form, ...formData };
const success = await state.update.submit();
if (success) {
toast.success('Data berhasil diperbarui!');
router.push('/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis');
}
} catch (err) {
console.error(err);
toast.error('Terjadi kesalahan saat memperbarui data');
} finally {
setIsSubmitting(false);
}
};
function Page() {
return ( return (
<div> <Box px={{ base: 'sm', md: 'lg' }} py="md">
Page {/* Header */}
</div> <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 Fasilitas Kesehatan
</Title>
</Group>
{/* Form */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
label="Nama Dokter"
placeholder="Masukkan nama dokter"
value={formData.name}
onChange={(e) => handleChange("name", e.target.value)}
required
/>
<TextInput
label="Jadwal"
placeholder="Masukkan jadwal"
value={formData.jadwal}
onChange={(e) => handleChange("jadwal", e.target.value)}
required
/>
<TextInput
label="Jadwal Libur"
placeholder="Masukkan jadwal libur"
value={formData.jadwalLibur}
onChange={(e) => handleChange("jadwalLibur", e.target.value)}
required
/>
<TimeInput
label="Jam Buka Operasional"
ref={refBuka}
rightSection={picker(refBuka)}
value={formData.jamBukaOperasional}
onChange={(e) => handleChange("jamBukaOperasional", e.target.value)}
required
/>
<TimeInput
label="Jam Tutup Operasional"
ref={refTutup}
rightSection={picker(refTutup)}
value={formData.jamTutupOperasional}
onChange={(e) => handleChange("jamTutupOperasional", e.target.value)}
required
/>
<TimeInput
label="Jam Buka Hari Libur"
ref={refBukaLibur}
rightSection={picker(refBukaLibur)}
value={formData.jamBukaLibur}
onChange={(e) => handleChange("jamBukaLibur", e.target.value)}
required
/>
<TimeInput
label="Jam Tutup Hari Libur"
ref={refTutupLibur}
rightSection={picker(refTutupLibur)}
value={formData.jamTutupLibur}
onChange={(e) => handleChange("jamTutupLibur", e.target.value)}
required
/>
{/* Tombol Simpan */}
<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 Page; export default EditDokterTenagaMedis;

View File

@@ -1,11 +1,165 @@
import React from 'react'; 'use client'
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Paper,
Skeleton,
Stack,
Text
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
function DetailDokterTenagaMedis() {
const params = useParams();
const router = useRouter();
const state = useProxy(fasilitasKesehatanState.dokter);
const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
useShallowEffect(() => {
state.findUnique.load(params?.id as string);
}, []);
const handleHapus = () => {
if (selectedId) {
state.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
router.push(
'/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis'
);
}
};
if (!state.findUnique.data) {
return (
<Stack py={10}>
<Skeleton height={500} radius="md" />
</Stack>
);
}
const data = state.findUnique.data;
function Page() {
return ( return (
<div> <Box py={10}>
Page {/* Tombol Back */}
</div> <Button
variant="subtle"
onClick={() => router.back()}
leftSection={<IconArrowBack size={24} color={colors['blue-button']} />}
mb={15}
>
Kembali
</Button>
{/* Wrapper Detail */}
<Paper
withBorder
w={{ base: '100%', md: '70%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
>
<Stack gap="md">
<Text fz="2xl" fw="bold" c={colors['blue-button']}>
Detail Dokter & Tenaga Medis
</Text>
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="sm">
<Box>
<Text fz="lg" fw="bold">Nama Dokter</Text>
<Text fz="md" c="dimmed">{data.name || '-'}</Text>
</Box>
<Box>
<Text fz="md" fw="bold">Specialist</Text>
<Text fz="md" c="dimmed">{data.specialist || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Jadwal</Text>
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.jadwal || '-' }} />
</Box>
<Box>
<Text fz="lg" fw="bold">Jadwal Libur</Text>
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.jadwalLibur || '-' }} />
</Box>
<Box>
<Text fz="lg" fw="bold">Jam Buka Operasional</Text>
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.jamBukaOperasional || '-' }} />
</Box>
<Box>
<Text fz="lg" fw="bold">Jam Tutup Operasional</Text>
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.jamTutupOperasional || '-' }} />
</Box>
<Box>
<Text fz="lg" fw="bold">Jam Buka Libur</Text>
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.jamBukaLibur || '-' }} />
</Box>
<Box>
<Text fz="lg" fw="bold">Jam Tutup Libur</Text>
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.jamTutupLibur || '-' }} />
</Box>
{/* Aksi */}
<Group gap="sm">
<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/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis/${data.id}/edit`
)
}
variant="light"
radius="md"
size="md"
>
<IconEdit size={20} />
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Paper>
{/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleHapus}
text="Apakah anda yakin ingin menghapus dokter & tenaga medis ini?"
/>
</Box>
); );
} }
export default Page; export default DetailDokterTenagaMedis;

View File

@@ -1,71 +1,184 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client' 'use client'
import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan'; import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import { ActionIcon, Box, Button, Group, Loader, Paper, Stack, TextInput, Title } from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react'; import { TimeInput } from '@mantine/dates';
import { useParams, useRouter } from 'next/navigation'; import { IconArrowBack, IconClock } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useRef, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function CreateDokter() { function CreateDokter() {
const params = useParams()
const createState = useProxy(fasilitasKesehatanState.dokter) const createState = useProxy(fasilitasKesehatanState.dokter)
const router = useRouter(); const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => { const resetForm = () => {
createState.create.create.form = { createState.create.create.form = {
name: "", name: "",
specialist: "", specialist: "",
jadwal: "", jadwal: "",
jadwalLibur: "",
jamBukaOperasional: "",
jamTutupOperasional: "",
jamBukaLibur: "",
jamTutupLibur: "",
}; };
}; };
const refBuka = useRef<HTMLInputElement>(null);
const refTutup = useRef<HTMLInputElement>(null);
const refBukaLibur = useRef<HTMLInputElement>(null);
const refTutupLibur = useRef<HTMLInputElement>(null);
const picker = (ref: any) => (
<ActionIcon variant="subtle" color="gray" onClick={() => ref.current?.showPicker()}>
<IconClock size={16} stroke={1.5} />
</ActionIcon>
);
const handleSubmit = async () => { const handleSubmit = async () => {
await createState.create.create.create(); try {
resetForm(); setIsSubmitting(true);
router.push(`/admin/kesehatan/fasilitas-kesehatan/${params?.id}/dokter-tenaga-medis`) await createState.create.create.create();
toast.success('Data berhasil disimpan');
resetForm();
router.push(`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis`)
} catch (error) {
console.error(error);
toast.error('Gagal menyimpan data');
} finally {
setIsSubmitting(false);
}
}; };
return ( return (
<Box component="form" onSubmit={handleSubmit}> <Box px={{ base: 'sm', md: 'lg' }} py="md" component="form" onSubmit={handleSubmit}>
<Box mb={10}> {/* Header */}
<Button variant="subtle" onClick={() => router.back()}> <Group mb="md">
<IconArrowBack color={colors['blue-button']} size={25} /> <Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
</Box> <Title order={4} ml="sm" c="dark">
Tambah Data Dokter & Tenaga Medis
</Title>
</Group>
<Paper bg={colors['white-1']} p="md" w={{ base: '100%', md: '50%' }}> {/* Form */}
<Stack gap="xs"> <Paper
<Title order={3}>Create Dokter</Title> w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput <TextInput
label={<Text fz="sm" fw="bold">Nama Dokter</Text>} label={"Nama Dokter"}
placeholder="masukkan nama dokter" placeholder="Masukkan nama dokter"
value={createState.create.create.form.name} value={createState.create.create.form.name}
onChange={(e) => { onChange={(e) => (createState.create.create.form.name = e.target.value)}
createState.create.create.form.name = e.target.value; required
}}
/> />
<Text fz="md" fw="bold">Specialist</Text>
{/* Informasi Umum */}
<TextInput <TextInput
label={<Text fz="sm" fw="bold">Specialist</Text>} label="Specialist"
placeholder="masukkan specialist" placeholder="Masukkan specialist"
value={createState.create.create.form.specialist} value={createState.create.create.form.specialist}
onChange={(e) => { onChange={(e) => (createState.create.create.form.specialist = e.target.value)}
createState.create.create.form.specialist = e.target.value; required
}}
/> />
<Box>
<Text fz="md" fw="bold">Jadwal</Text> <TextInput
<CreateEditor label="Jadwal"
value={createState.create.create.form.jadwal} placeholder="Masukkan jadwal"
onChange={(htmlContent) => { value={createState.create.create.form.jadwal}
createState.create.create.form.jadwal = htmlContent; onChange={(e) => (createState.create.create.form.jadwal = e.target.value)}
required
/>
<TextInput
label="Jadwal Libur"
placeholder="Masukkan jadwal libur"
value={createState.create.create.form.jadwalLibur}
onChange={(e) => (createState.create.create.form.jadwalLibur = e.target.value)}
required
/>
<TimeInput
label="Jam Buka Operasional"
ref={refBuka}
rightSection={picker(refBuka)}
value={createState.create.create.form.jamBukaOperasional}
onChange={(e) => (createState.create.create.form.jamBukaOperasional = e.target.value)}
required
/>
<TimeInput
label="Jam Tutup Operasional"
ref={refTutup}
rightSection={picker(refTutup)}
value={createState.create.create.form.jamTutupOperasional}
onChange={(e) => (createState.create.create.form.jamTutupOperasional = e.target.value)}
required
/>
<TimeInput
label="Jam Buka Hari Libur"
ref={refBukaLibur}
rightSection={picker(refBukaLibur)}
value={createState.create.create.form.jamBukaLibur}
onChange={(e) => (createState.create.create.form.jamBukaLibur = e.target.value)}
required
/>
<TimeInput
label="Jam Tutup Hari Libur"
ref={refTutupLibur}
rightSection={picker(refTutupLibur)}
value={createState.create.create.form.jamTutupLibur}
onChange={(e) => (createState.create.create.form.jamTutupLibur = e.target.value)}
required
/>
{/* Submit */}
<Group justify="right">
{/* Tombol Batal */}
<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)',
}} }}
/> >
</Box> {isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
<Button onClick={handleSubmit} bg={colors['blue-button']}> </Button>
Simpan </Group>
</Button>
</Stack> </Stack>
</Paper> </Paper>
</Box> </Box>

View File

@@ -1,13 +1,12 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text } from '@mantine/core'; import { Box, Button, Center, Group, Pagination, Paper, Skeleton, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconDeviceImacCog, IconSearch } from '@tabler/icons-react'; import { IconArrowBack, IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '@/app/admin/(dashboard)/_com/header'; import HeaderSearch from '@/app/admin/(dashboard)/_com/header';
import JudulList from '@/app/admin/(dashboard)/_com/judulList';
import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan'; import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan';
import { useState } from 'react'; import { useState } from 'react';
@@ -18,7 +17,7 @@ function DokterTenagaMedis() {
return ( return (
<Box> <Box>
<Box mb={10}> <Box mb={10}>
<Button variant="subtle" onClick={() => router.back()}> <Button variant="subtle" onClick={() => router.push('/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan')}>
<IconArrowBack color={colors['blue-button']} size={25} /> <IconArrowBack color={colors['blue-button']} size={25} />
</Button> </Button>
</Box> </Box>
@@ -60,49 +59,101 @@ function ListDokterTenagaMedis({ search }: { search: string }) {
} }
return ( return (
<Box py={10}> <Box py={10}>
<Paper bg={colors['white-1']} p={'md'}> <Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
<Stack> {/* Judul + Tombol Tambah */}
<JudulList <Group justify="space-between" mb="md">
title='List Fasilitas Kesehatan' <Title order={4}>Daftar Dokter dan Tenaga Medis</Title>
href={`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis/create`} <Button
/> leftSection={<IconPlus size={18} />}
<Box style={{ overflowX: "auto" }}> color="blue"
<Table striped withRowBorders withTableBorder style={{ minWidth: '700px' }}> variant="light"
<TableThead> onClick={() =>
<TableTr> router.push(
<TableTh>Fasilitas Kesehatan</TableTh> '/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis/create'
<TableTh>Alamat</TableTh> )
<TableTh>Jam Operasional</TableTh> }
<TableTh>Detail</TableTh> >
</TableTr> Tambah Baru
</TableThead> </Button>
<TableTbody> </Group>
{filteredData.map((item) => (
{/* Tabel */}
<Box style={{ overflowX: "auto" }}>
<Table highlightOnHover>
<TableThead>
<TableTr>
<TableTh>Nama Dokter</TableTh>
<TableTh>Spesialis</TableTh>
<TableTh>Jadwal</TableTh>
<TableTh>Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd>{item.name}</TableTd>
<TableTd>{item.specialist}</TableTd>
<TableTd> <TableTd>
<Text dangerouslySetInnerHTML={{ __html: item.jadwal }} /> <Box w={150}>
<Text fw={500} truncate="end" lineClamp={1}>
{item.name}
</Text>
</Box>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Button onClick={() => router.push(`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/${item.id}`)}> <Box w={150}>
<IconDeviceImacCog size={25} /> {item.specialist || '-'}
</Box>
</TableTd>
<TableTd>
<Box w={150}>
<Text dangerouslySetInnerHTML={{ __html: item.jadwal || '-' }} />
</Box>
</TableTd>
<TableTd>
<Button
variant="light"
color="blue"
onClick={() =>
router.push(
`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis/${item.id}`
)
}
>
<IconDeviceImacCog size={20} />
<Text ml={5}>Detail</Text>
</Button> </Button>
</TableTd> </TableTd>
</TableTr> </TableTr>
))} ))
</TableTbody> ) : (
</Table> <TableTr>
</Box> <TableTd colSpan={4}>
</Stack> <Center py={20}>
<Text c="dimmed">
Tidak ada fasilitas kesehatan yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
</Paper> </Paper>
{/* Pagination */}
<Center> <Center>
<Pagination <Pagination
value={page} value={page}
onChange={(newPage) => load(newPage)} // ini penting! onChange={(newPage) => {
load(newPage, 10, search);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages} total={totalPages}
mt="md" mt="md"
mb="md" mb="md"
color="blue"
radius="md"
/> />
</Center> </Center>
</Box> </Box>

View File

@@ -1,9 +1,12 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { import {
ActionIcon,
Box, Box,
Button, Button,
Center, Center,
Grid,
GridCol,
Group, Group,
Pagination, Pagination,
Paper, Paper,
@@ -16,30 +19,52 @@ import {
TableThead, TableThead,
TableTr, TableTr,
Text, Text,
Title TextInput,
Title,
Tooltip
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react'; import { IconCoin, IconDeviceImacCog, IconPlus, IconReportMedical, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header';
import fasilitasKesehatanState from '../../../_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan'; import fasilitasKesehatanState from '../../../_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan';
function FasilitasKesehatan() { function FasilitasKesehatan() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const router = useRouter()
return ( return (
<Box> <Box>
{/* Header Search */} <Grid mb={10}>
<HeaderSearch <GridCol span={{ base: 12, md: 8 }}>
title='Fasilitas Kesehatan' <Title order={3}>Fasilitas Kesehatan</Title>
placeholder='Cari nama, alamat, atau jam operasional...' </GridCol>
searchIcon={<IconSearch size={20} />} <GridCol span={{ base: 12, md: 4 }}>
value={search} <Group gap={"xs"}>
onChange={(e) => setSearch(e.currentTarget.value)} <Tooltip label="List Dokter" withArrow>
/> <ActionIcon onClick={() => router.push('/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis')} size="lg" radius="xl" color="green.6">
<IconReportMedical size={20} />
</ActionIcon>
</Tooltip>
<Tooltip label="List Tarif Layanan" withArrow>
<ActionIcon onClick={()=> router.push('/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/tarif-layanan')} size="lg" radius="xl" color="blue.6">
<IconCoin size={20} />
</ActionIcon>
</Tooltip>
<Paper radius="lg" bg={colors['white-1']}>
<TextInput
radius="lg"
placeholder='Cari nama, alamat, atau jam operasional...'
leftSection={<IconSearch size={20} />}
w="133%"
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
</Paper>
</Group>
</GridCol>
</Grid>
<ListFasilitasKesehatan search={search} /> <ListFasilitasKesehatan search={search} />
</Box> </Box>
@@ -54,6 +79,7 @@ function ListFasilitasKesehatan({ search }: { search: string }) {
const { data, page, totalPages, loading, load } = stateFasilitasKesehatan.findMany; const { data, page, totalPages, loading, load } = stateFasilitasKesehatan.findMany;
useShallowEffect(() => { useShallowEffect(() => {
load(page, 10, search); load(page, 10, search);
}, [page, search]); }, [page, search]);
@@ -93,8 +119,8 @@ function ListFasilitasKesehatan({ search }: { search: string }) {
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh>Fasilitas Kesehatan</TableTh> <TableTh>Fasilitas Kesehatan</TableTh>
<TableTh>Dokter</TableTh> <TableTh>Jumlah Dokter</TableTh>
<TableTh>Layanan</TableTh> <TableTh>Jumlah Layanan</TableTh>
<TableTh>Aksi</TableTh> <TableTh>Aksi</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
@@ -111,13 +137,17 @@ function ListFasilitasKesehatan({ search }: { search: string }) {
</TableTd> </TableTd>
<TableTd> <TableTd>
<Box w={150}> <Box w={150}>
{item.dokterdantenagamedis?.name || '-'} {item.dokterdantenagamedis?.length
? `${item.dokterdantenagamedis.length} dokter`
: '-'}
</Box> </Box>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Box w={150}> <Box w={150}>
<Text truncate="end" lineClamp={1}> <Text truncate="end" lineClamp={1}>
{item.tarifdanlayanan?.layanan || '-'} {item.tarifdanlayanan?.length
? `${item.tarifdanlayanan.length} layanan`
: '-'}
</Text> </Text>
</Box> </Box>
</TableTd> </TableTd>
@@ -141,7 +171,7 @@ function ListFasilitasKesehatan({ search }: { search: string }) {
<TableTr> <TableTr>
<TableTd colSpan={4}> <TableTd colSpan={4}>
<Center py={20}> <Center py={20}>
<Text color="dimmed"> <Text c="dimmed">
Tidak ada fasilitas kesehatan yang cocok Tidak ada fasilitas kesehatan yang cocok
</Text> </Text>
</Center> </Center>

View File

@@ -0,0 +1,173 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
Title
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
function EditTarifLayanan() {
const editState = useProxy(fasilitasKesehatanState.tarif);
const router = useRouter();
const params = useParams();
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
tarif: '',
layanan: ''
});
const [formData, setFormData] = useState({
tarif: '',
layanan: ''
});
useEffect(() => {
const loadTarifLayanan = async () => {
const id = params?.id as string;
if (!id) return;
try {
const data = await editState.update.load(id);
if (data) {
setFormData({
tarif: data.tarif || '',
layanan: data.layanan || '',
});
setOriginalData({
tarif: data.tarif || '',
layanan: data.layanan || '',
});
}
} catch (error) {
console.error('Error loading tarif layanan:', error);
toast.error('Gagal memuat data tarif layanan');
}
};
loadTarifLayanan();
}, [params?.id]);
const handleChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleResetForm = () => {
setFormData({
tarif: originalData.tarif,
layanan: originalData.layanan,
});
toast.info('Form dikembalikan ke data awal');
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
// update global state hanya saat submit
editState.update.form = {
...editState.update.form,
tarif: formData.tarif,
layanan: formData.layanan,
};
await editState.update.submit();
toast.success('Tarif Layanan berhasil diperbarui!');
router.push('/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/tarif-layanan');
} catch (error) {
console.error('Error updating tarif layanan:', error);
toast.error('Terjadi kesalahan saat memperbarui tarif layanan');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* 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">
Edit Tarif Layanan
</Title>
</Group>
{/* Form Wrapper */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
name="layanan"
label="Layanan"
placeholder="Masukkan nama layanan"
value={formData.layanan}
onChange={(e) => handleChange('layanan', e.target.value)}
required
/>
<TextInput
name="tarif"
label="Tarif"
placeholder="Masukkan tarif layanan"
value={formData.tarif}
onChange={(e) => handleChange('tarif', e.target.value)}
required
/>
<Group justify="right">
<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 EditTarifLayanan;

View File

@@ -0,0 +1,119 @@
'use client';
import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
Title
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
function CreateTarifLayanan() {
const createState = useProxy(fasilitasKesehatanState.tarif);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => {
createState.create.form = {
tarif: '',
layanan: '',
};
};
const handleSubmit = async () => {
setIsSubmitting(true);
try {
await createState.create.create();
resetForm();
router.push('/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/tarif-layanan');
} catch (error) {
console.error('Error creating tarif layanan:', error);
toast.error('Gagal menambahkan tarif layanan');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header dengan back button */}
<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 Tarif Layanan
</Title>
</Group>
{/* Form utama */}
<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="Layanan"
placeholder="Masukkan nama layanan"
value={createState.create.form.layanan || ''}
onChange={(e) => (createState.create.form.layanan = e.target.value)}
required
/>
<TextInput
label="Tarif"
placeholder="Masukkan tarif"
value={createState.create.form.tarif || ''}
onChange={(e) => (createState.create.form.tarif = e.target.value)}
required
/>
<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 CreateTarifLayanan;

View File

@@ -1,13 +1,13 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text } from '@mantine/core'; import { Box, Button, Center, Group, Pagination, Paper, Skeleton, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconDeviceImacCog, IconSearch } from '@tabler/icons-react'; import { IconArrowBack, IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '@/app/admin/(dashboard)/_com/header'; import HeaderSearch from '@/app/admin/(dashboard)/_com/header';
import JudulList from '@/app/admin/(dashboard)/_com/judulList'; import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan'; import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan';
import { useState } from 'react'; import { useState } from 'react';
@@ -18,12 +18,12 @@ function TarifLayanan() {
return ( return (
<Box> <Box>
<Box mb={10}> <Box mb={10}>
<Button variant="subtle" onClick={() => router.back()}> <Button variant="subtle" onClick={() => router.push('/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan')}>
<IconArrowBack color={colors['blue-button']} size={25} /> <IconArrowBack color={colors['blue-button']} size={25} />
</Button> </Button>
</Box> </Box>
<HeaderSearch <HeaderSearch
title='Dokter dan Tenaga Medis' title='Tarif dan Layanan'
placeholder='pencarian' placeholder='pencarian'
searchIcon={<IconSearch size={20} />} searchIcon={<IconSearch size={20} />}
value={search} value={search}
@@ -35,8 +35,11 @@ function TarifLayanan() {
} }
function ListTarifLayanan({ search }: { search: string }) { function ListTarifLayanan({ search }: { search: string }) {
const stateFasilitasKesehatan = useProxy(fasilitasKesehatanState.dokter) const stateFasilitasKesehatan = useProxy(fasilitasKesehatanState.tarif);
const router = useRouter(); const router = useRouter();
const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const { const {
data, data,
loading, loading,
@@ -49,6 +52,15 @@ function ListTarifLayanan({ search }: { search: string }) {
load(page, 10, search) load(page, 10, search)
}, [page, search]) }, [page, search])
const handleDelete = () => {
if (selectedId) {
stateFasilitasKesehatan.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
load(page, 10, search);
}
};
const filteredData = data || [] const filteredData = data || []
if (loading || !data) { if (loading || !data) {
@@ -60,51 +72,116 @@ function ListTarifLayanan({ search }: { search: string }) {
} }
return ( return (
<Box py={10}> <Box py={10}>
<Paper bg={colors['white-1']} p={'md'}> <Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
<Stack> {/* Judul + Tombol Tambah */}
<JudulList <Group justify="space-between" mb="md">
title='List Fasilitas Kesehatan' <Title order={4}>Daftar Tarif dan Layanan</Title>
href={`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis/create`} <Button
/> leftSection={<IconPlus size={18} />}
<Box style={{ overflowX: "auto" }}> color="blue"
<Table striped withRowBorders withTableBorder style={{ minWidth: '700px' }}> variant="light"
<TableThead> onClick={() =>
<TableTr> router.push(
<TableTh>Fasilitas Kesehatan</TableTh> '/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/tarif-layanan/create'
<TableTh>Alamat</TableTh> )
<TableTh>Jam Operasional</TableTh> }
<TableTh>Detail</TableTh> >
</TableTr> Tambah Baru
</TableThead> </Button>
<TableTbody> </Group>
{filteredData.map((item) => (
{/* Tabel */}
<Box style={{ overflowX: "auto" }}>
<Table highlightOnHover>
<TableThead>
<TableTr>
<TableTh>Layanan</TableTh>
<TableTh>Tarif</TableTh>
<TableTh>Edit</TableTh>
<TableTh>Hapus</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd>{item.name}</TableTd>
<TableTd>{item.specialist}</TableTd>
<TableTd> <TableTd>
<Text dangerouslySetInnerHTML={{ __html: item.jadwal }} /> <Box w={150}>
{item.layanan || '-'}
</Box>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Button onClick={() => router.push(`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/${item.id}`)}> <Box w={150}>
<IconDeviceImacCog size={25} /> <Text fw={500} truncate="end" lineClamp={1}>
{item.tarif}
</Text>
</Box>
</TableTd>
<TableTd>
<Button
variant="light"
color="green"
onClick={() =>
router.push(
`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/tarif-layanan/${item.id}`
)
}
>
<IconEdit size={18} />
</Button>
</TableTd>
<TableTd>
<Button
variant="light"
color="red"
disabled={stateFasilitasKesehatan.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
>
<IconTrash size={18} />
</Button> </Button>
</TableTd> </TableTd>
</TableTr> </TableTr>
))} ))
</TableTbody> ) : (
</Table> <TableTr>
</Box> <TableTd colSpan={4}>
</Stack> <Center py={20}>
<Text c="dimmed">
Tidak ada fasilitas kesehatan yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
</Paper> </Paper>
{/* Pagination */}
<Center> <Center>
<Pagination <Pagination
value={page} value={page}
onChange={(newPage) => load(newPage)} // ini penting! onChange={(newPage) => {
load(newPage, 10, search);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages} total={totalPages}
mt="md" mt="md"
mb="md" mb="md"
color="blue"
radius="md"
/> />
</Center> </Center>
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleDelete}
text="Apakah anda yakin ingin menghapus tarif layanan ini?"
/>
</Box> </Box>
) )
} }

View File

@@ -1,94 +1,102 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'; 'use client';
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes'; import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes';
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,
Badge,
Box, Box,
Button, Button,
Group, Group,
Image, Image,
Loader,
NumberInput,
Paper, Paper,
Select,
Stack, Stack,
Table,
Text, Text,
TextInput, TextInput,
Title, Title,
Loader,
ActionIcon
} from '@mantine/core'; } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconFile, IconPhoto, IconUpload, IconX } from '@tabler/icons-react'; import {
IconArrowBack,
IconFile,
IconPhoto,
IconPlus,
IconTrash,
IconX
} from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
// Tipe untuk form item
type ItemForm = {
kode: string;
uraian: string;
anggaran: number;
realisasi: number;
level: number;
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
};
function EditAPBDes() { function EditAPBDes() {
const apbdesState = useProxy(apbdes); const apbdesState = useProxy(apbdes);
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const [formData, setFormData] = useState({
name: '',
jumlah: '',
imageId: '',
fileId: ''
});
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
name: "",
jumlah: "",
imageId: "",
fileId: "",
imageUrl: "",
docUrl: "",
});
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
const [previewDoc, setPreviewDoc] = useState<string | null>(null); const [previewDoc, setPreviewDoc] = useState<string | null>(null);
const [imageFile, setImageFile] = useState<File | null>(null); const [imageFile, setImageFile] = useState<File | null>(null);
const [docFile, setDocFile] = useState<File | null>(null); const [docFile, setDocFile] = useState<File | null>(null);
// Load data on mount // Form input untuk item baru
const [newItem, setNewItem] = useState<ItemForm>({
kode: '',
uraian: '',
anggaran: 0,
realisasi: 0,
level: 1,
tipe: 'pendapatan',
});
// Type for the API response
interface APBDesResponse {
id: string;
image?: {
link: string;
id: string;
};
file?: {
link: string;
id: string;
};
// Add other properties as needed
}
// Load data saat pertama kali
useEffect(() => { useEffect(() => {
const loadData = async () => { const id = params?.id as string;
const id = params?.id as string; if (id) {
if (!id) return; apbdesState.edit.load(id).then((response) => {
const data = response as unknown as APBDesResponse;
try {
const data = await apbdesState.edit.load(id);
if (data) { if (data) {
const newForm = { // ✅ Ambil link langsung dari response
name: data.name || "",
jumlah: data.jumlah || "",
imageId: data.imageId || "",
fileId: data.fileId || "",
};
setFormData(newForm);
// simpan juga versi original
setOriginalData({
...newForm,
imageUrl: data.image?.link || "",
docUrl: data.file?.link || "",
});
setPreviewImage(data.image?.link || null); setPreviewImage(data.image?.link || null);
setPreviewDoc(data.file?.link || null); setPreviewDoc(data.file?.link || null);
} }
} catch (err) { });
console.error(err); }
toast.error('Gagal memuat data APBDes');
}
};
loadData();
}, [params?.id]); }, [params?.id]);
// Generic Dropzone handler
const handleDrop = (fileType: 'image' | 'doc') => (files: File[]) => { const handleDrop = (fileType: 'image' | 'doc') => (files: File[]) => {
const file = files[0]; const file = files[0];
if (!file) return; if (!file) return;
@@ -102,51 +110,97 @@ function EditAPBDes() {
} }
}; };
const handleAddItem = () => {
const { kode, uraian, anggaran, realisasi, level, tipe } = newItem;
if (!kode || !uraian) {
return toast.warn('Kode dan uraian wajib diisi');
}
const finalTipe = level === 1 ? null : tipe;
const selisih = realisasi - anggaran;
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
apbdesState.edit.addItem({
kode,
uraian,
anggaran,
realisasi,
selisih,
persentase,
level,
tipe: finalTipe, // ✅ Tidak akan undefined
});
setNewItem({
kode: '',
uraian: '',
anggaran: 0,
realisasi: 0,
level: 1,
tipe: 'pendapatan',
});
};
const handleRemoveItem = (index: number) => {
apbdesState.edit.removeItem(index);
};
const handleSubmit = async () => { const handleSubmit = async () => {
if (apbdesState.edit.form.items.length === 0) {
return toast.warn('Minimal harus ada 1 item APBDes');
}
try { try {
setIsSubmitting(true); setIsSubmitting(true);
// Update global state with local form data first
apbdesState.edit.form = { ...apbdesState.edit.form, ...formData };
// Helper function for uploading file // Upload file baru jika ada
const uploadFile = async (file: File | null) => { if (imageFile) {
if (!file) return null; const res = await ApiFetch.api.fileStorage.create.post({
const res = await ApiFetch.api.fileStorage.create.post({ file, name: file.name }); file: imageFile,
const uploaded = res.data?.data; name: imageFile.name,
if (!uploaded?.id) throw new Error('Upload gagal'); });
return uploaded.id; const imageId = res.data?.data?.id;
}; if (imageId) apbdesState.edit.form.imageId = imageId;
}
// Upload files if selected if (docFile) {
const uploadedImageId = await uploadFile(imageFile); const res = await ApiFetch.api.fileStorage.create.post({
const uploadedDocId = await uploadFile(docFile); file: docFile,
name: docFile.name,
});
const fileId = res.data?.data?.id;
if (fileId) apbdesState.edit.form.fileId = fileId;
}
if (uploadedImageId) apbdesState.edit.form.imageId = uploadedImageId; const success = await apbdesState.edit.update();
if (uploadedDocId) apbdesState.edit.form.fileId = uploadedDocId; if (success) {
router.push('/admin/landing-page/apbdes');
await apbdesState.edit.update(); }
toast.success('APBDes berhasil diperbarui!');
router.push('/admin/landing-page/APBDes');
} catch (err) { } catch (err) {
console.error(err); console.error('Update error:', err);
toast.error('Terjadi kesalahan saat memperbarui APBDes'); toast.error('Gagal memperbarui APBDes');
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}; };
const handleResetForm = () => { const handleReset = () => {
setFormData({ const id = params?.id as string;
name: originalData.name, if (id) {
jumlah: originalData.jumlah, apbdesState.edit.load(id);
imageId: originalData.imageId, setImageFile(null);
fileId: originalData.fileId, setDocFile(null);
}); setNewItem({
setPreviewImage(originalData.imageUrl || null); kode: '',
setImageFile(null); uraian: '',
setPreviewDoc(originalData.docUrl || null); anggaran: 0,
setDocFile(null); realisasi: 0,
toast.info("Form dikembalikan ke data awal"); level: 1,
tipe: 'pendapatan',
});
toast.info('Form dikembalikan ke data awal');
}
}; };
return ( return (
@@ -160,163 +214,277 @@ function EditAPBDes() {
</Title> </Title>
</Group> </Group>
<Paper w={{ base: '100%', md: '50%' }} bg={colors['white-1']} p="lg" radius="md" shadow="sm" style={{ border: '1px solid #e0e0e0' }}> <Paper
w={{ base: '100%', md: '100%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md"> <Stack gap="md">
{/* Controlled Inputs */} {/* Header Form */}
<TextInput <NumberInput
label="Nama APBDes" label="Tahun"
placeholder="Masukkan nama APBDes" value={apbdesState.edit.form.tahun || new Date().getFullYear()}
value={formData.name} onChange={(val) =>
onChange={(e) => setFormData({ ...formData, name: e.target.value })} (apbdesState.edit.form.tahun = Number(val) || new Date().getFullYear())
}
min={2000}
max={2100}
required required
/> />
<TextInput {/* Gambar & Dokumen */}
label="Jumlah Anggaran" <Stack gap="xs">
placeholder="Masukkan jumlah anggaran" <Box>
value={formData.jumlah} <Text fw="bold" fz="sm" mb={6}>
onChange={(e) => setFormData({ ...formData, jumlah: e.target.value })} Gambar APBDes
required </Text>
/> <Dropzone
onDrop={handleDrop('image')}
onReject={() => toast.error('File gambar tidak valid')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Idle>
<IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle>
<Stack gap="xs" align="center">
<Text size="md" fw={500}>
{previewImage ? 'Ganti gambar' : 'Unggah gambar'}
</Text>
</Stack>
</Group>
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview"
radius="md"
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
/>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setImageFile(null);
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
{/* Image Dropzone */} <Box>
<Box> <Text fw="bold" fz="sm" mb={6}>
<Text fw="bold" fz="sm" mb={6}>Gambar APBDes</Text> Dokumen APBDes
<Dropzone </Text>
onDrop={handleDrop('image')} <Dropzone
onReject={() => toast.error('File tidak valid, gunakan format gambar')} onDrop={handleDrop('doc')}
maxSize={5 * 1024 ** 2} onReject={() => toast.error('File dokumen tidak valid')}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }} maxSize={10 * 1024 ** 2}
radius="md" accept={{
p="xl" 'application/pdf': ['.pdf'],
> 'application/msword': ['.doc'],
<Group justify="center" gap="xl" mih={180}> 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
<Dropzone.Accept><IconUpload size={48} color={colors['blue-button']} stroke={1.5} /></Dropzone.Accept> 'application/vnd.ms-excel': ['.xls'],
<Dropzone.Reject><IconX size={48} color="red" stroke={1.5} /></Dropzone.Reject> 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
<Dropzone.Idle><IconPhoto size={48} color="#868e96" stroke={1.5} /></Dropzone.Idle> }}
<Stack gap="xs" align="center"> radius="md"
<Text size="md" fw={500}>Seret gambar atau klik untuk memilih file</Text> p="xl"
<Text size="sm" c="dimmed">Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp</Text> >
</Stack> <Group justify="center" gap="xl" mih={180}>
</Group> <Dropzone.Idle>
</Dropzone> <IconFile size={48} color="#868e96" stroke={1.5} />
{previewImage && ( </Dropzone.Idle>
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}> <Stack gap="xs" align="center">
<Image <Text size="md" fw={500}>
src={previewImage} {previewDoc ? 'Ganti dokumen' : 'Unggah dokumen'}
alt="Preview Gambar" </Text>
radius="md" </Stack>
style={{ </Group>
maxHeight: 200, </Dropzone>
objectFit: 'contain', {previewDoc && (
border: '1px solid #ddd', <Box mt="sm">
}} <Button
loading="lazy" component="a"
href={previewDoc}
target="_blank"
rel="noopener noreferrer"
variant="light"
size="xs"
leftSection={<IconFile size={14} />}
>
Lihat Dokumen
</Button>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
ml="sm"
onClick={() => {
setPreviewDoc(null);
setDocFile(null);
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
</Stack>
{/* Input Item Baru */}
<Paper withBorder p="md" radius="md">
<Title order={6} mb="sm">
Tambah Item Pendapatan/Belanja
</Title>
<Stack gap="xs">
<Group grow>
<TextInput
label="Kode"
placeholder="Contoh: 4.1.2"
value={newItem.kode}
onChange={(e) => setNewItem({ ...newItem, kode: e.target.value })}
required
/>
<Select
label="Level"
data={[
{ value: '1', label: 'Level 1 (Kelompok)' },
{ value: '2', label: 'Level 2 (Sub-kelompok)' },
{ value: '3', label: 'Level 3 (Detail)' },
]}
value={String(newItem.level)}
onChange={(val) => setNewItem({ ...newItem, level: Number(val) || 1 })}
/>
<Select
label="Tipe"
data={[
{ value: 'pendapatan', label: 'Pendapatan' },
{ value: 'belanja', label: 'Belanja' },
{ value: 'pembiayaan', label: 'Pembiayaan' },
]}
value={newItem.tipe}
onChange={(val) => setNewItem({ ...newItem, tipe: (val as any) || 'pendapatan' })}
/> />
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setImageFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
{/* Document Dropzone */}
<Box>
<Text fw="bold" fz="sm" mb={6}>Dokumen APBDes</Text>
<Dropzone
onDrop={handleDrop('doc')}
onReject={() => toast.error('File tidak valid, gunakan format dokumen')}
maxSize={10 * 1024 ** 2}
accept={{
'application/pdf': ['.pdf'],
'application/msword': ['.doc'],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
'application/vnd.ms-excel': ['.xls'],
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
}}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={150}>
<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><IconFile size={48} color="#868e96" stroke={1.5} /></Dropzone.Idle>
<Stack gap="xs" align="center">
<Text size="md" fw={500}>Seret dokumen atau klik untuk memilih file</Text>
<Text size="sm" c="dimmed">Maksimal 10MB, format PDF/DOC/DOCX/XLS/XLSX</Text>
</Stack>
</Group> </Group>
</Dropzone> <TextInput
{previewDoc && ( label="Uraian"
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}> placeholder="Contoh: Dana Desa"
<Text size="sm" c="dimmed" mb="xs">Dokumen terpilih: {docFile?.name || 'Dokumen'}</Text> value={newItem.uraian}
<Button component="a" href={previewDoc} target="_blank" rel="noopener noreferrer" variant="light" leftSection={<IconFile size={16} />} size="sm"> onChange={(e) => setNewItem({ ...newItem, uraian: e.target.value })}
Lihat Dokumen required
</Button> />
{/* Tombol hapus (pojok kanan atas) */} <Group grow>
<ActionIcon <NumberInput
variant="filled" label="Anggaran (Rp)"
color="red" value={newItem.anggaran}
radius="xl" onChange={(val) => setNewItem({ ...newItem, anggaran: Number(val) || 0 })}
size="sm" thousandSeparator
pos="absolute" min={0}
top={5} />
right={5} <NumberInput
onClick={() => { label="Realisasi (Rp)"
setPreviewDoc(null); value={newItem.realisasi}
setDocFile(null); onChange={(val) => setNewItem({ ...newItem, realisasi: Number(val) || 0 })}
}} thousandSeparator
style={{ min={0}
boxShadow: '0 2px 6px rgba(0,0,0,0.15)', />
}} </Group>
> <Button
<IconX size={14} /> leftSection={<IconPlus size={16} />}
</ActionIcon> onClick={handleAddItem}
</Box> disabled={!newItem.kode || !newItem.uraian}
)} >
</Box> Tambah Item
</Button>
</Stack>
</Paper>
{/* Tabel Items */}
{apbdesState.edit.form.items.length > 0 && (
<Paper withBorder p="md" radius="md">
<Title order={6} mb="sm">
Daftar Item ({apbdesState.edit.form.items.length})
</Title>
<Table striped highlightOnHover>
<thead>
<tr>
<th>Kode</th>
<th>Uraian</th>
<th>Anggaran</th>
<th>Realisasi</th>
<th>Level</th>
<th>Tipe</th>
<th style={{ width: '50px' }}>Aksi</th>
</tr>
</thead>
<tbody>
{apbdesState.edit.form.items.map((item, idx) => (
<tr key={idx}>
<td>
<Text size="sm" fw={500}>
{item.kode}
</Text>
</td>
<td>{item.uraian}</td>
<td>{item.anggaran.toLocaleString('id-ID')}</td>
<td>{item.realisasi.toLocaleString('id-ID')}</td>
<td>
<Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}>
L{item.level}
</Badge>
</td>
<td>
{item.tipe ? (
<Badge color={item.tipe === 'pendapatan' ? 'teal' : 'red'} size="sm">
{item.tipe}
</Badge>
) : (
'-'
)}
</td>
<td>
<ActionIcon color="red" onClick={() => handleRemoveItem(idx)}>
<IconTrash size={16} />
</ActionIcon>
</td>
</tr>
))}
</tbody>
</Table>
</Paper>
)}
{/* Tombol Aksi */}
<Group justify="right"> <Group justify="right">
{/* Tombol Batal */} <Button variant="outline" color="gray" radius="md" onClick={handleReset}>
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal Batal
</Button> </Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"
size="md" disabled={apbdesState.edit.form.items.length === 0 || apbdesState.edit.loading}
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)',
}} }}
> >
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'} {isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan Perubahan'}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
@@ -325,4 +493,4 @@ function EditAPBDes() {
); );
} }
export default EditAPBDes; export default EditAPBDes;

View File

@@ -1,36 +1,53 @@
'use client' /* eslint-disable react-hooks/exhaustive-deps */
'use client';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text } from '@mantine/core'; import {
import { useShallowEffect } from '@mantine/hooks'; Box,
Button,
Group,
Image,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text
} from '@mantine/core';
import { IconArrowBack, IconEdit, IconFile, IconTrash } from '@tabler/icons-react'; import { IconArrowBack, IconEdit, IconFile, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
import apbdes from '../../../_state/landing-page/apbdes'; import apbdes from '../../../_state/landing-page/apbdes';
function DetailAPBDes() { function DetailAPBDes() {
const apbdesState = useProxy(apbdes) const apbdesState = useProxy(apbdes);
const [modalHapus, setModalHapus] = useState(false) const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null) const [selectedId, setSelectedId] = useState<string | null>(null);
const params = useParams() const params = useParams();
const router = useRouter() const router = useRouter();
useShallowEffect(() => {
apbdesState.findUnique.load(params?.id as string)
}, [])
useEffect(() => {
if (!params?.id) return;
apbdesState.findUnique.load(params.id as string);
}, [params?.id]);
const handleHapus = () => { const handleHapus = () => {
if (selectedId) { if (selectedId) {
apbdesState.delete.byId(selectedId) apbdesState.delete.byId(selectedId);
setModalHapus(false) setModalHapus(false);
setSelectedId(null) setSelectedId(null);
router.push("/admin/landing-page/APBDes") router.push('/admin/landing-page/apbdes');
} }
} };
if (!apbdesState.findUnique.data) { if (!apbdesState.findUnique.data) {
return ( return (
@@ -42,6 +59,11 @@ function DetailAPBDes() {
const data = apbdesState.findUnique.data; const data = apbdesState.findUnique.data;
// Helper: indentasi berdasarkan level
const getIndent = (level: number) => ({
paddingLeft: `${(level - 1) * 20}px`,
});
return ( return (
<Box py={10}> <Box py={10}>
<Button <Button
@@ -55,7 +77,7 @@ function DetailAPBDes() {
<Paper <Paper
withBorder withBorder
w={{ base: "100%", md: "60%" }} w={{ base: '100%', md: '100%' }}
bg={colors['white-1']} bg={colors['white-1']}
p="lg" p="lg"
radius="md" radius="md"
@@ -66,16 +88,21 @@ function DetailAPBDes() {
Detail APBDes Detail APBDes
</Text> </Text>
{/* Info Header */}
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs"> <Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="md"> <Stack gap="md">
<Box> <Box>
<Text fz="lg" fw="bold">Nama APBDes</Text> <Text fz="lg" fw="bold">Nama APBDes</Text>
<Text fz="md" c="dimmed">{data.name || '-'}</Text> <Text fz="md" c="dimmed">
{data.name || '-'}
</Text>
</Box> </Box>
<Box> <Box>
<Text fz="lg" fw="bold">Jumlah Anggaran</Text> <Text fz="lg" fw="bold">Tahun</Text>
<Text fz="md" c="dimmed">Rp. {data.jumlah || '-'}</Text> <Text fz="md" c="dimmed">
{data.tahun || '-'}
</Text>
</Box> </Box>
<Box> <Box>
@@ -117,32 +144,80 @@ function DetailAPBDes() {
</Box> </Box>
<Group gap="sm" mt="md"> <Group gap="sm" mt="md">
<Button <Button
color="red" color="red"
onClick={() => { onClick={() => {
setSelectedId(data.id); setSelectedId(data.id);
setModalHapus(true); setModalHapus(true);
}} }}
disabled={apbdesState.delete.loading} disabled={apbdesState.delete.loading}
variant="light" variant="light"
radius="md" radius="md"
size="md" size="md"
> >
<IconTrash size={20} /> <IconTrash size={20} />
</Button> </Button>
<Button <Button
color="green" color="green"
onClick={() => router.push(`/admin/landing-page/APBDes/${data.id}/edit`)} onClick={() => router.push(`/admin/landing-page/apbdes/${data.id}/edit`)}
variant="light" variant="light"
radius="md" radius="md"
size="md" size="md"
> >
<IconEdit size={20} /> <IconEdit size={20} />
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>
{/* Tabel Items */}
{data.items && data.items.length > 0 ? (
<Paper withBorder p="md" radius="md">
<Text fz="lg" fw="bold" mb="sm">
Rincian Pendapatan & Belanja ({data.items.length} item)
</Text>
<Box style={{ overflowX: 'auto' }}>
<Table striped highlightOnHover>
<TableThead>
<TableTr>
<TableTh>Uraian</TableTh>
<TableTh>Anggaran (Rp)</TableTh>
<TableTh>Realisasi (Rp)</TableTh>
<TableTh>Selisih (Rp)</TableTh>
<TableTh>Persentase (%)</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{[...data.items] // Create a new array before sorting
.sort((a, b) => a.kode.localeCompare(b.kode))
.map((item) => (
<TableTr key={item.id}>
<TableTd style={getIndent(item.level)}>
<Group>
<Text fw={item.level === 1 ? 'bold' : 'normal'}>{item.kode}</Text>
<Text fz="sm" c="dimmed">{item.uraian}</Text>
</Group>
</TableTd>
<TableTd>{item.anggaran.toLocaleString('id-ID')}</TableTd>
<TableTd>{item.realisasi.toLocaleString('id-ID')}</TableTd>
<TableTd>
<Text c={item.selisih >= 0 ? 'green' : 'red'}>
{item.selisih.toLocaleString('id-ID')}
</Text>
</TableTd>
<TableTd>
<Text fw={500}>{item.persentase.toFixed(2)}%</Text>
</TableTd>
</TableTr>
))}
</TableTbody>
</Table>
</Box>
</Paper>
) : (
<Text>Belum ada data item</Text>
)}
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* 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';
@@ -13,46 +14,76 @@ import {
TextInput, TextInput,
Title, Title,
Loader, Loader,
ActionIcon ActionIcon,
NumberInput,
Select,
Table,
Badge,
} from '@mantine/core'; } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconFile, IconPhoto, IconUpload, IconX } from '@tabler/icons-react'; import { IconArrowBack, IconFile, IconPhoto, IconUpload, IconX, IconPlus, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import apbdes from '../../../_state/landing-page/apbdes'; import apbdes from '../../../_state/landing-page/apbdes';
// Tipe item untuk form
type ItemForm = {
kode: string;
uraian: string;
anggaran: number;
realisasi: number;
level: number;
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
};
function CreateAPBDes() { function CreateAPBDes() {
const router = useRouter(); const router = useRouter();
const stateAPBDes = useProxy(apbdes) const stateAPBDes = useProxy(apbdes);
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
const [previewDoc, setPreviewDoc] = useState<string | null>(null); const [previewDoc, setPreviewDoc] = useState<string | null>(null);
const [imageFile, setImageFile] = useState<File | null>(null); const [imageFile, setImageFile] = useState<File | null>(null);
const [docFile, setDocFile] = useState<File | null>(null); const [docFile, setDocFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
// Form sementara untuk input item baru
const [newItem, setNewItem] = useState<ItemForm>({
kode: '',
uraian: '',
anggaran: 0,
realisasi: 0,
level: 1,
tipe: 'pendapatan',
});
useEffect(() => { useEffect(() => {
stateAPBDes.findMany.load(); stateAPBDes.findMany.load();
}, []); }, []);
const resetForm = () => { const resetForm = () => {
stateAPBDes.create.form = { stateAPBDes.create.reset();
name: "",
jumlah: "",
imageId: "",
fileId: "",
};
setImageFile(null); setImageFile(null);
setDocFile(null); setDocFile(null);
setPreviewImage(null); setPreviewImage(null);
setPreviewDoc(null);
setNewItem({
kode: '',
uraian: '',
anggaran: 0,
realisasi: 0,
level: 1,
tipe: 'pendapatan',
});
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
if (!imageFile || !docFile) { if (!imageFile || !docFile) {
return toast.warn("Pilih gambar dan dokumen terlebih dahulu"); return toast.warn("Pilih gambar dan dokumen terlebih dahulu");
} }
if (stateAPBDes.create.form.items.length === 0) {
return toast.warn("Minimal tambahkan 1 item APBDes");
}
try { try {
setIsSubmitting(true); setIsSubmitting(true);
@@ -68,6 +99,7 @@ function CreateAPBDes() {
return toast.error("Gagal mengupload file"); return toast.error("Gagal mengupload file");
} }
// Update form dengan ID file
stateAPBDes.create.form.imageId = imageId; stateAPBDes.create.form.imageId = imageId;
stateAPBDes.create.form.fileId = fileId; stateAPBDes.create.form.fileId = fileId;
@@ -75,7 +107,7 @@ function CreateAPBDes() {
toast.success("Berhasil menambahkan APBDes"); toast.success("Berhasil menambahkan APBDes");
resetForm(); resetForm();
router.push("/admin/landing-page/APBDes"); router.push("/admin/landing-page/apbdes");
} catch (error) { } catch (error) {
console.error("Gagal submit:", error); console.error("Gagal submit:", error);
toast.error("Gagal menyimpan data"); toast.error("Gagal menyimpan data");
@@ -84,6 +116,44 @@ function CreateAPBDes() {
} }
}; };
// Tambahkan item ke state
const handleAddItem = () => {
const { kode, uraian, anggaran, realisasi, level, tipe } = newItem;
if (!kode || !uraian) {
return toast.warn("Kode dan uraian wajib diisi");
}
const finalTipe = level === 1 ? null : tipe;
const selisih = realisasi - anggaran;
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
stateAPBDes.create.addItem({
kode,
uraian,
anggaran,
realisasi,
selisih,
persentase,
level,
tipe: finalTipe,
});
// Reset form input
setNewItem({
kode: '',
uraian: '',
anggaran: 0,
realisasi: 0,
level: 1,
tipe: 'pendapatan',
});
};
// Hapus item
const handleRemoveItem = (index: number) => {
stateAPBDes.create.removeItem(index);
};
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
<Group mb="md"> <Group mb="md">
@@ -104,199 +174,290 @@ function CreateAPBDes() {
style={{ border: '1px solid #e0e0e0' }} style={{ border: '1px solid #e0e0e0' }}
> >
<Stack gap="md"> <Stack gap="md">
{/* Gambar APBDes */} {/* Gambar & Dokumen (dipendekkan untuk fokus pada items) */}
<Box> <Stack gap={"xs"}>
<Text fw="bold" fz="sm" mb={6}> {/* Gambar APBDes */}
Gambar Program Inovasi <Box>
</Text> <Text fw="bold" fz="sm" mb={6}>
<Dropzone Gambar APBDes
onDrop={(files) => { </Text>
const selectedFile = files[0]; <Dropzone
if (selectedFile) { onDrop={(files) => {
setImageFile(selectedFile); const selectedFile = files[0];
setPreviewImage(URL.createObjectURL(selectedFile)); if (selectedFile) {
} setImageFile(selectedFile);
}} setPreviewImage(URL.createObjectURL(selectedFile));
onReject={() => toast.error('File tidak valid, gunakan format gambar')} }
maxSize={5 * 1024 ** 2} }}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }} onReject={() => toast.error('File tidak valid, gunakan format gambar')}
radius="md" maxSize={5 * 1024 ** 2}
p="xl" accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
> radius="md"
<Group justify="center" gap="xl" mih={180}> p="xl"
<Dropzone.Accept> >
<IconUpload size={48} color={colors['blue-button']} stroke={1.5} /> <Group justify="center" gap="xl" mih={180}>
</Dropzone.Accept> <Dropzone.Accept>
<Dropzone.Reject> <IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
<IconX size={48} color="red" stroke={1.5} /> </Dropzone.Accept>
</Dropzone.Reject> <Dropzone.Reject>
<Dropzone.Idle> <IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
<IconPhoto size={48} color="#868e96" stroke={1.5} /> </Dropzone.Reject>
</Dropzone.Idle> <Dropzone.Idle>
<Stack gap="xs" align="center"> <IconPhoto size={48} color="#868e96" stroke={1.5} />
<Text size="md" fw={500}> </Dropzone.Idle>
Seret gambar atau klik untuk memilih file <Stack gap="xs" align="center">
</Text> <Text size="md" fw={500}>
<Text size="sm" c="dimmed"> Seret gambar atau klik untuk memilih
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp </Text>
</Text> </Stack>
</Stack> </Group>
</Group> </Dropzone>
</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"
/>
{/* ✅ Preview gambar + tombol X */} {/* Tombol hapus (pojok kanan atas) */}
{previewImage && ( <ActionIcon
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}> variant="filled"
<Image color="red"
src={previewImage} radius="xl"
alt="Preview Gambar" size="sm"
radius="md" pos="absolute"
style={{ top={5}
maxHeight: 200, right={5}
objectFit: 'contain', onClick={() => {
border: '1px solid #ddd', setPreviewImage(null);
}} setImageFile(null);
loading="lazy" }}
/> style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
{/* Tombol hapus (pojok kanan atas) */} }}
<ActionIcon >
variant="filled" <IconX size={14} />
color="red" </ActionIcon>
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setImageFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
{/* Dokumen APBDes */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Dokumen APBDes
</Text>
<Dropzone
onDrop={(files) => {
const selectedFile = files[0];
if (selectedFile) {
setDocFile(selectedFile);
setPreviewDoc(URL.createObjectURL(selectedFile));
}
}}
onReject={() => toast.error('File tidak valid, gunakan format PDF, DOC, atau DOCX')}
maxSize={5 * 1024 ** 2}
accept={{
'application/pdf': ['.pdf'],
'application/msword': ['.doc'],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
}}
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>
<IconFile size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
</Dropzone.Idle>
<Box>
<Text size="xl" inline>
Seret dokumen atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed" inline display="block" mt={7}>
Maksimal 5MB (format: PDF, DOC, DOCX)
</Text>
</Box> </Box>
</Group> )}
</Dropzone> </Box>
{previewDoc && ( {/* Dokumen APBDes */}
<Box mt="md" pos="relative" style={{ textAlign: 'center' }}> <Box>
<Text fw="bold" fz="sm" mb={6}> <Text fw="bold" fz="sm" mb={6}>
Pratinjau Dokumen Dokumen APBDes
</Text> </Text>
<iframe <Dropzone
src={previewDoc} onDrop={(files) => {
width="100%" const selectedFile = files[0];
height="500px" if (selectedFile) {
style={{ border: '1px solid #ddd', borderRadius: '8px' }} setDocFile(selectedFile);
setPreviewDoc(URL.createObjectURL(selectedFile));
}
}}
onReject={() => toast.error('File tidak valid')}
maxSize={5 * 1024 ** 2}
accept={{
'application/pdf': ['.pdf'],
'application/msword': ['.doc'],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
}}
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>
<IconFile size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle>
<Stack gap="xs" align="center">
<Text size="md" fw={500}>
Seret dokumen atau klik untuk memilih
</Text>
</Stack>
</Group>
</Dropzone>
{previewDoc && (
<Box mt="md" pos="relative" style={{ textAlign: 'center' }}>
<Text fw="bold" fz="sm" mb={6}>
Pratinjau Dokumen
</Text>
<iframe
src={previewDoc}
width="100%"
height="500px"
style={{ border: '1px solid #ddd', borderRadius: '8px' }}
/>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewDoc(null);
setDocFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
</Stack>
{/* Form Header */}
<NumberInput
label="Tahun"
value={stateAPBDes.create.form.tahun || new Date().getFullYear()}
onChange={(val) => (stateAPBDes.create.form.tahun = Number(val) || new Date().getFullYear())}
min={2000}
max={2100}
required
/>
{/* Input Item Baru */}
<Paper withBorder p="md" radius="md">
<Title order={6} mb="sm">Tambah Item Pendapatan/Belanja</Title>
<Stack gap="xs">
<Group grow>
<TextInput
label="Kode"
placeholder="Contoh: 4.1.2"
value={newItem.kode}
onChange={(e) => setNewItem({ ...newItem, kode: e.target.value })}
required
/> />
<Select
label="Level"
data={[
{ value: '1', label: 'Level 1 (Kelompok)' },
{ value: '2', label: 'Level 2 (Sub-kelompok)' },
{ value: '3', label: 'Level 3 (Detail)' },
]}
value={String(newItem.level)}
onChange={(val) => setNewItem({ ...newItem, level: Number(val) || 1 })}
/>
<Select
label="Tipe"
data={[
{ value: 'pendapatan', label: 'Pendapatan' },
{ value: 'belanja', label: 'Belanja' },
{ value: 'pembiayaan', label: 'Pembiayaan' },
]}
value={newItem.level === 1 ? null : newItem.tipe}
onChange={(val) => setNewItem({ ...newItem, tipe: val as any })}
disabled={newItem.level === 1}
/>
</Group>
<TextInput
label="Uraian"
placeholder="Contoh: Dana Desa"
value={newItem.uraian}
onChange={(e) => setNewItem({ ...newItem, uraian: e.target.value })}
required
/>
<Group grow>
<NumberInput
label="Anggaran (Rp)"
value={newItem.anggaran}
onChange={(val) => setNewItem({ ...newItem, anggaran: Number(val) || 0 })}
thousandSeparator
min={0}
/>
<NumberInput
label="Realisasi (Rp)"
value={newItem.realisasi}
onChange={(val) => setNewItem({ ...newItem, realisasi: Number(val) || 0 })}
thousandSeparator
min={0}
/>
</Group>
<Button
leftSection={<IconPlus size={16} />}
onClick={handleAddItem}
disabled={!newItem.kode || !newItem.uraian}
>
Tambah Item
</Button>
</Stack>
</Paper>
<ActionIcon {/* Tabel Items */}
variant="filled" {stateAPBDes.create.form.items.length > 0 && (
color="red" <Paper withBorder p="md" radius="md">
radius="xl" <Title order={6} mb="sm">Daftar Item ({stateAPBDes.create.form.items.length})</Title>
size="sm" <Table striped highlightOnHover>
pos="absolute" <thead>
top={5} <tr>
right={5} <th>Kode</th>
onClick={() => { <th>Uraian</th>
setPreviewDoc(null); <th>Anggaran</th>
setDocFile(null); <th>Realisasi</th>
}} <th>Level</th>
style={{ <th>Tipe</th>
boxShadow: '0 2px 6px rgba(0,0,0,0.15)', <th style={{ width: 50 }}>Aksi</th>
}} </tr>
> </thead>
<IconX size={14} /> <tbody>
</ActionIcon> {stateAPBDes.create.form.items.map((item, idx) => (
</Box> <tr key={idx}>
)} <td><Text size="sm" fw={500}>{item.kode}</Text></td>
</Box> <td>{item.uraian}</td>
<td>{item.anggaran.toLocaleString('id-ID')}</td>
{/* Form Input */} <td>{item.realisasi.toLocaleString('id-ID')}</td>
<TextInput <td>
label="Nama APBDes" <Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}>
placeholder="Masukkan nama APBDes" L{item.level}
value={stateAPBDes.create.form.name || ''} </Badge>
onChange={(e) => (stateAPBDes.create.form.name = e.target.value)} </td>
required <td>
/> <Badge size="sm" color={item.tipe === 'pendapatan' ? 'teal' : 'red'}>
<TextInput {item.tipe}
label="Jumlah Anggaran" </Badge>
placeholder="14 M / 1 T / 200 JT / 900 RB" </td>
value={stateAPBDes.create.form.jumlah || ''} <td>
onChange={(e) => (stateAPBDes.create.form.jumlah = e.target.value)} <ActionIcon color="red" onClick={() => handleRemoveItem(idx)}>
required <IconTrash size={16} />
/> </ActionIcon>
</td>
</tr>
))}
</tbody>
</Table>
</Paper>
)}
{/* Tombol Aksi */}
<Group justify="right"> <Group justify="right">
{/* Tombol Batal */} <Button variant="outline" color="gray" radius="md" onClick={resetForm}>
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset Reset
</Button> </Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"
size="md" disabled={stateAPBDes.create.form.items.length === 0}
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)',
}} }}
> >
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'} {isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
@@ -308,4 +469,4 @@ function CreateAPBDes() {
); );
} }
export default CreateAPBDes; export default CreateAPBDes;

View File

@@ -1,6 +1,23 @@
'use client' 'use client';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Group, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core'; import {
Box,
Button,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconDeviceImacCog, IconFile, IconPlus, IconSearch } from '@tabler/icons-react'; import { IconDeviceImacCog, IconFile, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@@ -9,14 +26,13 @@ import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../_com/header'; import HeaderSearch from '../../_com/header';
import apbdes from '../../_state/landing-page/apbdes'; import apbdes from '../../_state/landing-page/apbdes';
function APBDes() { function APBDes() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
return ( return (
<Box> <Box>
<HeaderSearch <HeaderSearch
title='APBDes' title="APBDes"
placeholder='Cari APBDes...' placeholder="Cari APBDes..."
searchIcon={<IconSearch size={20} />} searchIcon={<IconSearch size={20} />}
value={search} value={search}
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => setSearch(e.currentTarget.value)}
@@ -27,22 +43,16 @@ function APBDes() {
} }
function ListAPBDes({ search }: { search: string }) { function ListAPBDes({ search }: { search: string }) {
const listState = useProxy(apbdes) const listState = useProxy(apbdes);
const router = useRouter(); const router = useRouter();
const { const { data, page, totalPages, loading, load } = listState.findMany;
data,
page,
totalPages,
loading,
load,
} = listState.findMany
useShallowEffect(() => { useShallowEffect(() => {
load(page, 10, search) load(page, 10, search);
}, [page, search]) }, [page, search]);
const filteredData = data || [] const filteredData = data || [];
if (loading || !data) { if (loading || !data) {
return ( return (
@@ -61,7 +71,7 @@ function ListAPBDes({ search }: { search: string }) {
leftSection={<IconPlus size={18} />} leftSection={<IconPlus size={18} />}
color="blue" color="blue"
variant="light" variant="light"
onClick={() => router.push('/admin/landing-page/APBDes/create')} onClick={() => router.push('/admin/landing-page/apbdes/create')}
> >
Tambah Baru Tambah Baru
</Button> </Button>
@@ -71,8 +81,8 @@ function ListAPBDes({ search }: { search: string }) {
<Table highlightOnHover> <Table highlightOnHover>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh style={{ width: '30%'}}>Nama APBDes</TableTh> <TableTh style={{ width: '25%' }}>APBDes</TableTh>
<TableTh style={{ width: '30%' }}>Jumlah</TableTh> <TableTh style={{ width: '25%' }}>Tahun</TableTh>
<TableTh style={{ width: '25%' }}>Dokumen</TableTh> <TableTh style={{ width: '25%' }}>Dokumen</TableTh>
<TableTh style={{ width: '25%' }}>Aksi</TableTh> <TableTh style={{ width: '25%' }}>Aksi</TableTh>
</TableTr> </TableTr>
@@ -81,53 +91,54 @@ function ListAPBDes({ search }: { search: string }) {
{filteredData.length > 0 ? ( {filteredData.length > 0 ? (
filteredData.map((item) => ( filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd style={{ width: '30%' }}> <TableTd style={{ width: '25%' }}>
<Text fw={500} truncate="end">{item.name}</Text> <Text fw={500} lineClamp={1}>
</TableTd> APBDes {item.tahun}
<TableTd style={{ width: '30%' }}> </Text>
<Box w={150}>
<Text>Rp. {item.jumlah}</Text>
</Box>
</TableTd> </TableTd>
<TableTd style={{ width: '25%' }}> <TableTd style={{ width: '25%' }}>
<Box w={150}> <Text fw={500}>{item.tahun || '-'}</Text>
{item.file?.link ? (
<Button
component="a"
href={item.file.link}
target="_blank"
rel="noopener noreferrer"
variant="light"
leftSection={<IconFile size={18} />}
size="sm"
>
Lihat Dokumen
</Button>
) : (
<Text c="dimmed" fz="sm">Tidak ada dokumen</Text>
)}
</Box>
</TableTd> </TableTd>
<TableTd style={{ width: '25%' }}> <TableTd style={{ width: '25%' }}>
<Box w={80}> {item.file?.link ? (
<Button <Button
size="xs" component="a"
radius="md" href={item.file.link}
variant="light" target="_blank"
color="blue" rel="noopener noreferrer"
leftSection={<IconDeviceImacCog size={16} />} variant="light"
onClick={() => router.push(`/admin/landing-page/APBDes/${item.id}`)} leftSection={<IconFile size={16} />}
fullWidth size="xs"
> radius="sm"
Detail >
</Button> Lihat Dokumen
</Box> </Button>
) : (
<Text c="dimmed" fz="sm">
Tidak ada dokumen
</Text>
)}
</TableTd>
<TableTd style={{ width: '25%' }}>
<Box w={100}>
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={14} />}
onClick={() => router.push(`/admin/landing-page/apbdes/${item.id}`)}
fullWidth
>
Detail
</Button>
</Box>
</TableTd> </TableTd>
</TableTr> </TableTr>
)) ))
) : ( ) : (
<TableTr> <TableTr>
<TableTd colSpan={4}> <TableTd colSpan={5}>
<Center py={20}> <Center py={20}>
<Text color="dimmed">Tidak ada data APBDes yang cocok</Text> <Text color="dimmed">Tidak ada data APBDes yang cocok</Text>
</Center> </Center>
@@ -152,7 +163,7 @@ function ListAPBDes({ search }: { search: string }) {
/> />
</Center> </Center>
</Box> </Box>
) );
} }
export default APBDes; export default APBDes;

View File

@@ -0,0 +1,12 @@
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 },
};

View File

@@ -1,5 +1,7 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client'; 'use client';
import SelectSocialMediaEdit from '@/app/admin/(dashboard)/_com/selectSocialMediaEdit';
import profileLandingPageState from '@/app/admin/(dashboard)/_state/landing-page/profile'; import profileLandingPageState from '@/app/admin/(dashboard)/_state/landing-page/profile';
import colors from '@/con/colors'; import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch'; import ApiFetch from '@/lib/api-fetch';
@@ -14,7 +16,7 @@ import {
Text, Text,
TextInput, TextInput,
Title, Title,
Loader Loader,
} 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';
@@ -23,15 +25,45 @@ import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
type SosmedKey =
| 'none'
| 'facebook'
| 'instagram'
| 'tiktok'
| 'youtube'
| 'whatsapp'
| 'gmail'
| 'telegram'
| 'x'
| 'telephone'
| 'custom';
const sosmedMap: Record<SosmedKey, { label: string; src: string | null }> = {
none: { label: "None", src: '/no-image.jpg' },
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 },
};
function EditMediaSosial() { function EditMediaSosial() {
const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial); const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial);
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const [selectedSosmed, setSelectedSosmed] = useState<SosmedKey>('facebook');
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
icon: '',
iconUrl: '', iconUrl: '',
imageId: '', imageId: '',
}); });
@@ -39,13 +71,14 @@ function EditMediaSosial() {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({ const [originalData, setOriginalData] = useState({
name: "", name: '',
iconUrl: "", icon: '',
imageId: "", iconUrl: '',
imageUrl: "", imageId: '',
imageUrl: '',
}); });
// Load data by ID // Load Data by ID
useEffect(() => { useEffect(() => {
const id = params?.id as string; const id = params?.id as string;
if (!id) return; if (!id) return;
@@ -54,81 +87,97 @@ function EditMediaSosial() {
try { try {
const data = await stateMediaSosial.update.load(id); const data = await stateMediaSosial.update.load(id);
if (data) { if (!data) return;
// isi form awal
const newForm = {
name: data.name || "",
iconUrl: data.iconUrl || "",
imageId: data.imageId || "",
};
setFormData(newForm);
// simpan juga versi original // Tentukan default/custom icon
setOriginalData({ // Tentukan default/custom icon
...newForm, if (data.imageId) {
imageUrl: data.image?.link || "", setSelectedSosmed('custom');
}); } else {
// ✅ Gunakan langsung data.icon jika ada dan valid
setPreviewImage(data.image?.link || null); if (data.icon && sosmedMap[data.icon as SosmedKey]) {
setSelectedSosmed(data.icon as SosmedKey);
} else {
setSelectedSosmed('none'); // fallback
}
} }
} catch (error) {
console.error('Error loading media sosial:', error); const newForm = {
toast.error( name: data.name || '',
error instanceof Error ? error.message : 'Gagal mengambil data media sosial' icon: data.icon || '',
); iconUrl: data.iconUrl || '',
imageId: data.imageId || '',
};
setFormData(newForm);
setOriginalData({
...newForm,
imageUrl: data.image?.link || '',
});
setPreviewImage(data.image?.link || null);
} catch {
toast.error('Gagal mengambil data media sosial');
} }
}; };
loadData(); loadData();
}, [params?.id]); }, [params?.id]);
const handleChange = (field: string, value: string) => { const handleChange = (field: keyof typeof formData, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value })); setFormData((prev) => ({ ...prev, [field]: value }));
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
// update global state hanya saat submit
stateMediaSosial.update.form = { ...stateMediaSosial.update.form, ...formData }; stateMediaSosial.update.form = { ...stateMediaSosial.update.form, ...formData };
if (file) { if (file) {
const res = await ApiFetch.api.fileStorage.create.post({ file, name: file.name }); const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
const uploaded = res.data?.data; const uploaded = res.data?.data;
if (!uploaded?.id) return toast.error('Gagal upload gambar'); if (!uploaded?.id) {
toast.error('Gagal upload gambar');
return;
}
stateMediaSosial.update.form.imageId = uploaded.id; stateMediaSosial.update.form.imageId = uploaded.id;
} }
// 🚨 Tambahkan ini untuk debugging
console.log("Data yang akan dikirim ke backend:", stateMediaSosial.update.form);
await stateMediaSosial.update.update(); await stateMediaSosial.update.update();
toast.success('Media sosial berhasil diperbarui!'); toast.success('Media sosial berhasil diperbarui!');
router.push('/admin/landing-page/profil/media-sosial'); router.push('/admin/landing-page/profil/media-sosial');
} catch (error) { } catch (error) {
console.error('Error updating media sosial:', error); console.error("Error di handleSubmit:", error); // 🚨 Tambahkan ini juga
toast.error('Terjadi kesalahan saat memperbarui media sosial'); toast.error('Terjadi kesalahan saat memperbarui media sosial');
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}; };
// ✅ Tombol Batal → balikin ke data original
const handleResetForm = () => { const handleResetForm = () => {
setFormData({ setFormData({
name: originalData.name, name: originalData.name,
icon: originalData.icon,
iconUrl: originalData.iconUrl, iconUrl: originalData.iconUrl,
imageId: originalData.imageId, imageId: originalData.imageId,
}); });
setPreviewImage(originalData.imageUrl || null); setPreviewImage(originalData.imageUrl || null);
setFile(null); setFile(null);
toast.info("Form dikembalikan ke data awal"); toast.info('Form dikembalikan ke data awal');
}; };
return ( return (
<Box <Box px={{ base: 'sm', md: 'lg' }} py="md">
px={{ base: 'sm', md: 'lg' }}
py="md"
>
<Group mb="md"> <Group mb="md">
<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} />
@@ -147,80 +196,119 @@ function EditMediaSosial() {
style={{ border: '1px solid #e0e0e0' }} style={{ border: '1px solid #e0e0e0' }}
> >
<Stack gap="md"> <Stack gap="md">
{/* Upload Gambar */} {/* Upload / Icon */}
<Box> <Box>
<Text fw="bold" fz="sm" mb={6}> <Text fw="bold" fz="sm" mb={6}>
Gambar Program Inovasi Icon / Gambar Media Sosial
</Text> </Text>
<Dropzone
onDrop={(files) => { {/* Custom Upload */}
const selectedFile = files[0]; {/* PILIH ICON */}
if (selectedFile) { <SelectSocialMediaEdit
setFile(selectedFile); value={selectedSosmed}
setPreviewImage(URL.createObjectURL(selectedFile)); onChange={(key) => {
setSelectedSosmed(key);
if (key === 'custom') {
// custom → gunakan Dropzone
setFormData((prev) => ({
...prev,
icon: '',
imageId: '',
}));
return;
} }
// default → pakai icon bawaan
setFormData((prev) => ({
...prev,
icon: key, // <-- simpan 'facebook', bukan path
imageId: '',
}));
}} }}
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 gambar .png, .jpg, .jpeg, webp
</Text>
</Stack>
</Group>
</Dropzone>
{/* ✅ Preview gambar + tombol X */} {selectedSosmed === 'custom' ? (
{previewImage && ( <>
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}> <Dropzone
<Image onDrop={(files) => {
src={previewImage} const selectedFile = files[0];
alt="Preview Gambar" if (selectedFile) {
setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile));
handleChange('imageId', '');
}
}}
onReject={() => toast.error('File tidak valid')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md" radius="md"
style={{ p="xl"
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} /> <Group justify="center" gap="xl" mih={180}>
</ActionIcon> <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 align="center" gap="xs">
<Text fw={500}>Seret gambar atau klik untuk pilih</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format: .png, .jpg, .jpeg, .webp
</Text>
</Stack>
</Group>
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview"
radius="md"
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
/>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setFile(null);
setPreviewImage(null);
handleChange('imageId', '');
}}
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</>
) : (
// Default icon
<Box mt="xs">
<Image
src={sosmedMap[selectedSosmed].src || ''}
alt="Icon bawaan"
width={40}
height={40}
radius="md"
style={{ border: '1px solid #ddd', padding: 4, background: '#fff' }}
/>
</Box> </Box>
)} )}
</Box> </Box>
@@ -237,25 +325,17 @@ function EditMediaSosial() {
{/* Link Media Sosial */} {/* Link Media Sosial */}
<TextInput <TextInput
label="Link Media Sosial / Nomor Telepon" label="Link Media Sosial / Nomor Telepon"
placeholder="Masukkan link media sosial atau nomor telepon" placeholder="Masukkan link atau nomor telepon"
value={formData.iconUrl} value={formData.iconUrl}
onChange={(e) => handleChange('iconUrl', e.target.value)} onChange={(e) => handleChange('iconUrl', e.target.value)}
required required
/> />
<Group justify="right"> <Group justify="right">
{/* Tombol Batal */} <Button variant="outline" color="gray" radius="md" size="md" onClick={handleResetForm}>
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal Batal
</Button> </Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client' 'use client'
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import profileLandingPageState from '@/app/admin/(dashboard)/_state/landing-page/profile'; import profileLandingPageState from '@/app/admin/(dashboard)/_state/landing-page/profile';
@@ -8,6 +9,7 @@ import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import { sosmedMap } from '../../_lib/sosmed';
function DetailMediaSosial() { function DetailMediaSosial() {
const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial); const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial);
@@ -16,6 +18,14 @@ function DetailMediaSosial() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const getIconSource = (item: any) => {
if (item.image?.link) return item.image.link;
if (item.icon && sosmedMap[item.icon as keyof typeof sosmedMap]?.src) {
return sosmedMap[item.icon as keyof typeof sosmedMap].src;
}
return null;
};
useShallowEffect(() => { useShallowEffect(() => {
stateMediaSosial.findUnique.load(params?.id as string); stateMediaSosial.findUnique.load(params?.id as string);
}, []); }, []);
@@ -77,46 +87,47 @@ function DetailMediaSosial() {
<Box> <Box>
<Text fz="lg" fw="bold">Gambar</Text> <Text fz="lg" fw="bold">Gambar</Text>
{data.image?.link ? ( {(() => {
<Image const src = getIconSource(data);
src={data.image.link}
alt={data.name || 'Gambar Media Sosial'}
w="100%"
maw={120} // max width biar tidak keluar layar
h="auto"
radius="md"
fit="cover"
loading="lazy"
/>
) : ( if (src) {
<Text fz="sm" c="dimmed">Tidak ada gambar</Text> return (
)} <Image
loading="lazy"
src={src}
alt={data.name}
fit={data.image?.link ? "cover" : "contain"}
/>
);
}
return <Box bg={colors['blue-button']} w="100%" h="100%" />;
})()}
</Box> </Box>
<Group gap="sm"> <Group gap="sm">
<Button <Button
color="red" color="red"
onClick={() => { onClick={() => {
setSelectedId(data.id); setSelectedId(data.id);
setModalHapus(true); setModalHapus(true);
}} }}
variant="light" variant="light"
radius="md" radius="md"
size="md" size="md"
> >
<IconTrash size={20} /> <IconTrash size={20} />
</Button> </Button>
<Button <Button
color="green" color="green"
onClick={() => router.push(`/admin/landing-page/profil/media-sosial/${data.id}/edit`)} onClick={() => router.push(`/admin/landing-page/profil/media-sosial/${data.id}/edit`)}
variant="light" variant="light"
radius="md" radius="md"
size="md" size="md"
> >
<IconEdit size={20} /> <IconEdit size={20} />
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -1,5 +1,6 @@
/* 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 ApiFetch from '@/lib/api-fetch'; import ApiFetch from '@/lib/api-fetch';
import { import {
@@ -22,10 +23,41 @@ import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import profileLandingPageState from '../../../../_state/landing-page/profile'; import profileLandingPageState from '../../../../_state/landing-page/profile';
import SelectSosialMedia from '@/app/admin/(dashboard)/_com/selectSocialMedia';
// ⭐ Tambah type SosmedKey
type SosmedKey =
| 'facebook'
| 'instagram'
| 'tiktok'
| 'youtube'
| 'whatsapp'
| 'gmail'
| 'telegram'
| 'x'
| 'telephone'
| 'custom';
// ⭐ mapping icon sosmed bawaan
const sosmedMap: Record<SosmedKey, { label: string; src: string | null }> = {
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 },
};
export default function CreateMediaSosial() { export default function CreateMediaSosial() {
const router = useRouter(); const router = useRouter();
const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial); const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial);
const [selectedSosmed, setSelectedSosmed] = useState<SosmedKey>('facebook');
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 [isSubmitting, setIsSubmitting] = useState(false);
@@ -39,16 +71,34 @@ export default function CreateMediaSosial() {
name: '', name: '',
imageId: '', imageId: '',
iconUrl: '', iconUrl: '',
icon: ''
}; };
setPreviewImage(null);
setFile(null); setFile(null);
setPreviewImage(null);
setSelectedSosmed('facebook');
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
// ──────────────── ⭐ CASE 1: PAKAI ICON DEFAULT ────────────────
if (selectedSosmed !== 'custom') {
stateMediaSosial.create.form.imageId = null;
stateMediaSosial.create.form.icon = sosmedMap[selectedSosmed].src!;
await stateMediaSosial.create.create();
resetForm();
router.push('/admin/landing-page/profil/media-sosial');
return;
}
// ──────────────── ⭐ CASE 2: CUSTOM ICON → WAJIB UPLOAD ────────────────
if (!file) { if (!file) {
return toast.warn('Silakan pilih file gambar terlebih dahulu'); toast.warn('Silakan upload icon custom terlebih dahulu');
return;
} }
const res = await ApiFetch.api.fileStorage.create.post({ const res = await ApiFetch.api.fileStorage.create.post({
@@ -59,10 +109,12 @@ export default function CreateMediaSosial() {
const uploaded = res.data?.data; const uploaded = res.data?.data;
if (!uploaded?.id) { if (!uploaded?.id) {
return toast.error('Gagal mengunggah gambar, silakan coba lagi'); toast.error('Gagal mengunggah icon custom');
return;
} }
stateMediaSosial.create.form.imageId = uploaded.id; stateMediaSosial.create.form.imageId = uploaded.id;
stateMediaSosial.create.form.icon = null;
await stateMediaSosial.create.create(); await stateMediaSosial.create.create();
@@ -78,6 +130,7 @@ export default function CreateMediaSosial() {
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header */}
<Group mb="md"> <Group mb="md">
<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} />
@@ -96,112 +149,110 @@ export default function CreateMediaSosial() {
style={{ border: '1px solid #e0e0e0' }} style={{ border: '1px solid #e0e0e0' }}
> >
<Stack gap="md"> <Stack gap="md">
<Box> {/* Select Sosmed */}
<Text fw="bold" fz="sm" mb={6}> <SelectSosialMedia value={selectedSosmed} onChange={setSelectedSosmed} />
Gambar Program Inovasi
</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={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>
{/* ✅ Preview gambar + tombol X */} {/* Custom icon uploader */}
{previewImage && ( {selectedSosmed === 'custom' && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}> <Box>
<Image <Text fw="bold" fz="sm" mb={6}>
src={previewImage} Upload Custom Icon
alt="Preview Gambar" </Text>
radius="md"
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
loading="lazy"
/>
{/* Tombol hapus (pojok kanan atas) */} <Dropzone
<ActionIcon onDrop={(files) => {
variant="filled" const selectedFile = files[0];
color="red" if (selectedFile) {
radius="xl" setFile(selectedFile);
size="sm" setPreviewImage(URL.createObjectURL(selectedFile));
pos="absolute" }
top={5} }}
right={5} onReject={() => toast.error('File tidak valid')}
onClick={() => { maxSize={5 * 1024 ** 2}
setPreviewImage(null); accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
setFile(null); radius="md"
}} p="xl"
style={{ >
boxShadow: '0 2px 6px rgba(0,0,0,0.15)', <Group justify="center" gap="xl" mih={180}>
}} <Dropzone.Accept>
> <IconUpload size={48} color={colors['blue-button']} stroke={1.5} />
<IconX size={14} /> </Dropzone.Accept>
</ActionIcon> <Dropzone.Reject>
</Box> <IconX size={48} color="red" stroke={1.5} />
)} </Dropzone.Reject>
</Box> <Dropzone.Idle>
<IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle>
<Stack align="center" gap="xs">
<Text fw={500}>Seret gambar atau klik untuk pilih</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format .png, .jpg, .jpeg, webp
</Text>
</Stack>
</Group>
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview"
radius="md"
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
/>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setFile(null);
setPreviewImage(null);
}}
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
)}
{/* Input name */}
<TextInput <TextInput
label="Nama Media Sosial / Kontak" label="Nama Media Sosial"
placeholder="Masukkan nama media sosial atau kontak" placeholder="Masukkan nama media sosial"
value={stateMediaSosial.create.form.name || ''} value={stateMediaSosial.create.form.name ?? ''}
onChange={(e) => (stateMediaSosial.create.form.name = e.target.value)} onChange={(e) => (stateMediaSosial.create.form.name = e.target.value)}
required required
/> />
{/* Input link */}
<TextInput <TextInput
label="Link Media Sosial / Nomor Telepon" label="Link / Kontak"
placeholder="Masukkan link media sosial atau nomor telepon" placeholder="Masukkan link atau nomor"
value={stateMediaSosial.create.form.iconUrl || ''} value={stateMediaSosial.create.form.iconUrl ?? ''}
onChange={(e) => (stateMediaSosial.create.form.iconUrl = e.target.value)} onChange={(e) => (stateMediaSosial.create.form.iconUrl = e.target.value)}
required required
/> />
{/* Actions */}
<Group justify="right"> <Group justify="right">
<Button <Button variant="outline" color="gray" radius="md" onClick={resetForm}>
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset Reset
</Button> </Button>
<Button <Button
onClick={handleSubmit}
radius="md" radius="md"
size="md" onClick={handleSubmit}
style={{ style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`, background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff', color: '#fff',

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Group, Image, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core'; import { Box, Button, Center, Group, Image, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core';
@@ -8,6 +9,7 @@ import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header'; import HeaderSearch from '../../../_com/header';
import profileLandingPageState from '../../../_state/landing-page/profile'; import profileLandingPageState from '../../../_state/landing-page/profile';
import { sosmedMap } from '../_lib/sosmed';
function MediaSosial() { function MediaSosial() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -29,6 +31,14 @@ function ListMediaSosial({ search }: { search: string }) {
const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial) const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial)
const router = useRouter(); const router = useRouter();
const getIconSource = (item: any) => {
if (item.image?.link) return item.image.link;
if (item.icon && sosmedMap[item.icon as keyof typeof sosmedMap]?.src) {
return sosmedMap[item.icon as keyof typeof sosmedMap].src;
}
return null;
};
const { const {
data, data,
page, page,
@@ -56,9 +66,9 @@ function ListMediaSosial({ search }: { search: string }) {
<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 Media Sosial</Title> <Title order={4}>Daftar Media Sosial</Title>
<Button leftSection={<IconPlus size={18} />} color="blue" variant="light" onClick={() => router.push('/admin/landing-page/profil/media-sosial/create')}> <Button leftSection={<IconPlus size={18} />} color="blue" variant="light" onClick={() => router.push('/admin/landing-page/profil/media-sosial/create')}>
Tambah Baru Tambah Baru
</Button> </Button>
</Group> </Group>
<Box style={{ overflowX: "auto" }}> <Box style={{ overflowX: "auto" }}>
<Table highlightOnHover> <Table highlightOnHover>
@@ -77,13 +87,26 @@ function ListMediaSosial({ search }: { search: string }) {
<TableTd style={{ width: '25%', }}> <TableTd style={{ width: '25%', }}>
<Text fw={500} truncate="end" lineClamp={1}>{item.name}</Text> <Text fw={500} truncate="end" lineClamp={1}>{item.name}</Text>
</TableTd> </TableTd>
<TableTd style={{ width: '20%', }}> <TableTd style={{ width: '20%' }}>
<Box w={50} h={50} style={{ borderRadius: 8, overflow: 'hidden', }}> <Box w={50} h={50} style={{ borderRadius: 8, overflow: 'hidden' }}>
{item.image?.link ? (
<Image loading='lazy' src={item.image.link} alt={item.name} fit="cover" /> {(() => {
) : ( const src = getIconSource(item);
<Box bg={colors['blue-button']} w="100%" h="100%" />
)} if (src) {
return (
<Image
loading="lazy"
src={src}
alt={item.name}
fit={item.image?.link ? "cover" : "contain"}
/>
);
}
return <Box bg={colors['blue-button']} w="100%" h="100%" />;
})()}
</Box> </Box>
</TableTd> </TableTd>
<TableTd style={{ width: '20%', }}> <TableTd style={{ width: '20%', }}>

View File

@@ -30,12 +30,13 @@ function Page() {
return ( return (
<Paper bg={colors['white-1']} p="lg" radius="md" shadow="sm"> <Paper bg={colors['white-1']} p="lg" radius="md" shadow="sm">
<Stack gap="md"> <Stack gap="md">
<Grid align="center"> <Grid>
<GridCol span={{ base: 12, md: 11 }}> <GridCol span={{ base: 12, md: 11 }}>
<Title order={3} c={colors['blue-button']}>Preview Profil PPID</Title> <Title order={3} c={colors['blue-button']}>Preview Profil PPID</Title>
</GridCol> </GridCol>
<GridCol span={{ base: 12, md: 1 }}> <GridCol span={{ base: 12, md: 1 }}>
<Button <Button
w={{base: '100%', md: "110%"}}
c="green" c="green"
variant="light" variant="light"
leftSection={<IconEdit size={18} stroke={2} />} leftSection={<IconEdit size={18} stroke={2} />}

View File

@@ -0,0 +1,34 @@
// src/app/admin/(dashboard)/user&role/_com/dynamicNavbar.ts
import { devBar, navBar, role1, role2, role3 } from '@/app/admin/_com/list_PageAdmin';
// ✅ Helper: normalisasi ID menu agar konsisten
const normalizeMenuId = (id: string): string => {
return id.trim().toLowerCase();
};
export function getNavbar({
roleId,
menuIds,
}: {
roleId: number;
menuIds?: string[] | null;
}) {
// ✅ Jika menuIds tersedia, gunakan untuk filter — dengan normalisasi
if (menuIds && menuIds.length > 0) {
// Normalisasi semua menuIds dari DB/state
const normalizedMenuSet = new Set(menuIds.map(id => normalizeMenuId(id)));
return navBar.filter(section => {
const normalizedSectionId = normalizeMenuId(section.id);
return normalizedMenuSet.has(normalizedSectionId);
});
}
// 🔁 Fallback ke role-based navigation
if (roleId === 0) return devBar;
if (roleId === 1) return navBar;
if (roleId === 2) return role1;
if (roleId === 3) return role2;
if (roleId === 4) return role3;
return [];
}

View File

@@ -2,7 +2,7 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core'; import { Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
import { IconForms, IconUser } from '@tabler/icons-react'; import { IconBrush, IconForms, IconUser } 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';
@@ -23,6 +23,12 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
href: "/admin/user&role/role", href: "/admin/user&role/role",
icon: <IconForms size={18} stroke={1.8} />, icon: <IconForms size={18} stroke={1.8} />,
}, },
{
label: "Menu Access",
value: "menu-access",
href: "/admin/user&role/menu-access",
icon: <IconBrush size={18} stroke={1.8} />,
}
]; ];
const currentTab = tabs.find(tab => tab.href === pathname); const currentTab = tabs.find(tab => tab.href === pathname);

View File

@@ -0,0 +1,129 @@
/* eslint-disable react-hooks/exhaustive-deps */
// src/app/admin/user&role/menu-access/page.tsx
'use client'
import { navBar } from '@/app/admin/_com/list_PageAdmin'
import { Button, Checkbox, Group, Paper, Select, Stack, Text, Title } from '@mantine/core'
import { useEffect, useState } from 'react'
import { useProxy } from 'valtio/utils'
import user from '../../_state/user/user-state'
import { useShallowEffect } from '@mantine/hooks'
// ✅ Helper: ekstrak semua menu ID dari struktur navBar
const extractMenuIds = (navSections: typeof navBar) => {
return navSections.map(section => ({
value: section.id, // "Landing Page", "Kesehatan", dll
label: section.name // "Landing Page", "Kesehatan", dll
}));
};
function MenuAccessPage() {
const stateUser = useProxy(user.userState)
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
const [userMenus, setUserMenus] = useState<string[]>([])
useShallowEffect(() => {
stateUser.findMany.load()
}, [])
// ✅ Gunakan helper untuk ekstrak menu
const availableMenus = extractMenuIds(navBar);
// Ambil data menu akses user
const loadUserMenuAccess = async () => {
if (!selectedUserId) return
try {
// ✅ Perbaiki URL: gunakan query string bukan dynamic route
const res = await fetch(`/api/admin/user-menu-access?userId=${selectedUserId}`)
const data = await res.json()
if (data.success) {
setUserMenus(data.menuIds || [])
}
} catch (error) {
console.error('Gagal memuat menu akses:', error)
}
}
useEffect(() => {
if (selectedUserId) {
loadUserMenuAccess()
}
}, [selectedUserId])
const handleToggleMenu = (menuId: string) => {
setUserMenus(prev =>
prev.includes(menuId)
? prev.filter(id => id !== menuId)
: [...prev, menuId]
)
}
const handleSave = async () => {
if (!selectedUserId) return
try {
const res = await fetch('/api/admin/user-menu-access', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: selectedUserId, menuIds: userMenus }),
})
const data = await res.json()
if (data.success) {
alert('Menu akses berhasil disimpan')
}
} catch (error) {
console.error('Gagal menyimpan menu akses:', error)
alert('Terjadi kesalahan')
}
}
return (
<Stack>
<Title order={2}>Tampilan Menu</Title>
<Paper p="xl" shadow="md" radius="md">
<Stack gap="lg">
<Group>
<Text fw={500}>Pilih User:</Text>
<Select
placeholder="Pilih user"
data={stateUser.findMany.data.map(u => ({
value: u.id,
label: `${u.username} (${u.nomor})`,
}))}
value={selectedUserId}
onChange={setSelectedUserId}
w={300}
/>
</Group>
{selectedUserId && (
<>
<Text fw={500}>Menu yang Bisa Diakses:</Text>
<Stack>
{availableMenus.map(menu => (
<Checkbox
key={menu.value}
label={menu.label}
checked={userMenus.includes(menu.value)}
onChange={() => handleToggleMenu(menu.value)}
/>
))}
</Stack>
<Button onClick={handleSave} mt="md">
Simpan Perubahan
</Button>
</>
)}
</Stack>
</Paper>
</Stack>
)
}
export default MenuAccessPage

View File

@@ -1,7 +1,8 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Loader, Group, MultiSelect, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import { Box, Button, Group, Loader, Paper, Stack, Text, TextInput, Title } from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
@@ -9,6 +10,7 @@ import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import user from '../../../_state/user/user-state'; import user from '../../../_state/user/user-state';
function EditRole() { function EditRole() {
const stateRole = useProxy(user.roleState); const stateRole = useProxy(user.roleState);
const router = useRouter(); const router = useRouter();
@@ -17,46 +19,37 @@ function EditRole() {
// Controlled local state // Controlled local state
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
permissions: [] as string[],
}); });
const [originalData, setOriginalData] = useState({ const [originalData, setOriginalData] = useState({
name: '', name: '',
permissions: [] as string[],
}); });
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
// Load role data // Load role data
const loadRole = useCallback(async (id: string) => { const loadRole = useCallback(async (id: string) => {
try { const data = await stateRole.update.load(id);
const data = await stateRole.update.load(id);
if (data) { if (data) {
setFormData({ setFormData({
name: data.name || '', name: data.name ?? '',
permissions: data.permissions || [], });
});
setOriginalData({ setOriginalData({
name: data.name || '', name: data.name ?? '',
permissions: data.permissions || [], });
});
}
} catch (error) {
console.error('Error loading role:', error);
toast.error(error instanceof Error ? error.message : 'Gagal mengambil data role');
} }
}, [stateRole.update]); }, []);
useEffect(() => { useEffect(() => {
stateRole.findMany.load(); // Load permissions/options stateRole.findMany.load(); // load permission
const id = params?.id as string; if (params?.id) loadRole(params.id as string);
if (id) loadRole(id); }, [params?.id]);
}, [params?.id, loadRole, stateRole.findMany]);
const handleResetForm = () => { const handleResetForm = () => {
setFormData({ setFormData({
name: originalData.name, name: originalData.name,
permissions: originalData.permissions,
}); });
toast.info("Form dikembalikan ke data awal"); toast.info("Form dikembalikan ke data awal");
}; };
@@ -66,10 +59,6 @@ function EditRole() {
toast.error('Nama role tidak boleh kosong'); toast.error('Nama role tidak boleh kosong');
return; return;
} }
if (!formData.permissions.length) {
toast.error('Pilih minimal satu permission');
return;
}
try { try {
setIsSubmitting(true); setIsSubmitting(true);
@@ -77,7 +66,6 @@ function EditRole() {
stateRole.update.form = { stateRole.update.form = {
...stateRole.update.form, ...stateRole.update.form,
name: formData.name, name: formData.name,
permissions: formData.permissions,
}; };
await stateRole.update.update(); await stateRole.update.update();
toast.success('Role berhasil diperbarui!'); toast.success('Role berhasil diperbarui!');
@@ -116,24 +104,7 @@ function EditRole() {
label={<Text fw="bold" fz="sm">Nama Role</Text>} label={<Text fw="bold" fz="sm">Nama Role</Text>}
placeholder="Masukkan nama role" placeholder="Masukkan nama role"
/> />
<MultiSelect <Group justify="right">
value={formData.permissions}
onChange={(val) => setFormData({ ...formData, permissions: val })}
label={<Text fw="bold" fz="sm">Permission</Text>}
placeholder="Pilih permission"
data={
stateRole.findMany.data?.map((v) => ({
value: v.id,
label: v.name,
})) || []
}
clearable
searchable
required
error={!formData.permissions.length ? 'Pilih minimal satu permission' : undefined}
/>
<Group justify="right">
{/* Tombol Batal */} {/* Tombol Batal */}
<Button <Button
variant="outline" variant="outline"

View File

@@ -5,9 +5,8 @@ import {
Box, Box,
Button, Button,
Group, Group,
MultiSelect,
Paper,
Loader, Loader,
Paper,
Stack, Stack,
TextInput, TextInput,
Title Title
@@ -15,9 +14,9 @@ import {
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack } 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 { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import user from '../../../_state/user/user-state'; import user from '../../../_state/user/user-state';
import { toast } from 'react-toastify';
export default function CreateRole() { export default function CreateRole() {
@@ -31,8 +30,7 @@ export default function CreateRole() {
const resetForm = () => { const resetForm = () => {
stateRole.create.form = { stateRole.create.form = {
name: '', name: ''
permissions: [],
}; };
}; };
@@ -80,28 +78,6 @@ export default function CreateRole() {
required required
/> />
</Box> </Box>
<Box>
<MultiSelect
label="Permission"
placeholder="Pilih permission"
data={
Array.from(
new Set(
stateRole.findMany.data
?.map((item) => item.permissions)
.flat()
)
)
.filter((p): p is string => typeof p === 'string')
.map((p) => ({ label: p, value: p }))
}
value={stateRole.create.form.permissions}
onChange={(value) => (stateRole.create.form.permissions = value)}
required
/>
</Box>
<Group justify="right"> <Group justify="right">
<Button <Button

View File

@@ -1,14 +1,14 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Group, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title, Tooltip } from '@mantine/core'; import { Box, Button, Center, Group, Pagination, Paper, Select, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title, Tooltip } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconCheck, IconSearch, IconX } from '@tabler/icons-react'; import { IconCheck, IconSearch, IconTrash, IconX } from '@tabler/icons-react';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../_com/header'; import HeaderSearch from '../../_com/header';
import { ModalKonfirmasiHapus } from '../../_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '../../_com/modalKonfirmasiHapus';
import user from '../../_state/user/user-state'; import user from '../../_state/user/user-state';
import { authStore } from '@/store/authStore';
function User() { function User() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -27,9 +27,10 @@ function User() {
} }
function ListUser({ search }: { search: string }) { function ListUser({ search }: { search: string }) {
const stateUser = useProxy(user.userState) const stateUser = useProxy(user.userState);
const [modalHapus, setModalHapus] = useState(false) const stateRole = useProxy(user.roleState);
const [selectedId, setSelectedId] = useState<string | null>(null) const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const { const {
data, data,
@@ -41,20 +42,105 @@ function ListUser({ search }: { search: string }) {
const handleDelete = () => { const handleDelete = () => {
if (selectedId) { if (selectedId) {
stateUser.delete.submit(selectedId) stateUser.deleteUser.delete(selectedId);
setModalHapus(false) setModalHapus(false);
setSelectedId(null) setSelectedId(null);
stateUser.findMany.load();
stateUser.findMany.load()
} }
} };
useShallowEffect(() => { useShallowEffect(() => {
load(page, 10, search) stateRole.findMany.load();
}, [page, search]) load(page, 10, search);
}, [page, search]);
// ✅ Helper function untuk nama role
const getRoleName = (roleId: string) => {
// Cari dari data role yang sudah diload
const role = stateRole.findMany.data.find((r) => r.id === roleId);
return role?.name || "Unknown Role";
};
// ✅ Handler untuk perubahan role dengan konfirmasi
const handleRoleChange = async (
userId: string,
username: string,
oldRoleId: string,
newRoleId: string
) => {
// Skip jika sama
if (oldRoleId === newRoleId) {
return true;
}
// ✅ Konfirmasi perubahan role
const confirmed = window.confirm(
`⚠️ PERINGATAN\n\n` +
`Mengubah role untuk "${username}" akan:\n` +
`• Logout user otomatis dari semua device\n` +
`• Mengubah akses menu sesuai role baru\n\n` +
`Role: ${getRoleName(oldRoleId)}${getRoleName(newRoleId)}\n\n` +
`Lanjutkan?`
);
if (!confirmed) {
// Reload data untuk reset dropdown ke nilai lama
stateUser.findMany.load(page, 10, search);
return false;
}
// ✅ Submit update
const success = await stateUser.update.submit({
id: userId,
roleId: newRoleId,
});
if (success) {
// ✅ Logout user jika sedang mengedit diri sendiri
const currentUserId = authStore.user?.id;
if (currentUserId === userId) {
authStore.setUser(null);
document.cookie = `${process.env.BASE_SESSION_KEY}=; Max-Age=0; path=/;`;
alert("Perubahan memerlukan login ulang");
window.location.href = "/login";
return;
}
// Reload data
stateUser.findMany.load(page, 10, search);
}
return success;
};
// ✅ Handler untuk toggle isActive
const handleToggleActive = async (userId: string, currentStatus: boolean) => {
const success = await stateUser.update.submit({
id: userId,
isActive: !currentStatus,
});
if (success) {
// ✅ Logout user jika sedang mengedit diri sendiri
const currentUserId = authStore.user?.id;
if (currentUserId === userId) {
authStore.setUser(null);
document.cookie = `${process.env.BASE_SESSION_KEY}=; Max-Age=0; path=/;`;
alert("Perubahan memerlukan login ulang");
window.location.href = "/login";
return;
}
// Reload data
stateUser.findMany.load(page, 10, search);
}
};
const filteredData = (data || []).filter((item) => {
return item.roleId !== "0" && item.roleId !== "1";
});
const filteredData = data || []
if (loading || !data) { if (loading || !data) {
return ( return (
@@ -78,25 +164,51 @@ function ListUser({ search }: { search: string }) {
<TableTh style={{ width: '20%' }}>Nomor</TableTh> <TableTh style={{ width: '20%' }}>Nomor</TableTh>
<TableTh style={{ width: '20%' }}>Role</TableTh> <TableTh style={{ width: '20%' }}>Role</TableTh>
<TableTh style={{ width: '15%' }}>Aktif / Nonaktif</TableTh> <TableTh style={{ width: '15%' }}>Aktif / Nonaktif</TableTh>
<TableTh style={{ width: '15%' }}>Hapus</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 style={{ width: '25%', }}> <TableTd style={{ width: '25%' }}>
<Text fw={500} truncate="end" lineClamp={1}>{item.username}</Text> <Text fw={500} truncate="end" lineClamp={1}>
{item.username}
</Text>
</TableTd> </TableTd>
<TableTd style={{ width: '20%', }}> <TableTd style={{ width: '20%' }}>
<Text truncate fz="sm" c="dimmed"> <Text truncate fz="sm" c="dimmed">
{item.nomor} {item.nomor}
</Text> </Text>
</TableTd> </TableTd>
<TableTd style={{ width: '20%', }}> <TableTd style={{ width: '20%' }}>
<Text truncate fz="sm" c="dimmed"> <Select
{item.role.name} placeholder="Pilih role"
</Text> data={stateRole.findMany.data
.filter(r => r.id !== "0" && r.id !== "1") // ❌ Sembunyikan SUPERADMIN dan DEVELOPER
.map(r => ({
label: r.name,
value: r.id,
}))}
value={item.roleId}
onChange={(val) => {
if (!val) return;
// ✅ Panggil handleRoleChange dengan konfirmasi
handleRoleChange(
item.id,
item.username,
item.roleId,
val
);
}}
searchable
clearable={false}
nothingFoundMessage="Role tidak ditemukan"
disabled={stateUser.update.loading}
/>
</TableTd> </TableTd>
<TableTd style={{ width: '15%' }}> <TableTd style={{ width: '15%' }}>
<Tooltip <Tooltip
label={item.isActive ? "Nonaktifkan user" : "Aktifkan user"} label={item.isActive ? "Nonaktifkan user" : "Aktifkan user"}
@@ -105,22 +217,34 @@ function ListUser({ search }: { search: string }) {
<Button <Button
variant="light" variant="light"
color={item.isActive ? "green" : "red"} color={item.isActive ? "green" : "red"}
onClick={async () => { onClick={() => handleToggleActive(item.id, item.isActive)}
await stateUser.updateActive.submit(item.id, !item.isActive) disabled={stateUser.update.loading}
stateUser.findMany.load(page, 10, search)
}}
> >
{item.isActive ? <IconCheck size={20} /> : <IconX size={20} />} {item.isActive ? <IconCheck size={20} /> : <IconX size={20} />}
</Button> </Button>
</Tooltip> </Tooltip>
</TableTd> </TableTd>
<TableTd style={{ width: '15%' }}>
<Button
variant="light"
color='red'
disabled={stateUser.deleteUser.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
>
<IconTrash size={20} />
</Button>
</TableTd>
</TableTr> </TableTr>
)) ))
) : ( ) : (
<TableTr> <TableTr>
<TableTd colSpan={4}> <TableTd colSpan={5}>
<Center py={20}> <Center py={20}>
<Text color="dimmed">Tidak ada data user yang cocok</Text> <Text c="dimmed">Tidak ada data user yang cocok</Text>
</Center> </Center>
</TableTd> </TableTd>
</TableTr> </TableTr>
@@ -129,6 +253,7 @@ function ListUser({ search }: { search: string }) {
</Table> </Table>
</Box> </Box>
</Paper> </Paper>
<Center> <Center>
<Pagination <Pagination
value={page} value={page}
@@ -143,6 +268,7 @@ function ListUser({ search }: { search: string }) {
radius="md" radius="md"
/> />
</Center> </Center>
{/* Modal Konfirmasi Hapus */} {/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus <ModalKonfirmasiHapus
opened={modalHapus} opened={modalHapus}
@@ -154,4 +280,4 @@ function ListUser({ search }: { search: string }) {
); );
} }
export default User; export default User;

View File

@@ -1,4 +1,4 @@
export const navBar = [ export const devBar = [
{ {
id: "Landing Page", id: "Landing Page",
name: "Landing Page", name: "Landing Page",
@@ -27,7 +27,7 @@ export const navBar = [
{ {
id: "Landing_Page_5", id: "Landing_Page_5",
name: "APBDes", name: "APBDes",
path: "/admin/landing-page/APBDes" path: "/admin/landing-page/apbdes"
}, },
{ {
id: "Landing_Page_6", id: "Landing_Page_6",
@@ -84,7 +84,6 @@ export const navBar = [
] ]
}, },
{ {
id: "Desa", id: "Desa",
name: "Desa", name: "Desa",
@@ -336,7 +335,824 @@ export const navBar = [
path: "/admin/lingkungan/konservasi-adat-bali/filosofi-tri-hita-karana" path: "/admin/lingkungan/konservasi-adat-bali/filosofi-tri-hita-karana"
} }
] ]
},
{
id: "Pendidikan",
name: "Pendidikan",
path: "",
children: [
{
id: "Pendidikan_1",
name: "Info Sekolah",
path: "/admin/pendidikan/info-sekolah/jenjang-pendidikan"
},
{
id: "Pendidikan_2",
name: "Beasiswa Desa",
path: "/admin/pendidikan/beasiswa-desa/beasiswa-pendaftar"
},
{
id: "Pendidikan_3",
name: "Program Pendidikan Anak",
path: "/admin/pendidikan/program-pendidikan-anak/program-unggulan"
},
{
id: "Pendidikan_4",
name: "Bimbingan Belajar Desa",
path: "/admin/pendidikan/bimbingan-belajar-desa/tujuan-program"
},
{
id: "Pendidikan_5",
name: "Pendidikan Non Formal",
path: "/admin/pendidikan/pendidikan-non-formal/tujuan-program"
},
{
id: "Pendidikan_6",
name: "Perpustakaan Digital",
path: "/admin/pendidikan/perpustakaan-digital/data-perpustakaan"
},
{
id: "Pendidikan_7",
name: "Data Pendidikan",
path: "/admin/pendidikan/data-pendidikan"
}
]
},
{
id: "User & Role",
name: "User & Role",
path: "",
children: [
{
id: "User",
name: "User",
path: "/admin/user&role/user"
},
{
id: "Role",
name: "Role",
path: "/admin/user&role/role"
},
{
id: "Menu Access",
name: "Menu Access",
path: "/admin/user&role/menu-access"
}
]
}
]
export const navBar = [
{
id: "Landing Page",
name: "Landing Page",
path: "",
children: [
{
id: "Landing_Page_1",
name: "Profil",
path: "/admin/landing-page/profil/program-inovasi"
},
{
id: "Landing_Page_2",
name: "Desa Anti Korupsi",
path: "/admin/landing-page/desa-anti-korupsi/list-desa-anti-korupsi"
},
{
id: "Landing_Page_3",
name: "Indeks Kepuasan Masyarakat",
path: "/admin/landing-page/indeks-kepuasan-masyarakat/grafik-kepuasan-masyarakat"
},
{
id: "Landing_Page_4",
name: "SDGs",
path: "/admin/landing-page/SDGs"
},
{
id: "Landing_Page_5",
name: "APBDes",
path: "/admin/landing-page/apbdes"
},
{
id: "Landing_Page_6",
name: "Prestasi Desa",
path: "/admin/landing-page/prestasi-desa/list-prestasi-desa"
}
]
},
{
id: "PPID",
name: "PPID",
path: "",
children: [
{
id: "PPID_1",
name: "Profil PPID",
path: "/admin/ppid/profil-ppid"
},
{
id: "PPID_2",
name: "Struktur PPID",
path: "/admin/ppid/struktur-ppid/pegawai"
},
{
id: "PPID_3",
name: "Visi Misi PPID",
path: "/admin/ppid/visi-misi-ppid"
},
{
id: "PPID_4",
name: "Dasar Hukum",
path: "/admin/ppid/dasar-hukum"
},
{
id: "PPID_5",
name: "Permohonan Informasi Publik",
path: "/admin/ppid/permohonan-informasi-publik"
},
{
id: "PPID_6",
name: "Permohonan Keberatan Informasi Publik",
path: "/admin/ppid/permohonan-keberatan-informasi-publik"
},
{
id: "PPID_7",
name: "Daftar Informasi Publik",
path: "/admin/ppid/daftar-informasi-publik"
},
{
id: "PPID_8",
name: "Indeks Kepuasan Masyarakat",
path: "/admin/ppid/indeks-kepuasan-masyarakat/grafik-kepuasan-masyarakat"
},
]
},
{
id: "Desa",
name: "Desa",
path: "",
children: [
{
id: "Desa_1",
name: "Profile",
path: "/admin/desa/profile/profile-desa"
},
{
id: "Desa_2",
name: "Potensi",
path: "/admin/desa/potensi/list-potensi"
},
{
id: "Desa_3",
name: "Berita",
path: "/admin/desa/berita/list-berita"
},
{
id: "Desa_4",
name: "Pengumuman",
path: "/admin/desa/pengumuman/list-pengumuman"
},
{
id: "Desa_5",
name: "Gallery",
path: "/admin/desa/gallery/foto"
},
{
id: "Desa_6",
name: "Layanan",
path: "/admin/desa/layanan/pelayanan_surat_keterangan"
},
{
id: "Desa_7",
name: "Penghargaan",
path: "/admin/desa/penghargaan"
}
]
},
{
id: "Kesehatan",
name: "Kesehatan",
path: "",
children: [
{
id: "Kesehatan_1",
name: "Posyandu",
path: "/admin/kesehatan/posyandu"
},
{
id: "Kesehatan_2",
name: "Data Kesehatan Warga",
path: "/admin/kesehatan/data-kesehatan-warga/persentase_data_kelahiran_kematian"
},
{
id: "Kesehatan_3",
name: "Puskesmas",
path: "/admin/kesehatan/puskesmas"
},
{
id: "Kesehatan_4",
name: "Program Kesehatan",
path: "/admin/kesehatan/program-kesehatan"
},
{
id: "Kesehatan_5",
name: "Penanganan Darurat",
path: "/admin/kesehatan/penanganan-darurat"
},
{
id: "Kesehatan_6",
name: "Kontak Darurat",
path: "/admin/kesehatan/kontak-darurat"
},
{
id: "Kesehatan_7",
name: "Info Wabah/Penyakit",
path: "/admin/kesehatan/info-wabah-penyakit"
}
]
},
{
id: "Keamanan",
name: "Keamanan",
path: "",
children: [
{
id: "Keamanan_1",
name: "Keamanan Lingkungan (Pecalang/Patwal)",
path: "/admin/keamanan/keamanan-lingkungan-pecalang-patwal"
},
{
id: "Keamanan_2",
name: "Polsek Terdekat",
path: "/admin/keamanan/polsek-terdekat"
},
{
id: "Keamanan_3",
name: "Kontak Darurat",
path: "/admin/keamanan/kontak-darurat/kontak-darurat-keamanan"
},
{
id: "Keamanan_4",
name: "Pencegahan Kriminalitas",
path: "/admin/keamanan/pencegahan-kriminalitas"
},
{
id: "Keamanan_5",
name: "Laporan Publik",
path: "/admin/keamanan/laporan-publik"
},
{
id: "Keamanan_6",
name: "Tips Keamanan",
path: "/admin/keamanan/tips-keamanan"
}
]
},
{
id: "Ekonomi",
name: "Ekonomi",
path: "",
children: [
{
id: "Ekonomi_1",
name: "Pasar Desa",
path: "/admin/ekonomi/pasar-desa/produk-pasar-desa"
},
{
id: "Ekonomi_2",
name: "Lowongan Kerja Lokal",
path: "/admin/ekonomi/lowongan-kerja-lokal"
},
{
id: "Ekonomi_3",
name: "Struktur Organisasi Dan Sk Pengurus Bumdesa",
path: "/admin/ekonomi/struktur-organisasi-dan-sk-pengurus-bumdesa/pegawai"
},
{
id: "Ekonomi_4",
name: "PADesa (Pendapatan Asli Desa)",
path: "/admin/ekonomi/PADesa-pendapatan-asli-desa/apbdesa"
},
{
id: "Ekonomi_5",
name: "Jumlah Pengangguran",
path: "/admin/ekonomi/jumlah-pengangguran"
},
{
id: "Ekonomi_6",
name: "Jumlah penduduk usia kerja yang menganggur",
path: "/admin/ekonomi/jumlah-penduduk-usia-kerja-yang-menganggur/pengangguran_berdasarkan_usia"
},
{
id: "Ekonomi_7",
name: "Jumlah Penduduk Miskin",
path: "/admin/ekonomi/jumlah-penduduk-miskin"
},
{
id: "Ekonomi_8",
name: "Program Kemiskinan",
path: "/admin/ekonomi/program-kemiskinan"
},
{
id: "Ekonomi_9",
name: "Sektor Unggulan Desa",
path: "/admin/ekonomi/sektor-unggulan-desa"
},
{
id: "Ekonomi_10",
name: "Demografi Pekerjaan",
path: "/admin/ekonomi/demografi-pekerjaan"
}
]
}, { }, {
id: "Inovasi",
name: "Inovasi",
path: "",
children: [
{
id: "Inovasi_1",
name: "Desa Digital/Smart Village",
path: "/admin/inovasi/desa-digital-smart-village"
},
{
id: "Inovasi_2",
name: "Layanan Online Desa",
path: "/admin/inovasi/layanan-online-desa/administrasi-online"
},
{
id: "Inovasi_3",
name: "Program Kreatif Desa",
path: "/admin/inovasi/program-kreatif-desa"
},
{
id: "Inovasi_4",
name: "Kolaborasi Inovasi",
path: "/admin/inovasi/kolaborasi-inovasi/list-kolaborasi-inovasi"
},
{
id: "Inovasi_5",
name: "Info Teknologi Tepat Guna",
path: "/admin/inovasi/info-teknologi-tepat-guna"
},
{
id: "Inovasi_6",
name: "Ajukan Ide Inovatif",
path: "/admin/inovasi/ajukan-ide-inovatif"
}
]
}, {
id: "Lingkungan",
name: "Lingkungan",
path: "",
children: [
{
id: "Lingkungan_1",
name: "Pengelolaan Sampah (Bank Sampah)",
path: "/admin/lingkungan/pengelolaan-sampah-bank-sampah/list-pengelolaan-sampah-bank-sampah"
},
{
id: "Lingkungan_2",
name: "Program Penghijauan",
path: "/admin/lingkungan/program-penghijauan"
},
{
id: "Lingkungan_3",
name: "Data Lingkungan Desa",
path: "/admin/lingkungan/data-lingkungan-desa"
},
{
id: "Lingkungan_4",
name: "Gotong Royong",
path: "/admin/lingkungan/gotong-royong/kegiatan-desa"
},
{
id: "Lingkungan_5",
name: "Edukasi Lingkungan",
path: "/admin/lingkungan/edukasi-lingkungan/tujuan-edukasi-lingkungan"
},
{
id: "Lingkungan_6",
name: "Konservasi Adat Bali",
path: "/admin/lingkungan/konservasi-adat-bali/filosofi-tri-hita-karana"
}
]
},
{
id: "Pendidikan",
name: "Pendidikan",
path: "",
children: [
{
id: "Pendidikan_1",
name: "Info Sekolah",
path: "/admin/pendidikan/info-sekolah/jenjang-pendidikan"
},
{
id: "Pendidikan_2",
name: "Beasiswa Desa",
path: "/admin/pendidikan/beasiswa-desa/beasiswa-pendaftar"
},
{
id: "Pendidikan_3",
name: "Program Pendidikan Anak",
path: "/admin/pendidikan/program-pendidikan-anak/program-unggulan"
},
{
id: "Pendidikan_4",
name: "Bimbingan Belajar Desa",
path: "/admin/pendidikan/bimbingan-belajar-desa/tujuan-program"
},
{
id: "Pendidikan_5",
name: "Pendidikan Non Formal",
path: "/admin/pendidikan/pendidikan-non-formal/tujuan-program"
},
{
id: "Pendidikan_6",
name: "Perpustakaan Digital",
path: "/admin/pendidikan/perpustakaan-digital/data-perpustakaan"
},
{
id: "Pendidikan_7",
name: "Data Pendidikan",
path: "/admin/pendidikan/data-pendidikan"
}
]
},
{
id: "User & Role",
name: "User & Role",
path: "",
children: [
{
id: "User",
name: "User",
path: "/admin/user&role/user"
},
{
id: "Role",
name: "Role",
path: "/admin/user&role/role"
},
{
id: "Menu Access",
name: "Menu Access",
path: "/admin/user&role/menu-access"
}
]
}
]
export const role1 = [
{
id: "Landing Page",
name: "Landing Page",
path: "",
children: [
{
id: "Landing_Page_1",
name: "Profil",
path: "/admin/landing-page/profil/program-inovasi"
},
{
id: "Landing_Page_2",
name: "Desa Anti Korupsi",
path: "/admin/landing-page/desa-anti-korupsi/list-desa-anti-korupsi"
},
{
id: "Landing_Page_3",
name: "Indeks Kepuasan Masyarakat",
path: "/admin/landing-page/indeks-kepuasan-masyarakat/grafik-kepuasan-masyarakat"
},
{
id: "Landing_Page_4",
name: "SDGs",
path: "/admin/landing-page/SDGs"
},
{
id: "Landing_Page_5",
name: "APBDes",
path: "/admin/landing-page/apbdes"
},
{
id: "Landing_Page_6",
name: "Prestasi Desa",
path: "/admin/landing-page/prestasi-desa/list-prestasi-desa"
}
]
},
{
id: "PPID",
name: "PPID",
path: "",
children: [
{
id: "PPID_1",
name: "Profil PPID",
path: "/admin/ppid/profil-ppid"
},
{
id: "PPID_2",
name: "Struktur PPID",
path: "/admin/ppid/struktur-ppid/pegawai"
},
{
id: "PPID_3",
name: "Visi Misi PPID",
path: "/admin/ppid/visi-misi-ppid"
},
{
id: "PPID_4",
name: "Dasar Hukum",
path: "/admin/ppid/dasar-hukum"
},
{
id: "PPID_5",
name: "Permohonan Informasi Publik",
path: "/admin/ppid/permohonan-informasi-publik"
},
{
id: "PPID_6",
name: "Permohonan Keberatan Informasi Publik",
path: "/admin/ppid/permohonan-keberatan-informasi-publik"
},
{
id: "PPID_7",
name: "Daftar Informasi Publik",
path: "/admin/ppid/daftar-informasi-publik"
},
{
id: "PPID_8",
name: "Indeks Kepuasan Masyarakat",
path: "/admin/ppid/indeks-kepuasan-masyarakat/grafik-kepuasan-masyarakat"
},
]
},
{
id: "Desa",
name: "Desa",
path: "",
children: [
{
id: "Desa_1",
name: "Profile",
path: "/admin/desa/profile/profile-desa"
},
{
id: "Desa_2",
name: "Potensi",
path: "/admin/desa/potensi/list-potensi"
},
{
id: "Desa_3",
name: "Berita",
path: "/admin/desa/berita/list-berita"
},
{
id: "Desa_4",
name: "Pengumuman",
path: "/admin/desa/pengumuman/list-pengumuman"
},
{
id: "Desa_5",
name: "Gallery",
path: "/admin/desa/gallery/foto"
},
{
id: "Desa_6",
name: "Layanan",
path: "/admin/desa/layanan/pelayanan_surat_keterangan"
},
{
id: "Desa_7",
name: "Penghargaan",
path: "/admin/desa/penghargaan"
}
]
},
{
id: "Keamanan",
name: "Keamanan",
path: "",
children: [
{
id: "Keamanan_1",
name: "Keamanan Lingkungan (Pecalang/Patwal)",
path: "/admin/keamanan/keamanan-lingkungan-pecalang-patwal"
},
{
id: "Keamanan_2",
name: "Polsek Terdekat",
path: "/admin/keamanan/polsek-terdekat"
},
{
id: "Keamanan_3",
name: "Kontak Darurat",
path: "/admin/keamanan/kontak-darurat/kontak-darurat-keamanan"
},
{
id: "Keamanan_4",
name: "Pencegahan Kriminalitas",
path: "/admin/keamanan/pencegahan-kriminalitas"
},
{
id: "Keamanan_5",
name: "Laporan Publik",
path: "/admin/keamanan/laporan-publik"
},
{
id: "Keamanan_6",
name: "Tips Keamanan",
path: "/admin/keamanan/tips-keamanan"
}
]
},
{
id: "Ekonomi",
name: "Ekonomi",
path: "",
children: [
{
id: "Ekonomi_1",
name: "Pasar Desa",
path: "/admin/ekonomi/pasar-desa/produk-pasar-desa"
},
{
id: "Ekonomi_2",
name: "Lowongan Kerja Lokal",
path: "/admin/ekonomi/lowongan-kerja-lokal"
},
{
id: "Ekonomi_3",
name: "Struktur Organisasi Dan Sk Pengurus Bumdesa",
path: "/admin/ekonomi/struktur-organisasi-dan-sk-pengurus-bumdesa/pegawai"
},
{
id: "Ekonomi_4",
name: "PADesa (Pendapatan Asli Desa)",
path: "/admin/ekonomi/PADesa-pendapatan-asli-desa/apbdesa"
},
{
id: "Ekonomi_5",
name: "Jumlah Pengangguran",
path: "/admin/ekonomi/jumlah-pengangguran"
},
{
id: "Ekonomi_6",
name: "Jumlah penduduk usia kerja yang menganggur",
path: "/admin/ekonomi/jumlah-penduduk-usia-kerja-yang-menganggur/pengangguran_berdasarkan_usia"
},
{
id: "Ekonomi_7",
name: "Jumlah Penduduk Miskin",
path: "/admin/ekonomi/jumlah-penduduk-miskin"
},
{
id: "Ekonomi_8",
name: "Program Kemiskinan",
path: "/admin/ekonomi/program-kemiskinan"
},
{
id: "Ekonomi_9",
name: "Sektor Unggulan Desa",
path: "/admin/ekonomi/sektor-unggulan-desa"
},
{
id: "Ekonomi_10",
name: "Demografi Pekerjaan",
path: "/admin/ekonomi/demografi-pekerjaan"
}
]
},
{
id: "Inovasi",
name: "Inovasi",
path: "",
children: [
{
id: "Inovasi_1",
name: "Desa Digital/Smart Village",
path: "/admin/inovasi/desa-digital-smart-village"
},
{
id: "Inovasi_2",
name: "Layanan Online Desa",
path: "/admin/inovasi/layanan-online-desa/administrasi-online"
},
{
id: "Inovasi_3",
name: "Program Kreatif Desa",
path: "/admin/inovasi/program-kreatif-desa"
},
{
id: "Inovasi_4",
name: "Kolaborasi Inovasi",
path: "/admin/inovasi/kolaborasi-inovasi/list-kolaborasi-inovasi"
},
{
id: "Inovasi_5",
name: "Info Teknologi Tepat Guna",
path: "/admin/inovasi/info-teknologi-tepat-guna"
},
{
id: "Inovasi_6",
name: "Ajukan Ide Inovatif",
path: "/admin/inovasi/ajukan-ide-inovatif"
}
]
},
{
id: "Lingkungan",
name: "Lingkungan",
path: "",
children: [
{
id: "Lingkungan_1",
name: "Pengelolaan Sampah (Bank Sampah)",
path: "/admin/lingkungan/pengelolaan-sampah-bank-sampah/list-pengelolaan-sampah-bank-sampah"
},
{
id: "Lingkungan_2",
name: "Program Penghijauan",
path: "/admin/lingkungan/program-penghijauan"
},
{
id: "Lingkungan_3",
name: "Data Lingkungan Desa",
path: "/admin/lingkungan/data-lingkungan-desa"
},
{
id: "Lingkungan_4",
name: "Gotong Royong",
path: "/admin/lingkungan/gotong-royong/kegiatan-desa"
},
{
id: "Lingkungan_5",
name: "Edukasi Lingkungan",
path: "/admin/lingkungan/edukasi-lingkungan/tujuan-edukasi-lingkungan"
},
{
id: "Lingkungan_6",
name: "Konservasi Adat Bali",
path: "/admin/lingkungan/konservasi-adat-bali/filosofi-tri-hita-karana"
}
]
}
]
export const role2 = [
{
id: "Kesehatan",
name: "Kesehatan",
path: "",
children: [
{
id: "Kesehatan_1",
name: "Posyandu",
path: "/admin/kesehatan/posyandu"
},
{
id: "Kesehatan_2",
name: "Data Kesehatan Warga",
path: "/admin/kesehatan/data-kesehatan-warga/persentase_data_kelahiran_kematian"
},
{
id: "Kesehatan_3",
name: "Puskesmas",
path: "/admin/kesehatan/puskesmas"
},
{
id: "Kesehatan_4",
name: "Program Kesehatan",
path: "/admin/kesehatan/program-kesehatan"
},
{
id: "Kesehatan_5",
name: "Penanganan Darurat",
path: "/admin/kesehatan/penanganan-darurat"
},
{
id: "Kesehatan_6",
name: "Kontak Darurat",
path: "/admin/kesehatan/kontak-darurat"
},
{
id: "Kesehatan_7",
name: "Info Wabah/Penyakit",
path: "/admin/kesehatan/info-wabah-penyakit"
}
]
}
]
export const role3 = [
{
id: "Pendidikan", id: "Pendidikan",
name: "Pendidikan", name: "Pendidikan",
path: "", path: "",
@@ -378,4 +1194,4 @@ export const navBar = [
} }
] ]
} }
] ]

View File

@@ -0,0 +1,9 @@
// src/app/(admin)/_com/navigationByRole.ts
import { navBar, role1, role2, role3 } from './list_PageAdmin';
export const navigationByRole = {
0: navBar, // SUPERADMIN
1: role1, // ADMIN DESA
2: role2, // ADMIN KESEHATAN
3: role3, // ADMIN PENDIDIKAN
} as const;

View File

@@ -1,41 +0,0 @@
export {
apiFetchLogin,
apiFetchRegister
};
const apiFetchLogin = async ({ nomor }: { nomor: string }) => {
const response = await fetch("/api/auth/login", {
method: "POST",
body: JSON.stringify({ nomor: nomor }),
headers: {
"Content-Type": "application/json",
},
});
return await response.json().catch(() => null);
};
const apiFetchRegister = async ({
nomor,
username,
}: {
nomor: string;
username: string;
}) => {
const data = {
username: username,
nomor: nomor,
};
const respone = await fetch("/api/auth/register", {
method: "POST",
body: JSON.stringify({ data }),
headers: {
"Content-Type": "application/json",
},
});
const result = await respone.json();
return result;
// return await respone.json().catch(() => null);
};

View File

@@ -1,6 +1,403 @@
// 'use client'
// import colors from "@/con/colors";
// import { authStore } from "@/store/authStore";
// import {
// ActionIcon,
// AppShell,
// AppShellHeader,
// AppShellMain,
// AppShellNavbar,
// Burger,
// Center,
// Flex,
// Group,
// Image,
// Loader,
// NavLink,
// ScrollArea,
// Text,
// Tooltip,
// rem
// } from "@mantine/core";
// import { useDisclosure } from "@mantine/hooks";
// import {
// IconChevronLeft,
// IconChevronRight,
// IconLogout2
// } from "@tabler/icons-react";
// import _ from "lodash";
// import Link from "next/link";
// import { useRouter, useSelectedLayoutSegments } from "next/navigation";
// import { useEffect, useState } from "react";
// // import { useSnapshot } from "valtio";
// import { getNavbar } from "./(dashboard)/user&role/_com/dynamicNavbar";
// export default function Layout({ children }: { children: React.ReactNode }) {
// const [opened, { toggle }] = useDisclosure();
// const [loading, setLoading] = useState(true);
// const [isLoggingOut, setIsLoggingOut] = useState(false);
// const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
// const router = useRouter();
// const segments = useSelectedLayoutSegments().map((s) => _.lowerCase(s));
// // const { user } = useSnapshot(authStore);
// // console.log("Current user in store:", user);
// // ✅ FIX: Selalu fetch user data setiap kali komponen mount
// useEffect(() => {
// const fetchUser = async () => {
// try {
// const res = await fetch('/api/auth/me');
// const data = await res.json();
// if (data.user) {
// // ✅ Check if user is NOT active → redirect to waiting room
// if (!data.user.isActive) {
// authStore.setUser(null);
// router.replace('/waiting-room');
// return;
// }
// // ✅ Fetch menuIds
// const menuRes = await fetch(`/api/admin/user-menu-access?userId=${data.user.id}`);
// const menuData = await menuRes.json();
// const menuIds = menuData.success && Array.isArray(menuData.menuIds)
// ? [...menuData.menuIds]
// : null;
// // ✅ Set user dengan menuIds yang fresh
// authStore.setUser({
// id: data.user.id,
// name: data.user.name,
// roleId: Number(data.user.roleId),
// menuIds,
// isActive: data.user.isActive
// });
// // ✅ TAMBAHKAN INI: Redirect ke dashboard sesuai roleId
// const currentPath = window.location.pathname;
// const expectedPath = getRedirectPath(Number(data.user.roleId));
// // Jika user di halaman /admin tapi bukan di path yang sesuai roleId
// if (currentPath === '/admin' || !currentPath.startsWith(expectedPath)) {
// router.replace(expectedPath);
// }
// } else {
// authStore.setUser(null);
// router.replace('/login');
// }
// } catch (error) {
// console.error('Gagal memuat data pengguna:', error);
// authStore.setUser(null);
// router.replace('/login');
// } finally {
// setLoading(false);
// }
// };
// fetchUser();
// }, [router]);
// // ✅ Fungsi helper untuk get redirect path
// const getRedirectPath = (roleId: number): string => {
// switch (roleId) {
// case 0: // DEVELOPER
// case 1: // SUPERADMIN
// case 2: // ADMIN_DESA
// return '/admin/landing-page/profil/program-inovasi';
// case 3: // ADMIN_KESEHATAN
// return '/admin/kesehatan/posyandu';
// case 4: // ADMIN_PENDIDIKAN
// return '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
// default:
// return '/admin';
// }
// };
// if (loading) {
// return (
// <AppShell>
// <AppShellMain>
// <Center h="100vh">
// <Loader />
// </Center>
// </AppShellMain>
// </AppShell>
// );
// }
// // ✅ Ambil menu berdasarkan roleId dan menuIds
// const currentNav = authStore.user
// ? getNavbar({ roleId: authStore.user.roleId, menuIds: authStore.user.menuIds })
// : [];
// const handleLogout = async () => {
// try {
// setIsLoggingOut(true);
// // ✅ Panggil API logout untuk clear session di server
// const response = await fetch('/api/auth/logout', { method: 'POST' });
// const result = await response.json();
// if (result.success) {
// // Clear user data dari store
// authStore.setUser(null);
// // Clear localStorage
// localStorage.removeItem('auth_nomor');
// localStorage.removeItem('auth_kodeId');
// // Force reload untuk reset semua state
// window.location.href = '/login';
// } else {
// console.error('Logout failed:', result.message);
// // Tetap redirect meskipun gagal
// authStore.setUser(null);
// window.location.href = '/login';
// }
// } catch (error) {
// console.error('Error during logout:', error);
// // Tetap clear store dan redirect jika error
// authStore.setUser(null);
// window.location.href = '/login';
// } finally {
// setIsLoggingOut(false);
// }
// };
// return (
// <AppShell
// suppressHydrationWarning
// header={{ height: 64 }}
// navbar={{
// width: { base: 260, sm: 280, lg: 300 },
// breakpoint: 'sm',
// collapsed: {
// mobile: !opened,
// desktop: !desktopOpened,
// },
// }}
// padding="md"
// >
// <AppShellHeader
// style={{
// background: "linear-gradient(90deg, #ffffff, #f9fbff)",
// borderBottom: `1px solid ${colors["blue-button"]}20`,
// padding: '0 16px',
// }}
// px={{ base: 'sm', sm: 'md' }}
// py={{ base: 'xs', sm: 'sm' }}
// >
// <Group w="100%" h="100%" justify="space-between" wrap="nowrap">
// <Flex align="center" gap="sm">
// <Image
// src="/assets/images/darmasaba-icon.png"
// alt="Logo Darmasaba"
// w={{ base: 32, sm: 40 }}
// h={{ base: 32, sm: 40 }}
// radius="md"
// loading="lazy"
// style={{
// minWidth: '32px',
// height: 'auto',
// }}
// />
// <Text
// fw={700}
// c={colors["blue-button"]}
// fz={{ base: 'md', sm: 'xl' }}
// >
// Admin Darmasaba
// </Text>
// </Flex>
// <Group gap="xs">
// {!desktopOpened && (
// <Tooltip label="Buka Navigasi" position="bottom" withArrow>
// <ActionIcon
// variant="light"
// radius="xl"
// size="lg"
// onClick={toggleDesktop}
// color={colors["blue-button"]}
// >
// <IconChevronRight />
// </ActionIcon>
// </Tooltip>
// )}
// <Burger
// opened={opened}
// onClick={toggle}
// hiddenFrom="sm"
// size="md"
// color={colors["blue-button"]}
// mr="xs"
// />
// <Tooltip label="Kembali ke Website Desa" position="bottom" withArrow>
// <ActionIcon
// onClick={() => {
// router.push("/darmasaba");
// }}
// color={colors["blue-button"]}
// radius="xl"
// size="lg"
// variant="gradient"
// gradient={{ from: colors["blue-button"], to: "#228be6" }}
// >
// <Image
// src="/assets/images/darmasaba-icon.png"
// alt="Logo Darmasaba"
// w={20}
// h={20}
// radius="md"
// loading="lazy"
// style={{
// minWidth: '20px',
// height: 'auto',
// }}
// />
// </ActionIcon>
// </Tooltip>
// <Tooltip label="Keluar" position="bottom" withArrow>
// <ActionIcon
// onClick={handleLogout}
// color={colors["blue-button"]}
// radius="xl"
// size="lg"
// variant="gradient"
// gradient={{ from: colors["blue-button"], to: "#228be6" }}
// loading={isLoggingOut}
// disabled={isLoggingOut}
// >
// <IconLogout2 size={22} />
// </ActionIcon>
// </Tooltip>
// </Group>
// </Group>
// </AppShellHeader>
// <AppShellNavbar
// component={ScrollArea}
// style={{
// background: "#ffffff",
// borderRight: `1px solid ${colors["blue-button"]}20`,
// }}
// p={{ base: 'xs', sm: 'sm' }}
// >
// <AppShell.Section p="sm">
// {currentNav.map((v, k) => {
// const isParentActive = segments.includes(_.lowerCase(v.name));
// return (
// <NavLink
// key={k}
// defaultOpened={isParentActive}
// c={isParentActive ? colors["blue-button"] : "gray"}
// label={
// <Text fw={isParentActive ? 600 : 400} fz="sm">
// {v.name}
// </Text>
// }
// style={{
// borderRadius: rem(10),
// marginBottom: rem(4),
// transition: "background 150ms ease",
// }}
// styles={{
// root: {
// '&:hover': {
// backgroundColor: 'rgba(25, 113, 194, 0.05)',
// },
// },
// }}
// variant="light"
// active={isParentActive}
// >
// {v.children.map((child, key) => {
// const isChildActive = segments.includes(
// _.lowerCase(child.name)
// );
// return (
// <NavLink
// key={key}
// href={child.path}
// c={isChildActive ? colors["blue-button"] : "gray"}
// label={
// <Text fw={isChildActive ? 600 : 400} fz="sm">
// {child.name}
// </Text>
// }
// styles={{
// root: {
// borderRadius: rem(8),
// marginBottom: rem(2),
// transition: 'background 150ms ease',
// padding: '6px 12px',
// '&:hover': {
// backgroundColor: isChildActive ? 'rgba(25, 113, 194, 0.15)' : 'rgba(25, 113, 194, 0.05)',
// },
// ...(isChildActive && {
// backgroundColor: 'rgba(25, 113, 194, 0.1)',
// }),
// },
// }}
// active={isChildActive}
// component={Link}
// />
// );
// })}
// </NavLink>
// );
// })}
// </AppShell.Section>
// <AppShell.Section py="md">
// <Group justify="end" pr="sm">
// <Tooltip
// label={desktopOpened ? "Tutup Navigasi" : "Buka Navigasi"}
// position="top"
// withArrow
// >
// <ActionIcon
// variant="light"
// radius="xl"
// size="lg"
// onClick={toggleDesktop}
// color={colors["blue-button"]}
// >
// <IconChevronLeft />
// </ActionIcon>
// </Tooltip>
// </Group>
// </AppShell.Section>
// </AppShellNavbar>
// <AppShellMain
// style={{
// background: "linear-gradient(180deg, #fdfdfd, #f6f9fc)",
// minHeight: "100vh",
// }}
// >
// {children}
// </AppShellMain>
// </AppShell>
// );
// }
// app/admin/layout.tsx
'use client' 'use client'
import colors from "@/con/colors"; import colors from "@/con/colors";
import { authStore } from "@/store/authStore";
import { import {
ActionIcon, ActionIcon,
AppShell, AppShell,
@@ -8,9 +405,11 @@ import {
AppShellMain, AppShellMain,
AppShellNavbar, AppShellNavbar,
Burger, Burger,
Center,
Flex, Flex,
Group, Group,
Image, Image,
Loader,
NavLink, NavLink,
ScrollArea, ScrollArea,
Text, Text,
@@ -26,13 +425,139 @@ import {
import _ from "lodash"; import _ from "lodash";
import Link from "next/link"; import Link from "next/link";
import { useRouter, useSelectedLayoutSegments } from "next/navigation"; import { useRouter, useSelectedLayoutSegments } from "next/navigation";
import { navBar } from "./_com/list_PageAdmin"; import { useEffect, useState } from "react";
import { getNavbar } from "./(dashboard)/user&role/_com/dynamicNavbar";
export default function Layout({ children }: { children: React.ReactNode }) { export default function Layout({ children }: { children: React.ReactNode }) {
const [opened, { toggle }] = useDisclosure(); const [opened, { toggle }] = useDisclosure();
const [loading, setLoading] = useState(true);
const [isLoggingOut, setIsLoggingOut] = useState(false);
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true); const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
const router = useRouter(); const router = useRouter();
const segments = useSelectedLayoutSegments().map((s) => _.lowerCase(s)); const segments = useSelectedLayoutSegments().map((s) => _.lowerCase(s));
useEffect(() => {
const fetchUser = async () => {
try {
const res = await fetch('/api/auth/me', {
credentials: 'include' // ✅ ADD credentials
});
const data = await res.json();
if (data.user) {
// ✅ Check if user is NOT active → redirect to waiting room
if (!data.user.isActive) {
authStore.setUser(null);
router.replace('/waiting-room');
return;
}
// ✅ Fetch menuIds
const menuRes = await fetch(`/api/admin/user-menu-access?userId=${data.user.id}`, {
credentials: 'include' // ✅ ADD credentials
});
const menuData = await menuRes.json();
const menuIds = menuData.success && Array.isArray(menuData.menuIds)
? [...menuData.menuIds]
: null;
// ✅ Set user dengan menuIds yang fresh
authStore.setUser({
id: data.user.id,
name: data.user.name,
roleId: Number(data.user.roleId),
menuIds,
isActive: data.user.isActive
});
// ✅ IMPROVED: Redirect ONLY if di root /admin
const currentPath = window.location.pathname;
if (currentPath === '/admin') {
const expectedPath = getRedirectPath(Number(data.user.roleId));
console.log('🔄 Redirecting from /admin to:', expectedPath);
router.replace(expectedPath);
}
// ✅ Jangan redirect jika user sudah di path yang valid
} else {
authStore.setUser(null);
router.replace('/login');
}
} catch (error) {
console.error('Gagal memuat data pengguna:', error);
authStore.setUser(null);
router.replace('/login');
} finally {
setLoading(false);
}
};
fetchUser();
}, [router]); // ✅ Only depend on router
const getRedirectPath = (roleId: number): string => {
switch (roleId) {
case 0: // DEVELOPER
case 1: // SUPERADMIN
case 2: // ADMIN_DESA
return '/admin/landing-page/profil/program-inovasi';
case 3: // ADMIN_KESEHATAN
return '/admin/kesehatan/posyandu';
case 4: // ADMIN_PENDIDIKAN
return '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
default:
return '/admin';
}
};
if (loading) {
return (
<AppShell>
<AppShellMain>
<Center h="100vh">
<Loader />
</Center>
</AppShellMain>
</AppShell>
);
}
const currentNav = authStore.user
? getNavbar({ roleId: authStore.user.roleId, menuIds: authStore.user.menuIds })
: [];
const handleLogout = async () => {
try {
setIsLoggingOut(true);
const response = await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include' // ✅ ADD credentials
});
const result = await response.json();
if (result.success) {
authStore.setUser(null);
localStorage.removeItem('auth_nomor');
localStorage.removeItem('auth_kodeId');
localStorage.removeItem('auth_username');
window.location.href = '/login';
} else {
console.error('Logout failed:', result.message);
authStore.setUser(null);
window.location.href = '/login';
}
} catch (error) {
console.error('Error during logout:', error);
authStore.setUser(null);
window.location.href = '/login';
} finally {
setIsLoggingOut(false);
}
};
return ( return (
<AppShell <AppShell
@@ -48,6 +573,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
}} }}
padding="md" padding="md"
> >
{/* ... rest of your JSX (Header, Navbar, Main) sama seperti sebelumnya ... */}
<AppShellHeader <AppShellHeader
style={{ style={{
background: "linear-gradient(90deg, #ffffff, #f9fbff)", background: "linear-gradient(90deg, #ffffff, #f9fbff)",
@@ -66,16 +592,9 @@ export default function Layout({ children }: { children: React.ReactNode }) {
h={{ base: 32, sm: 40 }} h={{ base: 32, sm: 40 }}
radius="md" radius="md"
loading="lazy" loading="lazy"
style={{ style={{ minWidth: '32px', height: 'auto' }}
minWidth: '32px',
height: 'auto',
}}
/> />
<Text <Text fw={700} c={colors["blue-button"]} fz={{ base: 'md', sm: 'xl' }}>
fw={700}
c={colors["blue-button"]}
fz={{ base: 'md', sm: 'xl' }}
>
Admin Darmasaba Admin Darmasaba
</Text> </Text>
</Flex> </Flex>
@@ -83,63 +602,22 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<Group gap="xs"> <Group gap="xs">
{!desktopOpened && ( {!desktopOpened && (
<Tooltip label="Buka Navigasi" position="bottom" withArrow> <Tooltip label="Buka Navigasi" position="bottom" withArrow>
<ActionIcon <ActionIcon variant="light" radius="xl" size="lg" onClick={toggleDesktop} color={colors["blue-button"]}>
variant="light"
radius="xl"
size="lg"
onClick={toggleDesktop}
color={colors["blue-button"]}
>
<IconChevronRight /> <IconChevronRight />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
)} )}
<Burger <Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="md" color={colors["blue-button"]} mr="xs" />
opened={opened}
onClick={toggle}
hiddenFrom="sm"
size="md"
color={colors["blue-button"]}
mr="xs"
/>
<Tooltip label="Kembali ke Website Desa" position="bottom" withArrow> <Tooltip label="Kembali ke Website Desa" position="bottom" withArrow>
<ActionIcon <ActionIcon onClick={() => router.push("/darmasaba")} color={colors["blue-button"]} radius="xl" size="lg" variant="gradient" gradient={{ from: colors["blue-button"], to: "#228be6" }}>
onClick={() => { <Image src="/assets/images/darmasaba-icon.png" alt="Logo Darmasaba" w={20} h={20} radius="md" loading="lazy" style={{ minWidth: '20px', height: 'auto' }} />
router.push("/darmasaba");
}}
color={colors["blue-button"]}
radius="xl"
size="lg"
variant="gradient"
gradient={{ from: colors["blue-button"], to: "#228be6" }}
>
<Image
src="/assets/images/darmasaba-icon.png"
alt="Logo Darmasaba"
w={20}
h={20}
radius="md"
loading="lazy"
style={{
minWidth: '20px',
height: 'auto',
}}
/>
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label="Keluar" position="bottom" withArrow> <Tooltip label="Keluar" position="bottom" withArrow>
<ActionIcon <ActionIcon onClick={handleLogout} color={colors["blue-button"]} radius="xl" size="lg" variant="gradient" gradient={{ from: colors["blue-button"], to: "#228be6" }} loading={isLoggingOut} disabled={isLoggingOut}>
onClick={() => {
router.push("/darmasaba");
}}
color={colors["blue-button"]}
radius="xl"
size="lg"
variant="gradient"
gradient={{ from: colors["blue-button"], to: "#228be6" }}
>
<IconLogout2 size={22} /> <IconLogout2 size={22} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
@@ -147,75 +625,17 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</Group> </Group>
</AppShellHeader> </AppShellHeader>
<AppShellNavbar <AppShellNavbar component={ScrollArea} style={{ background: "#ffffff", borderRight: `1px solid ${colors["blue-button"]}20` }} p={{ base: 'xs', sm: 'sm' }}>
component={ScrollArea} {/* ... Navbar content sama seperti sebelumnya ... */}
style={{
background: "#ffffff",
borderRight: `1px solid ${colors["blue-button"]}20`,
}}
p={{ base: 'xs', sm: 'sm' }}
>
<AppShell.Section p="sm"> <AppShell.Section p="sm">
{navBar.map((v, k) => { {currentNav.map((v, k) => {
const isParentActive = segments.includes(_.lowerCase(v.name)); const isParentActive = segments.includes(_.lowerCase(v.name));
return ( return (
<NavLink <NavLink key={k} defaultOpened={isParentActive} c={isParentActive ? colors["blue-button"] : "gray"} label={<Text fw={isParentActive ? 600 : 400} fz="sm">{v.name}</Text>} style={{ borderRadius: rem(10), marginBottom: rem(4), transition: "background 150ms ease" }} styles={{ root: { '&:hover': { backgroundColor: 'rgba(25, 113, 194, 0.05)' } } }} variant="light" active={isParentActive}>
key={k}
defaultOpened={isParentActive}
c={isParentActive ? colors["blue-button"] : "gray"}
label={
<Text fw={isParentActive ? 600 : 400} fz="sm">
{v.name}
</Text>
}
style={{
borderRadius: rem(10),
marginBottom: rem(4),
transition: "background 150ms ease",
}}
styles={{
root: {
'&:hover': {
backgroundColor: 'rgba(25, 113, 194, 0.05)',
},
},
}}
variant="light"
active={isParentActive}
>
{v.children.map((child, key) => { {v.children.map((child, key) => {
const isChildActive = segments.includes( const isChildActive = segments.includes(_.lowerCase(child.name));
_.lowerCase(child.name)
);
return ( return (
<NavLink <NavLink key={key} href={child.path} c={isChildActive ? colors["blue-button"] : "gray"} label={<Text fw={isChildActive ? 600 : 400} fz="sm">{child.name}</Text>} styles={{ root: { borderRadius: rem(8), marginBottom: rem(2), transition: 'background 150ms ease', padding: '6px 12px', '&:hover': { backgroundColor: isChildActive ? 'rgba(25, 113, 194, 0.15)' : 'rgba(25, 113, 194, 0.05)' }, ...(isChildActive && { backgroundColor: 'rgba(25, 113, 194, 0.1)' }) } }} active={isChildActive} component={Link} />
key={key}
href={child.path}
c={isChildActive ? colors["blue-button"] : "gray"}
label={
<Text fw={isChildActive ? 600 : 400} fz="sm">
{child.name}
</Text>
}
styles={{
root: {
borderRadius: rem(8),
marginBottom: rem(2),
transition: 'background 150ms ease',
padding: '6px 12px',
'&:hover': {
backgroundColor: isChildActive ? 'rgba(25, 113, 194, 0.15)' : 'rgba(25, 113, 194, 0.05)',
},
...(isChildActive && {
backgroundColor: 'rgba(25, 113, 194, 0.1)',
}),
},
}}
active={isChildActive}
component={Link}
/>
); );
})} })}
</NavLink> </NavLink>
@@ -225,18 +645,8 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<AppShell.Section py="md"> <AppShell.Section py="md">
<Group justify="end" pr="sm"> <Group justify="end" pr="sm">
<Tooltip <Tooltip label={desktopOpened ? "Tutup Navigasi" : "Buka Navigasi"} position="top" withArrow>
label={desktopOpened ? "Tutup Navigasi" : "Buka Navigasi"} <ActionIcon variant="light" radius="xl" size="lg" onClick={toggleDesktop} color={colors["blue-button"]}>
position="top"
withArrow
>
<ActionIcon
variant="light"
radius="xl"
size="lg"
onClick={toggleDesktop}
color={colors["blue-button"]}
>
<IconChevronLeft /> <IconChevronLeft />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
@@ -244,14 +654,9 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</AppShell.Section> </AppShell.Section>
</AppShellNavbar> </AppShellNavbar>
<AppShellMain <AppShellMain style={{ background: "linear-gradient(180deg, #fdfdfd, #f6f9fc)", minHeight: "100vh" }}>
style={{
background: "linear-gradient(180deg, #fdfdfd, #f6f9fc)",
minHeight: "100vh",
}}
>
{children} {children}
</AppShellMain> </AppShellMain>
</AppShell> </AppShell>
); );
} }

View File

@@ -1,5 +1,4 @@
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { randomOTP } from "../_lib/randomOTP"; import { randomOTP } from "../_lib/randomOTP";
@@ -12,52 +11,70 @@ export async function POST(req: Request) {
} }
try { try {
const codeOtp = randomOTP();
const body = await req.json(); const body = await req.json();
const { nomor } = body; const { nomor } = body;
const res = await fetch(
`https://wa.wibudev.com/code?nom=${nomor}&text=Website Desa Darmasaba - Kode ini bersifat RAHASIA dan JANGAN DI BAGIKAN KEPADA SIAPAPUN, termasuk anggota ataupun Admin lainnya.
\n
>> Kode OTP anda: ${codeOtp}.
`
);
const sendWa = await res.json(); if (!nomor || typeof nomor !== "string") {
if (sendWa.status !== "success")
return NextResponse.json( return NextResponse.json(
{ success: false, message: "Nomor Whatsapp Tidak Aktif" }, { success: false, message: "Nomor tidak valid" },
{ status: 400 } { status: 400 }
); );
}
const createOtpId = await prisma.kodeOtp.create({ // Cek apakah user sudah terdaftar
data: { const existingUser = await prisma.user.findUnique({
nomor: nomor, where: { nomor },
otp: codeOtp, select: { id: true }, // cukup cek ada/tidak
},
}); });
if (!createOtpId) const isRegistered = !!existingUser;
// Generate OTP
const codeOtp = randomOTP(); // Pastikan ini menghasilkan number (sesuai tipe di KodeOtp.otp: Int)
// Kirim OTP via WA
const waRes = await fetch(
`https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=Website Desa Darmasaba - Kode ini bersifat RAHASIA dan JANGAN DI BAGIKAN KEPADA SIAPAPUN, termasuk anggota ataupun Admin lainnya.%0A%0A>> Kode OTP anda: ${codeOtp}.`
);
const sendWa = await waRes.json();
if (sendWa.status !== "success") {
return NextResponse.json( return NextResponse.json(
{ success: false, message: "Gagal mengirim kode OTP" }, { success: false, message: "Nomor WhatsApp tidak aktif" },
{ status: 400 } { status: 400 }
); );
}
// Simpan OTP ke database
const otpRecord = await prisma.kodeOtp.create({
data: {
nomor: nomor,
otp: codeOtp, // Pastikan tipe ini number (Int di Prisma = number di TS)
},
});
return NextResponse.json( return NextResponse.json(
{ {
success: true, success: true,
message: "Kode verifikasi terkirim", message: "Kode verifikasi terkirim",
kodeId: createOtpId.id, kodeId: otpRecord.id,
isRegistered, // 🔑 Ini kunci untuk frontend tahu harus ke register atau verifikasi
}, },
{ status: 200 } { status: 200 }
); );
} catch (error) { } catch (error) {
console.log("Error Login", error); console.error("Error Login:", error);
return NextResponse.json( return NextResponse.json(
{ success: false, message: "Terjadi masalah saat login" , reason: error as Error }, {
success: false,
message: "Terjadi masalah saat login",
// Hindari mengirim error mentah ke client di production
// reason: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined,
},
{ status: 500 } { status: 500 }
); );
} finally { } finally {
await prisma.$disconnect(); await prisma.$disconnect();
} }
} }

View File

@@ -0,0 +1,30 @@
import prisma from "@/lib/prisma";
import { NextRequest } from "next/server";
// Jika pakai custom session (bukan next-auth), ganti dengan logic session-mu
export async function GET(req: NextRequest) {
// 🔸 GANTI DENGAN LOGIC SESSION-MU
// Contoh: jika kamu simpan user.id di cookie atau JWT
const userId = req.cookies.get("hipmi_user_id")?.value; // sesuaikan
if (!userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
username: true,
nomor: true,
isActive: true,
role: { select: { name: true } },
},
});
if (!user) {
return Response.json({ error: "User not found" }, { status: 404 });
}
return Response.json({ user });
}

View File

@@ -0,0 +1,104 @@
// import prisma from "@/lib/prisma";
// import { NextResponse } from "next/server";
// export async function POST(req: Request) {
// if (req.method !== "POST") {
// return NextResponse.json(
// { success: false, message: "Method Not Allowed" },
// { status: 405 }
// );
// }
// try {
// const { username, nomor, otp, kodeId } = await req.json();
// // Validasi input
// if (!username || !nomor || !otp || !kodeId) {
// return NextResponse.json(
// { success: false, message: "Data tidak lengkap" },
// { status: 400 }
// );
// }
// // 1. Verifikasi OTP
// const otpRecord = await prisma.kodeOtp.findUnique({
// where: { id: kodeId },
// });
// if (!otpRecord) {
// return NextResponse.json(
// { success: false, message: "Kode verifikasi tidak valid" },
// { status: 400 }
// );
// }
// if (!otpRecord.isActive) {
// return NextResponse.json(
// { success: false, message: "Kode verifikasi sudah digunakan atau kadaluarsa" },
// { status: 400 }
// );
// }
// if (otpRecord.otp !== otp) {
// return NextResponse.json(
// { success: false, message: "Kode OTP salah" },
// { status: 400 }
// );
// }
// if (otpRecord.nomor !== nomor) {
// return NextResponse.json(
// { success: false, message: "Nomor tidak sesuai dengan kode verifikasi" },
// { status: 400 }
// );
// }
// // 3. Cek apakah nomor sudah terdaftar
// const existingUserByNomor = await prisma.user.findUnique({
// where: { nomor },
// });
// if (existingUserByNomor) {
// return NextResponse.json(
// { success: false, message: "Nomor sudah terdaftar" },
// { status: 409 }
// );
// }
// // 4. Buat user
// const newUser = await prisma.user.create({
// data: {
// username,
// nomor,
// // roleId default "1" (sesuai model)
// },
// });
// // 5. Nonaktifkan OTP agar tidak bisa dipakai lagi
// await prisma.kodeOtp.update({
// where: { id: kodeId },
// data: { isActive: false },
// });
// return NextResponse.json(
// {
// success: true,
// message: "Registrasi berhasil",
// userId: newUser.id,
// },
// { status: 201 }
// );
// } catch (error) {
// console.error("Error registrasi:", error);
// return NextResponse.json(
// {
// success: false,
// message: "Terjadi kesalahan saat registrasi",
// // reason: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined,
// },
// { status: 500 }
// );
// } finally {
// await prisma.$disconnect();
// }
// }

View File

@@ -6,33 +6,24 @@ import path from "path";
const beritaDelete = async (context: Context) => { const beritaDelete = async (context: Context) => {
const id = context.params?.id as string; const id = context.params?.id as string;
if (!id) { if (!id) return { status: 400, body: "ID tidak diberikan" };
return {
status: 400,
body: "ID tidak diberikan",
};
}
const berita = await prisma.berita.findUnique({ const berita = await prisma.berita.findUnique({
where: { id }, where: { id },
include: { include: { image: true, kategoriBerita: true },
image: true,
kategoriBerita: true, // pastikan relasi image sudah ada di prisma schema
},
}); });
if (!berita) { if (!berita) return { status: 404, body: "Berita tidak ditemukan" };
return {
status: 404,
body: "Berita tidak ditemukan",
};
}
// Hapus file gambar dari filesystem jika ada // 1. HAPUS BERITA DULU
await prisma.berita.delete({ where: { id } });
// 2. BARU HAPUS FILE
if (berita.image) { if (berita.image) {
try { try {
const filePath = path.join(berita.image.path, berita.image.name); const filePath = path.join(berita.image.path, berita.image.name);
await fs.unlink(filePath); await fs.unlink(filePath);
await prisma.fileStorage.delete({ await prisma.fileStorage.delete({
where: { id: berita.image.id }, where: { id: berita.image.id },
}); });
@@ -41,15 +32,11 @@ const beritaDelete = async (context: Context) => {
} }
} }
// Hapus berita dari DB
await prisma.berita.delete({
where: { id },
});
return { return {
success: true, success: true,
message: "Berita dan file terkait berhasil dihapus", message: "Berita dan file terkait berhasil dihapus",
}; };
}; };
export default beritaDelete; export default beritaDelete;

View File

@@ -1,18 +1,16 @@
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { Context } from "elysia"; import { Context } from "elysia";
type FasilitasKesehatanInput = {
name: string;
informasiUmum: { fasilitas: string; alamat: string; jamOperasional: string };
layananUnggulan: { content: string };
dokterdanTenagaMedis: { name: string; specialist: string; jadwal: string };
fasilitasPendukung: { content: string };
prosedurPendaftaran: { content: string };
tarifDanLayanan: { layanan: string; tarif: string };
};
const fasilitasKesehatanCreate = async (context: Context) => { const fasilitasKesehatanCreate = async (context: Context) => {
const body = await context.body as FasilitasKesehatanInput; const body = (await context.body) as {
name: string;
informasiUmum: { fasilitas: string; alamat: string; jamOperasional: string };
layananUnggulan: { content: string };
dokterdanTenagaMedis: string[]; // ← ARRAY OF ID
fasilitasPendukung: { content: string };
prosedurPendaftaran: { content: string };
tarifDanLayanan: string[]; // ← ARRAY OF ID
};
const { const {
name, name,
@@ -24,25 +22,30 @@ const fasilitasKesehatanCreate = async (context: Context) => {
tarifDanLayanan, tarifDanLayanan,
} = body; } = body;
// Buat masing-masing relasi terlebih dahulu // CREATE SINGLE DATA
const [createdInformasiUmum, createdLayananUnggulan, createdDokter, createdPendukung, createdProsedur, createdTarif] = await Promise.all([ const [createdInformasi, createdUnggulan, createdPendukung, createdProsedur] =
prisma.informasiUmum.create({ data: informasiUmum }), await Promise.all([
prisma.layananUnggulan.create({ data: layananUnggulan }), prisma.informasiUmum.create({ data: informasiUmum }),
prisma.dokterdanTenagaMedis.create({ data: dokterdanTenagaMedis }), prisma.layananUnggulan.create({ data: layananUnggulan }),
prisma.fasilitasPendukung.create({ data: fasilitasPendukung }), prisma.fasilitasPendukung.create({ data: fasilitasPendukung }),
prisma.prosedurPendaftaran.create({ data: prosedurPendaftaran }), prisma.prosedurPendaftaran.create({ data: prosedurPendaftaran }),
prisma.tarifDanLayanan.create({ data: tarifDanLayanan }), ]);
]);
// ✅ CUKUP CONNECT KE ID YANG SUDAH ADA
const fasilitas = await prisma.fasilitasKesehatan.create({ const fasilitas = await prisma.fasilitasKesehatan.create({
data: { data: {
name, name,
informasiUmumId: createdInformasiUmum.id, informasiUmumId: createdInformasi.id,
layananUnggulanId: createdLayananUnggulan.id, layananUnggulanId: createdUnggulan.id,
dokterdanTenagaMedisId: createdDokter.id,
fasilitasPendukungId: createdPendukung.id, fasilitasPendukungId: createdPendukung.id,
prosedurPendaftaranId: createdProsedur.id, prosedurPendaftaranId: createdProsedur.id,
tarifDanLayananId: createdTarif.id,
dokterdantenagamedis: {
connect: dokterdanTenagaMedis.map(id => ({ id })), // ← langsung dari input
},
tarifdanlayanan: {
connect: tarifDanLayanan.map(id => ({ id })), // ← langsung dari input
},
}, },
include: { include: {
informasiumum: true, informasiumum: true,

View File

@@ -2,42 +2,14 @@ import prisma from "@/lib/prisma";
import { Context } from "elysia"; import { Context } from "elysia";
const fasilitasKesehatanDelete = async (context: Context) => { const fasilitasKesehatanDelete = async (context: Context) => {
const id = context.params?.id as string; const id = context.params?.id as string;
if (!id) { const data = await prisma.fasilitasKesehatan.findUnique({ where: { id } });
return { if (!data) return { status: 404, message: "Data tidak ditemukan" };
status: 400,
message: "ID tidak ditemukan",
}
}
const fasilitasKesehatan = await prisma.fasilitasKesehatan.findUnique({ await prisma.fasilitasKesehatan.delete({ where: { id } });
where: { id },
include: {
informasiumum: true,
layananunggulan: true,
dokterdantenagamedis: true,
fasilitaspendukung: true,
prosedurpendaftaran: true,
tarifdanlayanan: true,
}
})
if (!fasilitasKesehatan) { return { success: true, message: "Berhasil dihapus" };
return { };
status: 404,
message: "Fasilitas kesehatan tidak ditemukan",
}
}
await prisma.fasilitasKesehatan.delete({ export default fasilitasKesehatanDelete;
where: { id },
})
return {
status: 200,
success: true,
message: "Fasilitas kesehatan berhasil dihapus",
}
}
export default fasilitasKesehatanDelete

View File

@@ -5,6 +5,11 @@ type FormCreate = {
name: string; name: string;
specialist: string; specialist: string;
jadwal: string; jadwal: string;
jadwalLibur: string;
jamBukaOperasional: string;
jamTutupOperasional: string;
jamBukaLibur: string;
jamTutupLibur: string;
}; };
export default async function dokterTenagaMedisCreate(context: Context) { export default async function dokterTenagaMedisCreate(context: Context) {
@@ -15,11 +20,21 @@ export default async function dokterTenagaMedisCreate(context: Context) {
name: body.name, name: body.name,
specialist: body.specialist, specialist: body.specialist,
jadwal: body.jadwal, jadwal: body.jadwal,
jadwalLibur: body.jadwalLibur,
jamBukaOperasional: body.jamBukaOperasional,
jamTutupOperasional: body.jamTutupOperasional,
jamBukaLibur: body.jamBukaLibur,
jamTutupLibur: body.jamTutupLibur,
}, },
select: { select: {
name: true, name: true,
specialist: true, specialist: true,
jadwal: true, jadwal: true,
jadwalLibur: true,
jamBukaOperasional: true,
jamTutupOperasional: true,
jamBukaLibur: true,
jamTutupLibur: true,
} }
}); });

View File

@@ -19,6 +19,7 @@ async function dokterTenagaMedisFindMany(context: Context) {
{ name: { contains: search, mode: 'insensitive' } }, { name: { contains: search, mode: 'insensitive' } },
{ specialist: { contains: search, mode: 'insensitive' } }, { specialist: { contains: search, mode: 'insensitive' } },
{ jadwal: { contains: search, mode: 'insensitive' } }, { jadwal: { contains: search, mode: 'insensitive' } },
{ jadwalLibur: { contains: search, mode: 'insensitive' } },
]; ];
} }

View File

@@ -19,6 +19,11 @@ const DokterTenagaMedis = new Elysia({
name: t.String(), name: t.String(),
specialist: t.String(), specialist: t.String(),
jadwal: t.String(), jadwal: t.String(),
jadwalLibur: t.String(),
jamBukaOperasional: t.String(),
jamTutupOperasional: t.String(),
jamBukaLibur: t.String(),
jamTutupLibur: t.String(),
}), }),
}) })
.put("/:id", dokterTenagaMedisUpdate, { .put("/:id", dokterTenagaMedisUpdate, {
@@ -26,6 +31,11 @@ const DokterTenagaMedis = new Elysia({
name: t.String(), name: t.String(),
specialist: t.String(), specialist: t.String(),
jadwal: t.String(), jadwal: t.String(),
jadwalLibur: t.String(),
jamBukaOperasional: t.String(),
jamTutupOperasional: t.String(),
jamBukaLibur: t.String(),
jamTutupLibur: t.String(),
}), }),
}) })
.delete("/del/:id", dokterTenagaMedisDelete) .delete("/del/:id", dokterTenagaMedisDelete)

View File

@@ -5,6 +5,11 @@ type FormUpdate = {
name: string; name: string;
specialist: string; specialist: string;
jadwal: string; jadwal: string;
jadwalLibur: string;
jamBukaOperasional: string;
jamTutupOperasional: string;
jamBukaLibur: string;
jamTutupLibur: string;
} }
export default async function dokterTenagaMedisUpdate(context: Context) { export default async function dokterTenagaMedisUpdate(context: Context) {
@@ -18,6 +23,12 @@ export default async function dokterTenagaMedisUpdate(context: Context) {
name: body.name, name: body.name,
specialist: body.specialist, specialist: body.specialist,
jadwal: body.jadwal, jadwal: body.jadwal,
jadwalLibur: body.jadwalLibur,
jamBukaOperasional: body.jamBukaOperasional,
jamTutupOperasional: body.jamTutupOperasional,
jamBukaLibur: body.jamBukaLibur,
jamTutupLibur: body.jamTutupLibur,
}, },
}); });
return { return {

View File

@@ -1,6 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import fasilitasKesehatanCreate from "./create"; import fasilitasKesehatanCreate from "./create";
import findManyFasilitasKesehatan from "./findMany"; import fasilitasKesehatanFindMany from "./findMany";
import findUniqueFasilitasKesehatan from "./findUnique"; import findUniqueFasilitasKesehatan from "./findUnique";
import fasilitasKesehatanUpdate from "./updt"; import fasilitasKesehatanUpdate from "./updt";
import fasilitasKesehatanDelete from "./del"; import fasilitasKesehatanDelete from "./del";
@@ -9,42 +9,61 @@ const FasilitasKesehatan = new Elysia({
prefix: "fasilitas-kesehatan", prefix: "fasilitas-kesehatan",
tags: ["Kesehatan/Fasilitas Kesehatan"], tags: ["Kesehatan/Fasilitas Kesehatan"],
}) })
// ==========================
// CREATE
// ==========================
.post("/create", fasilitasKesehatanCreate, { .post("/create", fasilitasKesehatanCreate, {
body: t.Object({ body: t.Object({
name: t.String(), name: t.String(),
informasiUmum: t.Object({ informasiUmum: t.Object({
fasilitas: t.String(), fasilitas: t.String(),
alamat: t.String(), alamat: t.String(),
jamOperasional: t.String(), jamOperasional: t.String(),
}), }),
layananUnggulan: t.Object({ layananUnggulan: t.Object({
content: t.String(), content: t.String(),
}), }),
dokterdanTenagaMedis: t.Object({
name: t.String(), dokterdanTenagaMedis: t.Array(t.String()), // FIX karena create pakai array of string
specialist: t.String(),
jadwal: t.String(),
}),
fasilitasPendukung: t.Object({ fasilitasPendukung: t.Object({
content: t.String(), content: t.String(),
}), }),
prosedurPendaftaran: t.Object({ prosedurPendaftaran: t.Object({
content: t.String(), content: t.String(),
}), }),
tarifDanLayanan: t.Object({
layanan: t.String(), tarifDanLayanan: t.Array(t.String()), // FIX karena create pakai array of string
tarif: t.String(),
}),
}), }),
}) })
.get("/find-many", findManyFasilitasKesehatan)
// ==========================
// FIND MANY
// ==========================
.get("/find-many", fasilitasKesehatanFindMany)
// ==========================
// DELETE
// ==========================
.delete("/del/:id", fasilitasKesehatanDelete) .delete("/del/:id", fasilitasKesehatanDelete)
// ==========================
// FIND UNIQUE
// ==========================
.get("/:id", async (context) => { .get("/:id", async (context) => {
const response = await findUniqueFasilitasKesehatan( const response = await findUniqueFasilitasKesehatan(
new Request(context.request) new Request(context.request)
); );
return response; return response;
}) })
// ==========================
// UPDATE
// ==========================
.put( .put(
"/:id", "/:id",
async (context) => { async (context) => {
@@ -54,29 +73,30 @@ const FasilitasKesehatan = new Elysia({
{ {
body: t.Object({ body: t.Object({
name: t.String(), name: t.String(),
informasiUmum: t.Object({ informasiUmum: t.Object({
fasilitas: t.String(), fasilitas: t.String(),
alamat: t.String(), alamat: t.String(),
jamOperasional: t.String(), jamOperasional: t.String(),
}), }),
layananUnggulan: t.Object({ layananUnggulan: t.Object({
content: t.String(), content: t.String(),
}), }),
dokterdanTenagaMedis: t.Object({
name: t.String(), // FIX → harus array of string (ID dokter)
specialist: t.String(), dokterdanTenagaMedis: t.Array(t.String()),
jadwal: t.String(),
}),
fasilitasPendukung: t.Object({ fasilitasPendukung: t.Object({
content: t.String(), content: t.String(),
}), }),
prosedurPendaftaran: t.Object({ prosedurPendaftaran: t.Object({
content: t.String(), content: t.String(),
}), }),
tarifDanLayanan: t.Object({
layanan: t.String(), // FIX → harus array of string (ID tarif)
tarif: t.String(), tarifDanLayanan: t.Array(t.String()),
}),
}), }),
} }
); );

View File

@@ -0,0 +1,28 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
type FormCreate = {
layanan: string;
tarif: string;
};
export default async function tarifLayananCreate(context: Context) {
const body = context.body as FormCreate;
const created = await prisma.tarifDanLayanan.create({
data: {
layanan: body.layanan,
tarif: body.tarif,
},
select: {
layanan: true,
tarif: true,
}
});
return {
success: true,
message: "Sukses menambahkan dokter tenaga medis",
data: created,
};
}

View File

@@ -0,0 +1,37 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function tarifLayananDelete(context: Context) {
const id = context.params?.id;
if (!id) {
return {
success: false,
message: "ID tidak ditemukan",
};
}
const existing = await prisma.tarifDanLayanan.findUnique({
where: {
id: id,
},
});
if (!existing) {
return {
success: false,
message: "Data tidak ditemukan",
};
}
const deleted = await prisma.tarifDanLayanan.delete({
where: { id },
});
return {
success: true,
message: "Data berhasil dihapus",
data: deleted,
};
}

View File

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

View File

@@ -0,0 +1,47 @@
import prisma from "@/lib/prisma";
export default async function tarifLayananFindUnique(request: Request) {
const url = new URL(request.url);
const pathSegments = url.pathname.split('/');
const id = pathSegments[pathSegments.length - 1];
if (!id) {
return Response.json({
success: false,
message: 'ID tidak boleh kosong',
}, {status: 400})
}
try {
if (typeof id !== 'string') {
return Response.json({
success: false,
message: "ID tidak valid",
}, { status: 400 });
}
const data = await prisma.tarifDanLayanan.findUnique({
where: { id },
});
if (!data) {
return Response.json({
success: false,
message: "Data tidak ditemukan",
}, { status: 404 });
}
return Response.json({
success: true,
message: "Berhasil mengambil data berdasarkan ID",
data,
}, { status: 200 });
} catch (error) {
console.error("Error fetching data:", error);
return Response.json({
success: false,
message: "Terjadi kesalahan saat mengambil data",
}, { status: 500 });
}
}

View File

@@ -0,0 +1,30 @@
import Elysia, { t } from "elysia";
import tarifLayananCreate from "./create";
import tarifLayananFindMany from "./findMany";
import tarifLayananFindUnique from "./findUnique";
import tarifLayananDelete from "./del";
import tarifLayananUpdate from "./updt";
const TarifLayanan = new Elysia({
prefix: "/tarifdanlayanan",
tags: ["Data Kesehatan/Fasilitas Kesehatan/Tarif Layanan"]
})
.get("/:id", async (context) => {
const response = await tarifLayananFindUnique(new Request(context.request));
return response;
})
.get("/findMany", tarifLayananFindMany)
.post("/create", tarifLayananCreate, {
body: t.Object({
tarif: t.String(),
layanan: t.String()
}),
})
.put("/:id", tarifLayananUpdate, {
body: t.Object({
tarif: t.String(),
layanan: t.String(),
}),
})
.delete("/del/:id", tarifLayananDelete)
export default TarifLayanan

View File

@@ -0,0 +1,32 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
type FormUpdate = {
layanan: string;
tarif: string;
};
export default async function tarifLayananUpdate(context: Context) {
const body = (await context.body) as FormUpdate;
const id = context.params.id as string;
try {
const result = await prisma.tarifDanLayanan.update({
where: { id },
data: {
layanan: body.layanan,
tarif: body.tarif,
},
});
return {
success: true,
message: "Berhasil mengupdate data tarif layanan",
data: result,
};
} catch (error) {
console.error("Error updating data tarif layanan:", error);
throw new Error(
"Gagal mengupdate data tarif layanan: " + (error as Error).message
);
}
}

View File

@@ -5,32 +5,26 @@ type FasilitasKesehatanInput = {
name: string; name: string;
informasiUmum: { fasilitas: string; alamat: string; jamOperasional: string }; informasiUmum: { fasilitas: string; alamat: string; jamOperasional: string };
layananUnggulan: { content: string }; layananUnggulan: { content: string };
dokterdanTenagaMedis: { name: string; specialist: string; jadwal: string }; dokterdanTenagaMedis: string[]; // ← ID saja
fasilitasPendukung: { content: string }; fasilitasPendukung: { content: string };
prosedurPendaftaran: { content: string }; prosedurPendaftaran: { content: string };
tarifDanLayanan: { layanan: string; tarif: string }; tarifDanLayanan: string[]; // ← ID saja
}; };
const fasilitasKesehatanUpdate = async (context: Context) => { const fasilitasKesehatanUpdate = async (context: Context) => {
const id = context.params?.id as string; const id = context.params?.id as string;
const body = await context.body as FasilitasKesehatanInput; const body = (await context.body) as FasilitasKesehatanInput;
if (!id) {
return new Response(
JSON.stringify({ success: false, message: "ID is required" }),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
const existing = await prisma.fasilitasKesehatan.findUnique({ const existing = await prisma.fasilitasKesehatan.findUnique({
where: { id }, where: { id },
include: {
dokterdantenagamedis: true,
tarifdanlayanan: true,
},
}); });
if (!existing) { if (!existing) {
return new Response( return { success: false, message: "Data tidak ditemukan" };
JSON.stringify({ success: false, message: "Data not found" }),
{ status: 404, headers: { "Content-Type": "application/json" } }
);
} }
const { const {
@@ -43,38 +37,46 @@ const fasilitasKesehatanUpdate = async (context: Context) => {
tarifDanLayanan, tarifDanLayanan,
} = body; } = body;
// Update data masing-masing relasi // update relasi 1-1
await Promise.all([ await Promise.all([
prisma.informasiUmum.update({ prisma.informasiUmum.update({
where: { id: existing.informasiUmumId }, where: { id: existing.informasiUmumId },
data: informasiUmum, data: informasiUmum,
}), }),
prisma.layananUnggulan.update({ prisma.layananUnggulan.update({
where: { id: existing.layananUnggulanId }, where: { id: existing.layananUnggulanId },
data: layananUnggulan, data: layananUnggulan,
}), }),
prisma.dokterdanTenagaMedis.update({
where: { id: existing.dokterdanTenagaMedisId },
data: dokterdanTenagaMedis,
}),
prisma.fasilitasPendukung.update({ prisma.fasilitasPendukung.update({
where: { id: existing.fasilitasPendukungId }, where: { id: existing.fasilitasPendukungId },
data: fasilitasPendukung, data: fasilitasPendukung,
}), }),
prisma.prosedurPendaftaran.update({ prisma.prosedurPendaftaran.update({
where: { id: existing.prosedurPendaftaranId }, where: { id: existing.prosedurPendaftaranId },
data: prosedurPendaftaran, data: prosedurPendaftaran,
}), }),
prisma.tarifDanLayanan.update({
where: { id: existing.tarifDanLayananId },
data: tarifDanLayanan,
}),
]); ]);
// Update main record // update m2m
const updated = await prisma.fasilitasKesehatan.update({ const updated = await prisma.fasilitasKesehatan.update({
where: { id }, where: { id },
data: { name },
data: {
name,
// reset dokter lama → ganti baru
dokterdantenagamedis: {
set: dokterdanTenagaMedis.map((id) => ({ id })),
},
tarifdanlayanan: {
set: tarifDanLayanan.map((id) => ({ id })),
},
},
include: { include: {
informasiumum: true, informasiumum: true,
layananunggulan: true, layananunggulan: true,
@@ -87,7 +89,7 @@ const fasilitasKesehatanUpdate = async (context: Context) => {
return { return {
success: true, success: true,
message: "Fasilitas berhasil diupdate", message: "Fasilitas diupdate",
data: updated, data: updated,
}; };
}; };

View File

@@ -20,6 +20,7 @@ import Kelahiran from "./data_kesehatan_warga/persentase_kelahiran_kematian/kela
import Kematian from "./data_kesehatan_warga/persentase_kelahiran_kematian/kematian"; import Kematian from "./data_kesehatan_warga/persentase_kelahiran_kematian/kematian";
import DokterTenagaMedis from "./data_kesehatan_warga/fasilitas_kesehatan/dokter-tenaga-medis"; import DokterTenagaMedis from "./data_kesehatan_warga/fasilitas_kesehatan/dokter-tenaga-medis";
import PendaftaranJadwalKegiatan from "./data_kesehatan_warga/jadwal_kegiatan/pendaftaran"; import PendaftaranJadwalKegiatan from "./data_kesehatan_warga/jadwal_kegiatan/pendaftaran";
import TarifLayanan from "./data_kesehatan_warga/fasilitas_kesehatan/tarif-layanan";
const Kesehatan = new Elysia({ const Kesehatan = new Elysia({
@@ -46,5 +47,6 @@ const Kesehatan = new Elysia({
.use(Kelahiran) .use(Kelahiran)
.use(Kematian) .use(Kematian)
.use(DokterTenagaMedis) .use(DokterTenagaMedis)
.use(TarifLayanan)
.use(PendaftaranJadwalKegiatan) .use(PendaftaranJadwalKegiatan)
export default Kesehatan; export default Kesehatan;

View File

@@ -1,36 +1,137 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { Context } from "elysia"; import { Context } from "elysia";
import { assignParentIdsToApbdesItems } from "./lib/getParentsID";
type APBDesItemInput = {
kode: string;
uraian: string;
anggaran: number;
realisasi: number;
selisih: number;
persentase: number;
level: number;
tipe?: string | null;
};
type FormCreate = { type FormCreate = {
name: string; tahun: number;
jumlah: string;
imageId: string; imageId: string;
fileId: string; fileId: string;
items: APBDesItemInput[];
}; };
export default async function apbdesCreate(context: Context) { export default async function apbdesCreate(context: Context) {
const body = context.body as FormCreate; const body = context.body as FormCreate;
// Log the incoming request for debugging
console.log('Incoming request body:', JSON.stringify(body, null, 2));
try { try {
const result = await prisma.aPBDes.create({ // Validate required fields
data: { if (!body.tahun) {
name: body.name, throw new Error('Tahun is required');
jumlah: body.jumlah, }
imageId: body.imageId, if (!body.imageId) {
fileId: body.fileId, throw new Error('Image ID is required');
}, }
include: { if (!body.fileId) {
image: true, throw new Error('File ID is required');
file: true, }
}, if (!body.items || body.items.length === 0) {
throw new Error('At least one item is required');
}
// 1. Buat APBDes + items (tanpa parentId dulu)
const created = await prisma.$transaction(async (prisma) => {
const apbdes = await prisma.aPBDes.create({
data: {
tahun: body.tahun,
name: `APBDes Tahun ${body.tahun}`,
imageId: body.imageId,
fileId: body.fileId,
},
});
// Create items in a batch
const items = await Promise.all(
body.items.map(item => {
// Create a new object with only the fields that exist in the APBDesItem model
const itemData = {
kode: item.kode,
uraian: item.uraian,
anggaran: item.anggaran,
realisasi: item.realisasi,
selisih: item.selisih,
persentase: item.persentase,
level: item.level,
tipe: item.tipe, // ✅ sertakan, biar null
apbdesId: apbdes.id,
};
return prisma.aPBDesItem.create({
data: itemData,
select: { id: true, kode: true },
});
})
);
return { ...apbdes, items };
}); });
try {
// 2. Isi parentId berdasarkan kode
await assignParentIdsToApbdesItems(created.items);
// 3. Ambil ulang data lengkap untuk response
const result = await prisma.aPBDes.findUnique({
where: { id: created.id },
include: {
image: true,
file: true,
items: {
orderBy: { kode: 'asc' },
},
},
});
console.log('APBDes created successfully:', JSON.stringify(result, null, 2));
return {
success: true,
message: "Berhasil membuat APBDes",
data: result,
};
} catch (innerError) {
console.error('Error in post-creation steps:', innerError);
// Even if post-creation steps fail, we still return success since the main record was created
return {
success: true,
message: "APBDes berhasil dibuat, tetapi ada masalah dengan pemrosesan tambahan",
data: created,
warning: process.env.NODE_ENV === 'development' ? String(innerError) : undefined,
};
}
} catch (error: any) {
console.error("Error creating APBDes:", error);
// Log the full error for debugging
if (error.code) console.error('Prisma error code:', error.code);
if (error.meta) console.error('Prisma error meta:', error.meta);
const errorMessage = error.message || 'Unknown error';
context.set.status = 500;
return { return {
success: true, success: false,
message: "Berhasil membuat APB Des", message: `Gagal membuat APBDes: ${errorMessage}`,
data: result, error: process.env.NODE_ENV === 'development' ? {
message: error.message,
code: error.code,
meta: error.meta,
stack: error.stack
} : undefined
}; };
} catch (error) {
console.error("Error creating APB Des:", error);
throw new Error("Gagal membuat APB Des: " + (error as Error).message);
} }
} }

View File

@@ -2,20 +2,59 @@ import prisma from "@/lib/prisma";
import { Context } from "elysia"; import { Context } from "elysia";
export default async function apbdesDelete(context: Context) { export default async function apbdesDelete(context: Context) {
const { params } = context; const { id } = context.params as { id: string };
const id = params?.id as string;
if (!id) { if (!id) {
throw new Error("ID tidak ditemukan dalam parameter"); context.set.status = 400;
return {
success: false,
message: "ID APBDes diperlukan",
};
} }
const deleted = await prisma.aPBDes.delete({ try {
where: { id }, // Cek apakah ada
}); const existing = await prisma.aPBDes.findUnique({
where: { id },
});
return { if (!existing) {
success: true, context.set.status = 404;
message: "Berhasil menghapus APB Des", return {
data: deleted, success: false,
}; message: "APBDes tidak ditemukan",
};
}
// Soft delete: set isActive = false & isi deletedAt
const result = await prisma.aPBDes.update({
where: { id },
data: {
isActive: false,
deletedAt: new Date(),
},
});
// Opsional: juga soft delete semua item terkait
await prisma.aPBDesItem.updateMany({
where: { apbdesId: id },
data: {
isActive: false,
deletedAt: new Date()
},
});
return {
success: true,
message: "APBDes berhasil dihapus",
data: result,
};
} catch (error) {
console.error("Error deleting APBDes:", error);
context.set.status = 500;
return {
success: false,
message: "Gagal menghapus APBDes",
};
}
} }

View File

@@ -1,56 +1,57 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
// /api/berita/findManyPaginated.ts // src/app/api/.../apbdes/findMany.ts
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { Context } from "elysia"; import { Context } from "elysia";
async function apbdesFindMany(context: Context) { export default async function apbdesFindMany(context: Context) {
const page = Number(context.query.page) || 1; const { page = "1", limit = "10", tahun } = context.query as {
const limit = Number(context.query.limit) || 10; page?: string;
const search = (context.query.search as string) || ''; limit?: string;
const skip = (page - 1) * limit; tahun?: string;
};
// Buat where clause const pageNumber = Math.max(1, parseInt(page, 10) || 1);
const where: any = { isActive: true }; const limitNumber = Math.min(100, Math.max(1, parseInt(limit, 10) || 10));
// Tambahkan pencarian (jika ada) const skip = (pageNumber - 1) * limitNumber;
if (search) {
where.OR = [
{ name: { contains: search, mode: 'insensitive' } },
];
}
try { try {
const where: any = { isActive: true };
if (tahun) {
where.tahun = parseInt(tahun, 10);
}
const [data, total] = await Promise.all([ const [data, total] = await Promise.all([
prisma.aPBDes.findMany({ prisma.aPBDes.findMany({
where, where,
skip,
take: limitNumber,
orderBy: { createdAt: "desc" },
include: { include: {
image: true, image: true,
file: true, file: true,
items: true,
}, },
skip,
take: limit,
orderBy: { name: "asc" }, // opsional, kalau mau urut berdasarkan waktu
}),
prisma.aPBDes.count({
where,
}), }),
prisma.aPBDes.count({ where }),
]); ]);
return { return {
success: true, success: true,
message: "Success fetch APB Des with pagination",
data, data,
page, meta: {
totalPages: Math.ceil(total / limit), page: pageNumber,
total, limit: limitNumber,
total,
totalPages: Math.ceil(total / limitNumber),
},
}; };
} catch (e) { } catch (error) {
console.error("Find many paginated error:", e); console.error("Error fetching APBDes list:", error);
context.set.status = 500;
return { return {
success: false, success: false,
message: "Failed fetch APB Des with pagination", message: "Gagal mengambil daftar APBDes",
}; };
} }
} }
export default apbdesFindMany;

View File

@@ -2,28 +2,78 @@ import prisma from "@/lib/prisma";
import { Context } from "elysia"; import { Context } from "elysia";
export default async function apbdesFindUnique(context: Context) { export default async function apbdesFindUnique(context: Context) {
const { params } = context; // ✅ Parse URL secara manual
const id = params?.id as string; const url = new URL(context.request.url);
const pathSegments = url.pathname.split('/').filter(Boolean);
if (!id) {
throw new Error("ID tidak ditemukan dalam parameter"); console.log("🔍 DEBUG INFO:");
console.log("- Full URL:", context.request.url);
console.log("- Pathname:", url.pathname);
console.log("- Path segments:", pathSegments);
// Expected: ['api', 'landingpage', 'apbdes', 'ID']
if (pathSegments.length < 4) {
context.set.status = 400;
return {
success: false,
message: "Route tidak valid. Format: /api/landingpage/apbdes/:id",
debug: { pathSegments }
};
}
if (pathSegments[0] !== 'api' ||
pathSegments[1] !== 'landingpage' ||
pathSegments[2] !== 'apbdes') {
context.set.status = 400;
return {
success: false,
message: "Route tidak valid",
debug: { pathSegments }
};
}
const id = pathSegments[3]; // ✅ ID ada di index ke-3
if (!id || id.trim() === '') {
context.set.status = 400;
return {
success: false,
message: "ID APBDes diperlukan",
};
} }
const data = await prisma.aPBDes.findUnique({ try {
where: { id }, const result = await prisma.aPBDes.findUnique({
include: { where: { id },
image: true, include: {
file: true, items: {
}, where: { isActive: true },
}); orderBy: { kode: 'asc' }
},
image: true,
file: true
}
});
if (!data) { if (!result || !result.isActive) {
throw new Error("APB Des tidak ditemukan"); context.set.status = 404;
return {
success: false,
message: "Data APBDes tidak ditemukan atau tidak aktif",
};
}
return {
success: true,
data: result,
};
} catch (error) {
console.error("❌ Error in apbdesFindUnique:", error);
context.set.status = 500;
return {
success: false,
message: "Terjadi kesalahan saat mengambil data APBDes",
error: process.env.NODE_ENV === 'development' ? String(error) : undefined
};
} }
}
return {
success: true,
message: "Data APB Des ditemukan",
data,
};
}

View File

@@ -1,3 +1,4 @@
// src/app/api/[[...slugs]]/_lib/landing_page/apbdes/index.ts
import Elysia, { t } from "elysia"; import Elysia, { t } from "elysia";
import apbdesCreate from "./create"; import apbdesCreate from "./create";
import apbdesDelete from "./del"; import apbdesDelete from "./del";
@@ -5,12 +6,24 @@ import apbdesFindMany from "./findMany";
import apbdesFindUnique from "./findUnique"; import apbdesFindUnique from "./findUnique";
import apbdesUpdate from "./updt"; import apbdesUpdate from "./updt";
// Definisikan skema untuk item APBDes
const ApbdesItemSchema = t.Object({
kode: t.String(),
uraian: t.String(),
anggaran: t.Number(),
realisasi: t.Number(),
selisih: t.Number(),
persentase: t.Number(),
level: t.Number(),
tipe: t.Optional(t.Union([t.String(), t.Null()])) // misal: "pendapatan" atau "belanja"
});
const APBDes = new Elysia({ const APBDes = new Elysia({
prefix: "/apbdes", prefix: "/apbdes",
tags: ["Landing Page/Profile/APB Des"], tags: ["Landing Page/Profile/APB Des"],
}) })
// ✅ Find all // ✅ Find all (dengan query opsional: page, limit, tahun)
.get("/findMany", apbdesFindMany) .get("/findMany", apbdesFindMany)
// ✅ Find by ID // ✅ Find by ID
@@ -19,23 +32,27 @@ const APBDes = new Elysia({
// ✅ Create // ✅ Create
.post("/create", apbdesCreate, { .post("/create", apbdesCreate, {
body: t.Object({ body: t.Object({
name: t.String(), tahun: t.Number(),
imageId: t.String(), imageId: t.String(),
fileId: t.String(), fileId: t.String(),
jumlah: t.String(), items: t.Array(ApbdesItemSchema),
}), }),
}) })
// ✅ Update // ✅ Update
.put("/:id", apbdesUpdate, { .put("/:id", apbdesUpdate, {
params: t.Object({ id: t.String() }),
body: t.Object({ body: t.Object({
name: t.String(), tahun: t.Number(),
imageId: t.Optional(t.String()), imageId: t.String(),
fileId: t.Optional(t.String()), fileId: t.String(),
jumlah: t.Optional(t.String()), items: t.Array(ApbdesItemSchema),
}), }),
}) })
// ✅ Delete
.delete("/del/:id", apbdesDelete);
export default APBDes; // ✅ Delete
.delete("/del/:id", apbdesDelete, {
params: t.Object({ id: t.String() }),
});
export default APBDes;

View File

@@ -0,0 +1,54 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma";
/**
* Helper untuk mengisi parentId pada item APBDes berdasarkan struktur kode.
* Harus dipanggil setelah semua item berhasil dibuat (dengan id yang valid).
*
* @param items - Array item yang sudah punya { id, kode }
*/
export async function assignParentIdsToApbdesItems(
items: { id: string; kode: string }[]
): Promise<void> {
// Buat lookup berdasarkan kode → id
const kodeToId = new Map<string, string>();
for (const item of items) {
kodeToId.set(item.kode, item.id);
}
// Siapkan batch update
const updates: Promise<any>[] = [];
for (const item of items) {
const { id, kode } = item;
// Skip jika kode tidak memiliki parent (level 1, misal "4")
if (!kode.includes(".")) {
// Pastikan parentId null untuk akar
updates.push(
prisma.aPBDesItem.update({
where: { id },
data: { parentId: null },
})
);
continue;
}
// Ambil kode parent: "4.1.2" → "4.1"
const parts = kode.split(".");
parts.pop(); // hapus bagian terakhir
const parentKode = parts.join(".");
const parentID = kodeToId.get(parentKode) || null;
updates.push(
prisma.aPBDesItem.update({
where: { id },
data: { parentId: parentID },
})
);
}
// Jalankan semua update secara paralel
await Promise.all(updates);
}

View File

@@ -1,51 +1,113 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { Context } from "elysia"; import { Context } from "elysia";
import { assignParentIdsToApbdesItems } from "./lib/getParentsID";
type FormUpdateAPBDes = { type APBDesItemInput = {
name?: string; kode: string;
imageId?: string; uraian: string;
fileId?: string; anggaran: number;
jumlah?: string; realisasi: number;
selisih: number;
persentase: number;
level: number;
tipe?: string | null;
};
type FormUpdateBody = {
tahun: number;
imageId: string;
fileId: string;
items: APBDesItemInput[];
}; };
export default async function apbdesUpdate(context: Context) { export default async function apbdesUpdate(context: Context) {
const body = context.body as FormUpdateAPBDes; const body = context.body as FormUpdateBody;
const { id } = context.params as { id: string };
const id = context.params.id;
if (!id) {
return {
success: false,
message: "ID APB Des wajib diisi",
};
}
try { try {
const updated = await prisma.aPBDes.update({ // 1. Pastikan APBDes ada
const existing = await prisma.aPBDes.findUnique({
where: { id },
});
if (!existing) {
context.set.status = 404;
return {
success: false,
message: "APBDes tidak ditemukan",
};
}
// 2. Hapus semua item lama
await prisma.aPBDesItem.deleteMany({
where: { apbdesId: id },
});
// 3. Buat item baru tanpa parentId terlebih dahulu
await prisma.aPBDesItem.createMany({
data: body.items.map((item) => ({
apbdesId: id,
kode: item.kode,
uraian: item.uraian,
anggaran: item.anggaran,
realisasi: item.realisasi,
selisih: item.anggaran - item.realisasi,
persentase: item.anggaran > 0 ? (item.realisasi / item.anggaran) * 100 : 0,
level: item.level,
tipe: item.tipe || null,
isActive: true,
})),
});
// 4. Dapatkan semua item yang baru dibuat untuk mendapatkan ID-nya
const allItems = await prisma.aPBDesItem.findMany({
where: { apbdesId: id },
select: { id: true, kode: true },
});
// 5. Update parentId untuk setiap item
// Pastikan allItems memiliki tipe yang benar
const itemsForParentUpdate = allItems.map(item => ({
id: item.id,
kode: item.kode,
}));
await assignParentIdsToApbdesItems(itemsForParentUpdate);
// 6. Update data APBDes
await prisma.aPBDes.update({
where: { id }, where: { id },
data: { data: {
name: body.name, tahun: body.tahun,
imageId: body.imageId, imageId: body.imageId,
fileId: body.fileId, fileId: body.fileId,
jumlah: body.jumlah,
}, },
});
// 5. Ambil data lengkap untuk response
const result = await prisma.aPBDes.findUnique({
where: { id },
include: { include: {
items: {
where: { isActive: true },
orderBy: { kode: 'asc' }
},
image: true, image: true,
file: true,
}, },
}); });
return { return {
success: true, success: true,
message: "APB Des berhasil diperbarui", message: "Berhasil memperbarui APBDes",
data: updated, data: result,
}; };
} catch (error: any) { } catch (error) {
console.error("Error update APB Des:", error); console.error("Error updating APBDes:", error);
context.set.status = 500;
return { return {
success: false, success: false,
message: "Gagal memperbarui data APB Des", message: "Gagal memperbarui APBDes",
error: error.message,
}; };
} }
} }

View File

@@ -5,6 +5,7 @@ type FormCreate = {
name: string; name: string;
imageId: string; imageId: string;
iconUrl: string; iconUrl: string;
icon: string;
}; };
export default async function mediaSosialCreate(context: Context) { export default async function mediaSosialCreate(context: Context) {
@@ -14,8 +15,9 @@ export default async function mediaSosialCreate(context: Context) {
const result = await prisma.mediaSosial.create({ const result = await prisma.mediaSosial.create({
data: { data: {
name: body.name, name: body.name,
imageId: body.imageId, imageId: body.imageId || null,
iconUrl: body.iconUrl, iconUrl: body.iconUrl,
icon: body.icon || null,
}, },
include: { include: {
image: true, image: true,
@@ -29,8 +31,6 @@ export default async function mediaSosialCreate(context: Context) {
}; };
} catch (error) { } catch (error) {
console.error("Error creating media sosial:", error); console.error("Error creating media sosial:", error);
throw new Error( throw new Error("Gagal membuat media sosial: " + (error as Error).message);
"Gagal membuat media sosial: " + (error as Error).message
);
} }
} }

View File

@@ -20,8 +20,9 @@ const MediaSosial = new Elysia({
.post("/create", MediaSosialCreate, { .post("/create", MediaSosialCreate, {
body: t.Object({ body: t.Object({
name: t.String(), name: t.String(),
imageId: t.String(), imageId: t.Union([t.String(), t.Null()]),
iconUrl: t.String(), iconUrl: t.Union([t.String(), t.Null()]),
icon: t.Union([t.String(), t.Null()]),
}), }),
}) })
@@ -29,8 +30,9 @@ const MediaSosial = new Elysia({
.put("/:id", MediaSosialUpdate, { .put("/:id", MediaSosialUpdate, {
body: t.Object({ body: t.Object({
name: t.String(), name: t.String(),
imageId: t.Optional(t.String()), imageId: t.Optional(t.Union([t.String(), t.Null()])),
iconUrl: t.Optional(t.String()), iconUrl: t.Optional(t.Union([t.String(), t.Null()])),
icon: t.Optional(t.Union([t.String(), t.Null()])),
}), }),
}) })
// ✅ Delete // ✅ Delete

View File

@@ -6,6 +6,7 @@ type FormUpdateMediaSosial = {
name?: string; name?: string;
imageId?: string; imageId?: string;
iconUrl?: string; iconUrl?: string;
icon?: string;
}; };
export default async function mediaSosialUpdate(context: Context) { export default async function mediaSosialUpdate(context: Context) {
@@ -20,13 +21,29 @@ export default async function mediaSosialUpdate(context: Context) {
}; };
} }
// 🚨 Tambahkan validasi di sini
if (!body.name || body.name.trim().length < 3) {
return {
success: false,
message: "Nama media sosial minimal 3 karakter",
};
}
if (!body.iconUrl || body.iconUrl.trim().length < 3) {
return {
success: false,
message: "Icon URL minimal 3 karakter",
};
}
try { try {
const updated = await prisma.mediaSosial.update({ const updated = await prisma.mediaSosial.update({
where: { id }, where: { id },
data: { data: {
name: body.name, name: body.name,
imageId: body.imageId, imageId: body.imageId || null, // pastikan null jika kosong
iconUrl: body.iconUrl, iconUrl: body.iconUrl,
icon: body.icon || null, // pastikan null jika kosong
}, },
include: { include: {
image: true, image: true,

View File

@@ -3,18 +3,17 @@ import { Context } from "elysia";
type FormCreate = { type FormCreate = {
namaLengkap: string; namaLengkap: string;
nik: string; nis: string;
kelas: string;
jenisKelamin: "LAKI_LAKI" | "PEREMPUAN";
alamatDomisili?: string;
tempatLahir: string; tempatLahir: string;
tanggalLahir: string; // ISO date string tanggalLahir: string; // ISO date string
jenisKelamin: "LAKI_LAKI" | "PEREMPUAN"; namaOrtu?: string;
kewarganegaraan: string; nik: string;
agama: "ISLAM" | "KRISTEN_PROTESTAN" | "KRISTEN_KATOLIK" | "HINDU" | "BUDDHA" | "KONGHUCU" | "LAINNYA"; pekerjaanOrtu?: string;
alamatKTP: string; penghasilan?: string;
alamatDomisili?: string;
noHp: string; noHp: string;
email: string;
statusPernikahan: "BELUM_MENIKAH" | "MENIKAH" | "JANDA_DUDA";
ukuranBaju?: "S" | "M" | "L" | "XL" | "XXL" | "LAINNYA";
}; };
export default async function beasiswaPendaftarCreate(context: Context) { export default async function beasiswaPendaftarCreate(context: Context) {
@@ -24,18 +23,17 @@ export default async function beasiswaPendaftarCreate(context: Context) {
const result = await prisma.beasiswaPendaftar.create({ const result = await prisma.beasiswaPendaftar.create({
data: { data: {
namaLengkap: body.namaLengkap, namaLengkap: body.namaLengkap,
nik: body.nik, nis: body.nis,
kelas: body.kelas,
jenisKelamin: body.jenisKelamin,
alamatDomisili: body.alamatDomisili,
tempatLahir: body.tempatLahir, tempatLahir: body.tempatLahir,
tanggalLahir: new Date(body.tanggalLahir), tanggalLahir: new Date(body.tanggalLahir),
jenisKelamin: body.jenisKelamin, namaOrtu: body.namaOrtu,
kewarganegaraan: body.kewarganegaraan, nik: body.nik,
agama: body.agama, pekerjaanOrtu: body.pekerjaanOrtu,
alamatKTP: body.alamatKTP, penghasilan: body.penghasilan,
alamatDomisili: body.alamatDomisili,
noHp: body.noHp, noHp: body.noHp,
email: body.email,
statusPernikahan: body.statusPernikahan,
ukuranBaju: body.ukuranBaju,
}, },
}); });

View File

@@ -13,18 +13,17 @@ const BeasiswaPendaftar = new Elysia({
.post("/create", beasiswaPendaftarCreate, { .post("/create", beasiswaPendaftarCreate, {
body: t.Object({ body: t.Object({
namaLengkap: t.String(), namaLengkap: t.String(),
nik: t.String(), nis: t.String(),
kelas: t.String(),
jenisKelamin: t.String(),
alamatDomisili: t.String(),
tempatLahir: t.String(), tempatLahir: t.String(),
tanggalLahir: t.String(), tanggalLahir: t.String(),
jenisKelamin: t.String(), namaOrtu: t.String(),
kewarganegaraan: t.String(), nik: t.String(),
agama: t.String(), pekerjaanOrtu: t.String(),
alamatKTP: t.String(), penghasilan: t.String(),
alamatDomisili: t.String(),
noHp: t.String(), noHp: t.String(),
email: t.String(),
statusPernikahan: t.String(),
ukuranBaju: t.String(),
}), }),
}) })
.get("/findMany", beasiswaPendaftarFindMany) .get("/findMany", beasiswaPendaftarFindMany)
@@ -43,18 +42,17 @@ const BeasiswaPendaftar = new Elysia({
{ {
body: t.Object({ body: t.Object({
namaLengkap: t.String(), namaLengkap: t.String(),
nik: t.String(), nis: t.String(),
kelas: t.String(),
jenisKelamin: t.String(),
alamatDomisili: t.String(),
tempatLahir: t.String(), tempatLahir: t.String(),
tanggalLahir: t.String(), tanggalLahir: t.String(),
jenisKelamin: t.String(), namaOrtu: t.String(),
kewarganegaraan: t.String(), nik: t.String(),
agama: t.String(), pekerjaanOrtu: t.String(),
alamatKTP: t.String(), penghasilan: t.String(),
alamatDomisili: t.String(),
noHp: t.String(), noHp: t.String(),
email: t.String(),
statusPernikahan: t.String(),
ukuranBaju: t.String(),
}), }),
} }
) )

View File

@@ -3,25 +3,17 @@ import { Context } from "elysia";
type FormUpdate = { type FormUpdate = {
namaLengkap: string; namaLengkap: string;
nik: string; nis: string;
kelas: string;
jenisKelamin: "LAKI_LAKI" | "PEREMPUAN";
alamatDomisili?: string;
tempatLahir: string; tempatLahir: string;
tanggalLahir: string; // ISO date string tanggalLahir: string; // ISO date string
jenisKelamin: "LAKI_LAKI" | "PEREMPUAN"; namaOrtu?: string;
kewarganegaraan: string; nik: string;
agama: pekerjaanOrtu?: string;
| "ISLAM" penghasilan?: string;
| "KRISTEN_PROTESTAN"
| "KRISTEN_KATOLIK"
| "HINDU"
| "BUDDHA"
| "KONGHUCU"
| "LAINNYA";
alamatKTP: string;
alamatDomisili?: string;
noHp: string; noHp: string;
email: string;
statusPernikahan: "BELUM_MENIKAH" | "MENIKAH" | "JANDA_DUDA";
ukuranBaju?: "S" | "M" | "L" | "XL" | "XXL" | "LAINNYA";
}; };
export default async function beasiswaPendaftarUpdate(context: Context) { export default async function beasiswaPendaftarUpdate(context: Context) {
@@ -40,18 +32,17 @@ export default async function beasiswaPendaftarUpdate(context: Context) {
where: { id }, where: { id },
data: { data: {
namaLengkap: body.namaLengkap, namaLengkap: body.namaLengkap,
nik: body.nik, nis: body.nis,
kelas: body.kelas,
jenisKelamin: body.jenisKelamin,
alamatDomisili: body.alamatDomisili,
tempatLahir: body.tempatLahir, tempatLahir: body.tempatLahir,
tanggalLahir: new Date(body.tanggalLahir), tanggalLahir: new Date(body.tanggalLahir),
jenisKelamin: body.jenisKelamin, namaOrtu: body.namaOrtu,
kewarganegaraan: body.kewarganegaraan, nik: body.nik,
agama: body.agama, pekerjaanOrtu: body.pekerjaanOrtu,
alamatKTP: body.alamatKTP, penghasilan: body.penghasilan,
alamatDomisili: body.alamatDomisili,
noHp: body.noHp, noHp: body.noHp,
email: body.email,
statusPernikahan: body.statusPernikahan,
ukuranBaju: body.ukuranBaju,
}, },
}); });

View File

@@ -3,39 +3,55 @@ import { Prisma } from "@prisma/client";
import { Context } from "elysia"; import { Context } from "elysia";
type FormCreate = Prisma.PermohonanInformasiPublikGetPayload<{ type FormCreate = Prisma.PermohonanInformasiPublikGetPayload<{
select: { select: {
name: true; name: true;
nik: true; nik: true;
email: true; email: true;
notelp: true; notelp: true;
alamat: true; alamat: true;
jenisInformasiDimintaId: true; jenisInformasiDimintaId: true;
caraMemperolehInformasiId: true; caraMemperolehInformasiId: true;
caraMemperolehSalinanInformasiId: true; caraMemperolehSalinanInformasiId: true;
} };
}> }>;
export default async function permohonanInformasiPublikCreate(context: Context) {
const body = context.body as FormCreate;
await prisma.permohonanInformasiPublik.create({
data: {
name: body.name,
nik: body.nik,
email: body.email,
notelp: body.notelp,
alamat: body.alamat,
jenisInformasiDimintaId: body.jenisInformasiDimintaId,
caraMemperolehInformasiId: body.caraMemperolehInformasiId,
caraMemperolehSalinanInformasiId: body.caraMemperolehSalinanInformasiId,
}
})
export default async function permohonanInformasiPublikCreate(context: Context) {
const body = context.body as FormCreate;
// ========== VALIDASI NIK ==========
if (body.nik && body.nik.length > 16) {
return { return {
success: true, success: false,
message: "Permohonan Informasi Publik Berhasil Dibuat", status: 400,
data: { message: "Maksimal NIK adalah 16 angka",
...body, };
} }
}
// ========== VALIDASI NOMOR TELEPON ==========
if (body.notelp && body.notelp.length > 15) {
return {
success: false,
status: 400,
message: "Maksimal nomor telepon adalah 15 angka",
};
}
await prisma.permohonanInformasiPublik.create({
data: {
name: body.name,
nik: body.nik,
email: body.email,
notelp: body.notelp,
alamat: body.alamat,
jenisInformasiDimintaId: body.jenisInformasiDimintaId,
caraMemperolehInformasiId: body.caraMemperolehInformasiId,
caraMemperolehSalinanInformasiId: body.caraMemperolehSalinanInformasiId,
},
});
return {
success: true,
message: "Permohonan Informasi Publik Berhasil Dibuat",
data: { ...body },
};
} }

View File

@@ -3,31 +3,42 @@ import { Prisma } from "@prisma/client";
import { Context } from "elysia"; import { Context } from "elysia";
type FormCreate = Prisma.FormulirPermohonanKeberatanGetPayload<{ type FormCreate = Prisma.FormulirPermohonanKeberatanGetPayload<{
select: { select: {
name: true; name: true;
email: true; email: true;
notelp: true; notelp: true;
alasan: true; alasan: true;
} };
}> }>;
export default async function permohonanKeberatanInformasiPublikCreate(context: Context) { export default async function permohonanKeberatanInformasiPublikCreate(
const body = context.body as FormCreate; context: Context
) {
await prisma.formulirPermohonanKeberatan.create({ const body = context.body as FormCreate;
data: {
name: body.name,
email: body.email,
notelp: body.notelp,
alasan: body.alasan,
}
})
// ========== VALIDASI NOMOR TELEPON ==========
if (body.notelp && body.notelp.length > 15) {
return { return {
success: true, success: false,
message: "Permohonan Keberatan Informasi Publik Berhasil Dibuat", status: 400,
data: { message: "Maksimal nomor telepon adalah 15 angka",
...body, };
} }
}
} await prisma.formulirPermohonanKeberatan.create({
data: {
name: body.name,
email: body.email,
notelp: body.notelp,
alasan: body.alasan,
},
});
return {
success: true,
message: "Permohonan Keberatan Informasi Publik Berhasil Dibuat",
data: {
...body,
},
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
// /api/user/delUser.ts
import prisma from '@/lib/prisma';
import { Context } from 'elysia';
export default async function userDeleteAccount(context: Context) {
const { id } = context.params as { id: string };
try {
// 1. Cek user dulu
const existingUser = await prisma.user.findUnique({
where: { id },
});
if (!existingUser) {
return {
success: false,
message: 'User tidak ditemukan',
};
}
// ✅ 2. Hapus SEMUA relasi dalam TRANSACTION
const result = await prisma.$transaction(async (tx) => {
// Hapus UserSession
const deletedSessions = await tx.userSession.deleteMany({
where: { userId: id },
});
// ✅ Hapus UserMenuAccess
const deletedMenuAccess = await tx.userMenuAccess.deleteMany({
where: { userId: id },
});
// ✅ Tambahkan relasi lain jika ada (contoh):
// await tx.userLog.deleteMany({ where: { userId: id } });
// await tx.userNotification.deleteMany({ where: { userId: id } });
// await tx.userToken.deleteMany({ where: { userId: id } });
// Hapus user
const deletedUser = await tx.user.delete({
where: { id },
});
return {
user: deletedUser,
sessionsDeleted: deletedSessions.count,
menuAccessDeleted: deletedMenuAccess.count,
};
});
return {
success: true,
message: `User berhasil dihapus permanen (${result.sessionsDeleted} session, ${result.menuAccessDeleted} menu access)`,
data: result,
};
} catch (error) {
console.error('Error delete user:', error);
return {
success: false,
message: 'Terjadi kesalahan saat menghapus user',
};
}
}

View File

@@ -5,6 +5,7 @@ import userFindMany from "./findMany";
import userFindUnique from "./findUnique"; import userFindUnique from "./findUnique";
import userDelete from "./del"; // `delete` nggak boleh jadi nama file JS langsung, jadi biasanya `del.ts` import userDelete from "./del"; // `delete` nggak boleh jadi nama file JS langsung, jadi biasanya `del.ts`
import userUpdate from "./updt"; import userUpdate from "./updt";
import userDeleteAccount from "./delUser";
const User = new Elysia({ prefix: "/api/user" }) const User = new Elysia({ prefix: "/api/user" })
.get("/findMany", userFindMany) .get("/findMany", userFindMany)
@@ -14,6 +15,21 @@ const User = new Elysia({ prefix: "/api/user" })
id: t.String(), id: t.String(),
}), }),
}) // pakai PUT untuk soft delete }) // pakai PUT untuk soft delete
.put("/updt", userUpdate); .put(
"/updt",
userUpdate,
{
body: t.Object({
id: t.String(),
isActive: t.Optional(t.Boolean()),
roleId: t.Optional(t.String()),
})
}
)
.delete("/delUser/:id", userDeleteAccount, {
params: t.Object({
id: t.String(),
}),
});
export default User; export default User;

View File

@@ -3,7 +3,6 @@ import { Context } from "elysia";
type FormCreate = { type FormCreate = {
name: string; name: string;
permissions: string[];
} }
export default async function roleCreate(context: Context) { export default async function roleCreate(context: Context) {
@@ -13,7 +12,6 @@ export default async function roleCreate(context: Context) {
const result = await prisma.role.create({ const result = await prisma.role.create({
data: { data: {
name: body.name, name: body.name,
permissions: body.permissions,
}, },
}); });
return { return {

View File

@@ -13,7 +13,6 @@ const Role = new Elysia({
.post("/create", roleCreate, { .post("/create", roleCreate, {
body: t.Object({ body: t.Object({
name: t.String(), name: t.String(),
permissions: t.Array(t.String()),
}), }),
}) })
@@ -27,7 +26,6 @@ const Role = new Elysia({
.put("/:id", roleUpdate, { .put("/:id", roleUpdate, {
body: t.Object({ body: t.Object({
name: t.String(), name: t.String(),
permissions: t.Array(t.String()),
}), }),
}) })
.delete("/del/:id", roleDelete); .delete("/del/:id", roleDelete);

View File

@@ -3,7 +3,6 @@ import { Context } from "elysia";
type FormUpdate = { type FormUpdate = {
name: string; name: string;
permissions: string[];
} }
export default async function roleUpdate(context: Context) { export default async function roleUpdate(context: Context) {
@@ -15,7 +14,6 @@ export default async function roleUpdate(context: Context) {
where: { id }, where: { id },
data: { data: {
name: body.name, name: body.name,
permissions: body.permissions,
}, },
}); });
return { return {

View File

@@ -2,39 +2,88 @@
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { Context } from "elysia"; import { Context } from "elysia";
// API update user
export default async function userUpdate(context: Context) { export default async function userUpdate(context: Context) {
try { try {
const { id, isActive } = await context.body as { id: string, isActive: boolean }; const { id, isActive, roleId } = (await context.body) as {
id: string;
isActive?: boolean;
roleId?: string;
};
if (!id) { if (!id) {
return { return { success: false, message: "ID user wajib ada" };
success: false,
message: "ID user wajib ada",
};
} }
// Validasi role
if (roleId) {
const role = await prisma.role.findUnique({ where: { id: roleId } });
if (!role) return { success: false, message: "Role tidak ditemukan" };
}
const currentUser = await prisma.user.findUnique({
where: { id },
select: { roleId: true, isActive: true },
});
if (!currentUser) {
return { success: false, message: "User tidak ditemukan" };
}
const isRoleChanged = roleId && currentUser.roleId !== roleId;
const isActiveChanged =
isActive !== undefined && currentUser.isActive !== isActive;
// ✅ Jika role berubah, hapus semua akses menu yang ada
if (isRoleChanged) {
await prisma.userMenuAccess.deleteMany({
where: { userId: id }
});
}
// Update user
const updatedUser = await prisma.user.update({ const updatedUser = await prisma.user.update({
where: { id }, where: { id },
data: { isActive }, data: {
...(isActive !== undefined && { isActive }),
...(roleId && { roleId }),
// Force logout: invalidate semua sesi
...(isRoleChanged ? { sessionInvalid: true } : {}),
},
select: { select: {
id: true, id: true,
username: true, username: true,
nomor: true, nomor: true,
isActive: true, isActive: true,
updatedAt: true, roleId: true,
} role: { select: { name: true } },
},
}); });
// ✅ HAPUS SEMUA SESI USER DI DATABASE
if (isRoleChanged) {
await prisma.userSession.deleteMany({ where: { userId: id } });
}
return { return {
success: true, success: true,
message: `User berhasil ${isActive ? "diaktifkan" : "dinonaktifkan"}`, roleChanged: isRoleChanged,
isActiveChanged,
data: updatedUser, data: updatedUser,
message: isRoleChanged
? `Role ${updatedUser.username} diubah. User akan logout otomatis.`
: isActiveChanged
? `${updatedUser.username} ${
isActive ? "diaktifkan" : "dinonaktifkan"
}.`
: "User berhasil diupdate",
}; };
} catch (e: any) { } catch (e: any) {
console.error("Error update user:", e); console.error("Error update user:", e);
return { return {
success: false, success: false,
message: "Gagal mengupdate status user", message: "Gagal mengupdate user: " + (e.message || "Unknown error"),
}; };
} }
} }

View File

@@ -0,0 +1,65 @@
// src/app/api/admin/user-menu-access/route.ts
import { NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
// ❌ HAPUS { params } karena tidak dipakai
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url)
const userId = searchParams.get('userId')
if (!userId) {
return NextResponse.json(
{ success: false, message: 'User ID diperlukan' },
{ status: 400 }
)
}
const menuAccess = await prisma.userMenuAccess.findMany({
where: { userId },
select: { menuId: true },
})
return NextResponse.json({
success: true,
menuIds: menuAccess.map(m => m.menuId),
})
} catch (error) {
console.error('GET User Menu Access Error:', error)
return NextResponse.json(
{ success: false, message: 'Gagal memuat menu akses' },
{ status: 500 }
)
}
}
// POST tetap sama (tanpa perubahan)
export async function POST(request: Request) {
try {
const { userId, menuIds } = await request.json()
if (!userId || !Array.isArray(menuIds)) {
return NextResponse.json(
{ success: false, message: 'Data tidak valid' },
{ status: 400 }
)
}
await prisma.userMenuAccess.deleteMany({ where: { userId } })
if (menuIds.length > 0) {
await prisma.userMenuAccess.createMany({
data: menuIds.map((menuId: string) => ({ userId, menuId })),
})
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('POST User Menu Access Error:', error)
return NextResponse.json(
{ success: false, message: 'Gagal menyimpan menu akses' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,144 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
// app/api/auth/_lib/api_fetch_auth.ts
// app/api/auth/_lib/api_fetch_auth.ts
export const apiFetchLogin = async ({ nomor }: { nomor: string }) => {
if (!nomor || nomor.replace(/\D/g, '').length < 10) {
throw new Error('Nomor tidak valid');
}
const cleanPhone = nomor.replace(/\D/g, '');
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nomor: cleanPhone }),
credentials: 'include'
});
// Pastikan respons bisa di-parse sebagai JSON
let data;
try {
data = await response.json();
} catch (e) {
console.error("Non-JSON response from /api/auth/login:", await response.text());
throw new Error('Respons server tidak valid');
}
if (!response.ok) {
throw new Error(data.message || 'Gagal memproses login');
}
// Validasi minimal respons
if (typeof data.success !== 'boolean' || typeof data.isRegistered !== 'boolean') {
throw new Error('Respons tidak sesuai format');
}
if (data.success) {
if (data.isRegistered && !data.kodeId) {
throw new Error('Kode verifikasi tidak ditemukan untuk user terdaftar');
}
return data; // { success, isRegistered, kodeId? }
} else {
throw new Error(data.message || 'Login gagal');
}
};
export const apiFetchRegister = async ({
username,
nomor,
}: {
username: string;
nomor: string;
}) => {
const cleanPhone = nomor.replace(/\D/g, '');
if (cleanPhone.length < 10) throw new Error('Nomor tidak valid');
const response = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: username.trim(), nomor: cleanPhone }),
credentials: 'include',
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Gagal mengirim OTP');
return data;
};
export const apiFetchOtpData = async ({ kodeId }: { kodeId: string }) => {
if (!kodeId) {
throw new Error('Kode ID tidak valid');
}
const response = await fetch("/api/auth/otp-data", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kodeId }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Gagal memuat data OTP');
}
return data;
};
// Ganti endpoint ke verify-otp-login
export const apiFetchVerifyOtp = async ({ nomor, otp, kodeId }: { nomor: string; otp: string; kodeId: string }) => {
const response = await fetch('/api/auth/verify-otp-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor, otp, kodeId }),
});
const data = await response.json();
return {
success: response.ok,
...data,
status: response.status,
};
};
// Di dalam api_fetch_auth.ts
export async function apiFetchUserMenuAccess(userId: string): Promise<{
success: boolean;
menuIds?: string[];
message?: string;
}> {
try {
const res = await fetch(`/api/admin/user-menu-access/${userId}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
const data = await res.json();
return data;
} catch (error) {
console.error('API Fetch User Menu Access Error:', error);
return { success: false, message: 'Gagal memuat menu akses' };
}
}
export async function apiUpdateUserMenuAccess(
userId: string,
menuIds: string[]
): Promise<{ success: boolean; message?: string }> {
try {
const res = await fetch('/api/admin/user-menu-access', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, menuIds }),
});
const data = await res.json();
return data;
} catch (error) {
console.error('API Update User Menu Access Error:', error);
return { success: false, message: 'Gagal menyimpan menu akses' };
}
}

View File

@@ -1,50 +1,32 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { jwtVerify } from "jose"; import { jwtVerify } from "jose";
export async function decrypt({ export async function decrypt({
token, token,
encodedKey, jwtSecret,
}: { }: {
token: string; token: string;
encodedKey: string; jwtSecret: string;
}): Promise<Record<string, any> | null> { }): Promise<Record<string, unknown> | null> {
if (!token || !encodedKey) { if (!token || !jwtSecret) return null;
console.error("Missing required parameters:", {
hasToken: !!token,
hasEncodedKey: !!encodedKey,
});
return null;
}
try { try {
const enc = new TextEncoder().encode(encodedKey); const secret = new TextEncoder().encode(jwtSecret);
const { payload } = await jwtVerify(token, enc, { const { payload } = await jwtVerify(token, secret, {
algorithms: ["HS256"], algorithms: ["HS256"],
}); });
if (!payload || !payload.user) { if (
console.error("Invalid payload structure:", { typeof payload !== "object" ||
hasPayload: !!payload, payload === null ||
hasUser: payload ? !!payload.user : false, !("user" in payload) ||
}); typeof payload.user !== "object"
) {
return null; return null;
} }
// Logging untuk debug return payload.user as Record<string, unknown>;
// console.log("Decrypt successful:", {
// payloadExists: !!payload,
// userExists: !!payload.user,
// tokenPreview: token.substring(0, 10) + "...",
// });
return payload.user as Record<string, any>;
} catch (error) { } catch (error) {
console.error("Token verification failed:", { console.error("JWT Decrypt failed:", error);
error,
tokenLength: token?.length,
errorName: error instanceof Error ? error.name : "Unknown error",
errorMessage: error instanceof Error ? error.message : String(error),
});
return null; return null;
} }
} }

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