Compare commits
42 Commits
nico/12-no
...
nico/10-de
| Author | SHA1 | Date | |
|---|---|---|---|
| 242ea86f77 | |||
| 99c2c9c6d7 | |||
| ac2fc1a705 | |||
| 9dbe172165 | |||
| cc318d4d54 | |||
| dcb8017594 | |||
| ec3ad12531 | |||
| dad44c0537 | |||
| 867dce42f0 | |||
| 7bb17ddf22 | |||
| a4069d3cba | |||
| ffe5e6dd9f | |||
| dcf195f54f | |||
| c03a6b3aed | |||
| 1bb9f239db | |||
| a213ff7d37 | |||
| 0018bdc251 | |||
| 83fb39a957 | |||
| 7238692dd0 | |||
| 8b50139d79 | |||
| 066180fc0e | |||
| 67f29aabef | |||
| dbf7c34228 | |||
| 036fc86fed | |||
| 2cecec733e | |||
| c64a2e5457 | |||
| 757911d7dd | |||
| 54232e4465 | |||
| 29a9a59bca | |||
| 2fb3666e57 | |||
| e30b27f7a4 | |||
| e941ed3893 | |||
| ace5aff1b6 | |||
| 716db0adca | |||
| a291bdfb51 | |||
| 0dff8f3254 | |||
| 78b8aa74cd | |||
| a0537810e8 | |||
| b3c169a2d4 | |||
| 2608a5ffdd | |||
| 6c32f3ebdb | |||
| 0feeb4de93 |
@@ -54,6 +54,7 @@
|
||||
"chart.js": "^4.4.8",
|
||||
"classnames": "^2.5.1",
|
||||
"colors": "^1.4.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^17.2.3",
|
||||
"elysia": "^1.3.5",
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
[
|
||||
{
|
||||
"id": "role-1",
|
||||
"name": "ADMIN DESA",
|
||||
"description": "Administrator Desa",
|
||||
"permissions": ["manage_users", "manage_content", "view_reports"],
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"id": "role-2",
|
||||
"name": "ADMIN KESEHATAN",
|
||||
"description": "Administrator Bidang Kesehatan",
|
||||
"permissions": ["manage_health_data", "view_reports"],
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"id": "role-3",
|
||||
"name": "ADMIN SEKOLAH",
|
||||
"description": "Administrator Sekolah",
|
||||
"permissions": ["manage_school_data", "view_reports"],
|
||||
"isActive": true
|
||||
}
|
||||
]
|
||||
{
|
||||
"id": "0",
|
||||
"name": "DEVELOPER",
|
||||
"description": "Developer",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"name": "SUPER ADMIN",
|
||||
"description": "Administrator",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "ADMIN DESA",
|
||||
"description": "Administrator Desa",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"name": "ADMIN KESEHATAN",
|
||||
"description": "Administrator Bidang Kesehatan",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"name": "ADMIN PENDIDIKAN",
|
||||
"description": "Administrator Bidang Pendidikan",
|
||||
"isActive": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,23 +1,10 @@
|
||||
[
|
||||
{
|
||||
"id": "user-1",
|
||||
"nama": "Admin Desa",
|
||||
"nomor": "089647037426",
|
||||
"roleId": "role-1",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"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
|
||||
"id": "cmie1o0zh0002vn132vtzg7hh",
|
||||
"username": "SuperAdmin-Nico",
|
||||
"nomor": "6289647037426",
|
||||
"roleId": 0,
|
||||
"isActive": true,
|
||||
"sessionInvalid": false
|
||||
}
|
||||
]
|
||||
|
||||
1127
prisma/migrations/20251119062255_add_unique_username/migration.sql
Normal file
1127
prisma/migrations/20251119062255_add_unique_username/migration.sql
Normal file
File diff suppressed because it is too large
Load Diff
@@ -136,6 +136,7 @@ model MediaSosial {
|
||||
name String
|
||||
image FileStorage? @relation(fields: [imageId], references: [id])
|
||||
imageId String?
|
||||
icon String?
|
||||
iconUrl String? @db.VarChar(255)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -184,18 +185,46 @@ model SdgsDesa {
|
||||
//========================================= APBDes ========================================= //
|
||||
model APBDes {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
jumlah String
|
||||
tahun Int?
|
||||
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])
|
||||
imageId String?
|
||||
file FileStorage? @relation("APBDesFile", fields: [fileId], references: [id])
|
||||
fileId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime @default(now())
|
||||
deletedAt DateTime? // opsional, tidak perlu default now()
|
||||
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 ========================================= //
|
||||
model PrestasiDesa {
|
||||
id String @id @default(cuid())
|
||||
@@ -754,24 +783,22 @@ model Penghargaan {
|
||||
|
||||
// ========================================= FASILITAS KESEHATAN ========================================= //
|
||||
model FasilitasKesehatan {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime @default(now())
|
||||
isActive Boolean @default(true)
|
||||
informasiumum InformasiUmum @relation(fields: [informasiUmumId], references: [id])
|
||||
informasiUmumId String
|
||||
layananunggulan LayananUnggulan @relation(fields: [layananUnggulanId], references: [id])
|
||||
layananUnggulanId String
|
||||
dokterdantenagamedis DokterdanTenagaMedis @relation(fields: [dokterdanTenagaMedisId], references: [id])
|
||||
dokterdanTenagaMedisId String
|
||||
fasilitaspendukung FasilitasPendukung @relation(fields: [fasilitasPendukungId], references: [id])
|
||||
fasilitasPendukungId String
|
||||
prosedurpendaftaran ProsedurPendaftaran @relation(fields: [prosedurPendaftaranId], references: [id])
|
||||
prosedurPendaftaranId String
|
||||
tarifdanlayanan TarifDanLayanan @relation(fields: [tarifDanLayananId], references: [id])
|
||||
tarifDanLayananId String
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime @default(now())
|
||||
isActive Boolean @default(true)
|
||||
informasiumum InformasiUmum @relation(fields: [informasiUmumId], references: [id])
|
||||
informasiUmumId String
|
||||
layananunggulan LayananUnggulan @relation(fields: [layananUnggulanId], references: [id])
|
||||
layananUnggulanId String
|
||||
dokterdantenagamedis DokterdanTenagaMedis[] @relation("Dokter")
|
||||
fasilitaspendukung FasilitasPendukung @relation(fields: [fasilitasPendukungId], references: [id])
|
||||
fasilitasPendukungId String
|
||||
prosedurpendaftaran ProsedurPendaftaran @relation(fields: [prosedurPendaftaranId], references: [id])
|
||||
prosedurPendaftaranId String
|
||||
tarifdanlayanan TarifDanLayanan[] @relation("Tarif")
|
||||
}
|
||||
|
||||
model InformasiUmum {
|
||||
@@ -797,15 +824,20 @@ model LayananUnggulan {
|
||||
}
|
||||
|
||||
model DokterdanTenagaMedis {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
specialist String
|
||||
jadwal String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime @default(now())
|
||||
isActive Boolean @default(true)
|
||||
FasilitasKesehatan FasilitasKesehatan[]
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
specialist String
|
||||
jadwal String
|
||||
jadwalLibur String?
|
||||
jamBukaOperasional String?
|
||||
jamTutupOperasional String?
|
||||
jamBukaLibur String?
|
||||
jamTutupLibur String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime @default(now())
|
||||
isActive Boolean @default(true)
|
||||
FasilitasKesehatan FasilitasKesehatan[] @relation("Dokter")
|
||||
}
|
||||
|
||||
model FasilitasPendukung {
|
||||
@@ -836,7 +868,7 @@ model TarifDanLayanan {
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime @default(now())
|
||||
isActive Boolean @default(true)
|
||||
FasilitasKesehatan FasilitasKesehatan[]
|
||||
FasilitasKesehatan FasilitasKesehatan[] @relation("Tarif")
|
||||
}
|
||||
|
||||
// ========================================= JADWAL KEGIATAN ========================================= //
|
||||
@@ -1942,23 +1974,28 @@ model KeunggulanProgram {
|
||||
}
|
||||
|
||||
model BeasiswaPendaftar {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
namaLengkap String
|
||||
nik String @unique
|
||||
nis String?
|
||||
kelas String?
|
||||
jenisKelamin JenisKelamin
|
||||
alamatDomisili String?
|
||||
tempatLahir String
|
||||
tanggalLahir DateTime
|
||||
jenisKelamin JenisKelamin
|
||||
kewarganegaraan String
|
||||
agama Agama
|
||||
alamatKTP String
|
||||
alamatDomisili String?
|
||||
namaOrtu String?
|
||||
nik String @unique
|
||||
pekerjaanOrtu String?
|
||||
penghasilan String?
|
||||
noHp String
|
||||
email String @unique
|
||||
statusPernikahan StatusPernikahan
|
||||
kewarganegaraan String?
|
||||
agama Agama?
|
||||
alamatKTP String?
|
||||
email String? @unique
|
||||
statusPernikahan StatusPernikahan?
|
||||
ukuranBaju UkuranBaju?
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
enum JenisKelamin {
|
||||
@@ -2130,25 +2167,28 @@ enum StatusPeminjaman {
|
||||
// ========================================= USER ========================================= //
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
username String
|
||||
nomor String @unique
|
||||
role Role @relation(fields: [roleId], references: [id])
|
||||
roleId String @default("1")
|
||||
instansi String?
|
||||
UserSession UserSession? // Nama instansi (Puskesmas, Sekolah, dll)
|
||||
isActive Boolean @default(true)
|
||||
lastLogin DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
id String @id @default(cuid())
|
||||
username String
|
||||
nomor String @unique
|
||||
roleId String @default("2")
|
||||
isActive Boolean @default(false)
|
||||
sessionInvalid Boolean @default(false)
|
||||
lastLogin DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
permissions Json?
|
||||
sessions UserSession[] // ✅ Relasi one-to-many
|
||||
role Role @relation(fields: [roleId], references: [id])
|
||||
menuAccesses UserMenuAccess[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Role {
|
||||
id String @id @default(cuid())
|
||||
name String @unique // ADMIN_DESA, ADMIN_KESEHATAN, ADMIN_SEKOLAH
|
||||
description String?
|
||||
permissions Json // Menyimpan permission dalam format JSON
|
||||
permissions Json?
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -2167,26 +2207,32 @@ model KodeOtp {
|
||||
otp Int
|
||||
}
|
||||
|
||||
// Tabel untuk menyimpan permission
|
||||
model Permission {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
model UserSession {
|
||||
id String @id @default(cuid())
|
||||
token String @db.Text // ✅ JWT bisa panjang
|
||||
expiresAt DateTime // ✅ Ubah jadi expiresAt (konsisten)
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
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 {
|
||||
id String @id @default(cuid())
|
||||
token String
|
||||
expires DateTime?
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
User User @relation(fields: [userId], references: [id])
|
||||
userId String @unique
|
||||
model UserMenuAccess {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
menuId String // ID menu (misal: "Landing Page", "Kesehatan")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@unique([userId, menuId]) // Satu user tidak bisa punya akses menu yang sama dua kali
|
||||
}
|
||||
|
||||
// ========================================= DATA PENDIDIKAN ========================================= //
|
||||
|
||||
180
prisma/seed.ts
180
prisma/seed.ts
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import prisma from "@/lib/prisma";
|
||||
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 tujuanProgram from "./data/pendidikan/program-pendidikan-anak/tujuan-program.json";
|
||||
import roles from "./data/user/roles.json";
|
||||
import users from "./data/user/users.json";
|
||||
import fileStorage from "./data/file-storage.json";
|
||||
import jenjangPendidikan from "./data/pendidikan/info-sekolah/jenjang-pendidikan.json";
|
||||
import seedAssets from "./seed_assets";
|
||||
import users from "./data/user/users.json";
|
||||
import { safeSeedUnique } from "./safeseedUnique";
|
||||
|
||||
(async () => {
|
||||
// =========== USER & ROLE ===========
|
||||
// In your seed.ts
|
||||
// =========== ROLES ===========
|
||||
console.log("🔄 Seeding roles...");
|
||||
|
||||
for (const r of roles) {
|
||||
await safeSeedUnique("role", { id: r.id }, {
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
permissions: r.permissions,
|
||||
isActive: r.isActive,
|
||||
});
|
||||
try {
|
||||
// ✅ Destructure to remove permissions if exists
|
||||
const { permissions, ...roleData } = r as any;
|
||||
|
||||
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");
|
||||
|
||||
// =========== USERS ===========
|
||||
// =========== USER ===========
|
||||
console.log("🔄 Seeding users...");
|
||||
for (const u of users) {
|
||||
// First verify the role exists
|
||||
const roleExists = await prisma.role.findUnique({
|
||||
where: { id: u.roleId },
|
||||
});
|
||||
|
||||
if (!roleExists) {
|
||||
console.error(`❌ Role with id ${u.roleId} not found for user ${u.nama}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await safeSeedUnique("user", { id: u.id }, {
|
||||
username: u.nama,
|
||||
nomor: u.nomor,
|
||||
roleId: u.roleId,
|
||||
isActive: u.isActive,
|
||||
try {
|
||||
// Verify role exists first
|
||||
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.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 ===========
|
||||
console.log("🔄 Seeding file storage...");
|
||||
for (const f of fileStorage) {
|
||||
await prisma.fileStorage.upsert({
|
||||
where: { id: f.id },
|
||||
update: {
|
||||
name: f.name,
|
||||
realName: f.realName,
|
||||
path: f.path,
|
||||
mimeType: f.mimeType,
|
||||
link: f.link,
|
||||
category: f.category,
|
||||
},
|
||||
create: {
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
realName: f.realName,
|
||||
path: f.path,
|
||||
mimeType: f.mimeType,
|
||||
link: f.link,
|
||||
category: f.category,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await prisma.fileStorage.upsert({
|
||||
where: { id: f.id },
|
||||
update: {
|
||||
name: f.name,
|
||||
realName: f.realName,
|
||||
path: f.path,
|
||||
mimeType: f.mimeType,
|
||||
link: f.link,
|
||||
category: f.category,
|
||||
},
|
||||
create: {
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
realName: f.realName,
|
||||
path: f.path,
|
||||
mimeType: f.mimeType,
|
||||
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");
|
||||
// =========== LANDING PAGE ===========
|
||||
@@ -539,15 +577,40 @@ import { safeSeedUnique } from "./safeseedUnique";
|
||||
console.log("posisi organisasi berhasil");
|
||||
|
||||
// =========== PEGAWAI PPID ===========
|
||||
console.log("🔄 Seeding pegawai PPID...");
|
||||
const flattenedPegawai = pegawaiPPID.flat();
|
||||
|
||||
// Check for duplicate emails
|
||||
const emails = new Set();
|
||||
for (const p of flattenedPegawai) {
|
||||
await prisma.pegawaiPPID.upsert({
|
||||
where: { id: p.id },
|
||||
update: p,
|
||||
create: p,
|
||||
});
|
||||
if (emails.has(p.email)) {
|
||||
console.warn(`⚠️ Duplicate email found in pegawaiPPID: ${p.email}`);
|
||||
}
|
||||
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 ===========
|
||||
|
||||
@@ -811,7 +874,9 @@ import { safeSeedUnique } from "./safeseedUnique";
|
||||
const flattenedPosisiBumdes = posisiOrganisasi.flat();
|
||||
|
||||
// ✅ 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) {
|
||||
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
|
||||
const kategoriKegiatan = kategoriKegiatanData.map((k, index) => ({
|
||||
...k,
|
||||
id: `kategori-${index + 1}`
|
||||
id: `kategori-${index + 1}`,
|
||||
}));
|
||||
|
||||
for (const k of kategoriKegiatan) {
|
||||
@@ -1183,7 +1248,6 @@ import { safeSeedUnique } from "./safeseedUnique";
|
||||
|
||||
// seed assets
|
||||
await seedAssets();
|
||||
|
||||
})()
|
||||
.then(() => prisma.$disconnect())
|
||||
.catch((e) => {
|
||||
|
||||
BIN
public/mangupuraaward.jpeg
Normal file
BIN
public/mangupuraaward.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 177 KiB |
76
src/app/admin/(dashboard)/_com/selectSocialMedia.tsx
Normal file
76
src/app/admin/(dashboard)/_com/selectSocialMedia.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
src/app/admin/(dashboard)/_com/selectSocialMediaEdit.tsx
Normal file
56
src/app/admin/(dashboard)/_com/selectSocialMediaEdit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -9,29 +9,30 @@ import { z } from "zod";
|
||||
// Validasi form
|
||||
const templateForm = z.object({
|
||||
name: z.string().min(1, "Nama harus diisi"),
|
||||
|
||||
informasiUmum: z.object({
|
||||
fasilitas: z.string().min(1, "Fasilitas harus diisi"),
|
||||
alamat: z.string().min(1, "Alamat harus diisi"),
|
||||
jamOperasional: z.string().min(1, "Jam operasional harus diisi"),
|
||||
fasilitas: z.string().min(1),
|
||||
alamat: z.string().min(1),
|
||||
jamOperasional: z.string().min(1),
|
||||
}),
|
||||
|
||||
layananUnggulan: z.object({
|
||||
content: z.string().min(1, "Layanan unggulan harus diisi"),
|
||||
}),
|
||||
dokterdanTenagaMedis: z.object({
|
||||
name: z.string().min(1, "Nama dokter harus diisi"),
|
||||
specialist: z.string().min(1, "Spesialis harus diisi"),
|
||||
jadwal: z.string().min(1, "Jadwal harus diisi"),
|
||||
content: z.string().min(1),
|
||||
}),
|
||||
|
||||
// NOW ARRAY OF STRING (ID)
|
||||
dokterdanTenagaMedis: z.array(z.string()).min(1, "Minimal pilih 1 dokter"),
|
||||
|
||||
fasilitasPendukung: z.object({
|
||||
content: z.string().min(1, "Fasilitas pendukung harus diisi"),
|
||||
content: z.string().min(1),
|
||||
}),
|
||||
|
||||
prosedurPendaftaran: z.object({
|
||||
content: z.string().min(1, "Prosedur pendaftaran harus diisi"),
|
||||
}),
|
||||
tarifDanLayanan: z.object({
|
||||
layanan: z.string().min(1, "Layanan harus diisi"),
|
||||
tarif: z.string().min(1, "Tarif harus diisi"),
|
||||
content: z.string().min(1),
|
||||
}),
|
||||
|
||||
// NOW ARRAY OF STRING (ID)
|
||||
tarifDanLayanan: z.array(z.string()).min(1, "Minimal pilih 1 tarif"),
|
||||
});
|
||||
|
||||
// Default form kosong
|
||||
@@ -45,21 +46,34 @@ const defaultForm = {
|
||||
layananUnggulan: {
|
||||
content: "",
|
||||
},
|
||||
dokterdanTenagaMedis: {
|
||||
name: "",
|
||||
specialist: "",
|
||||
jadwal: "",
|
||||
},
|
||||
|
||||
dokterdanTenagaMedis: [] as string[], // ← array kosong
|
||||
tarifDanLayanan: [] as string[], // ← array kosong
|
||||
|
||||
fasilitasPendukung: {
|
||||
content: "",
|
||||
},
|
||||
prosedurPendaftaran: {
|
||||
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({
|
||||
@@ -186,33 +200,26 @@ const fasilitasKesehatan = proxy({
|
||||
|
||||
const result = await res.json();
|
||||
const data = result.data;
|
||||
|
||||
fasilitasKesehatan.edit.id = data.id;
|
||||
fasilitasKesehatan.edit.form = {
|
||||
this.id = data.id;
|
||||
this.form = {
|
||||
name: data.name,
|
||||
informasiUmum: {
|
||||
fasilitas: data.informasiumum.fasilitas,
|
||||
alamat: data.informasiumum.alamat,
|
||||
jamOperasional: data.informasiumum.jamOperasional,
|
||||
},
|
||||
layananUnggulan: {
|
||||
content: data.layananunggulan.content,
|
||||
},
|
||||
dokterdanTenagaMedis: {
|
||||
name: data.dokterdantenagamedis.name,
|
||||
specialist: data.dokterdantenagamedis.specialist,
|
||||
jadwal: data.dokterdantenagamedis.jadwal,
|
||||
},
|
||||
fasilitasPendukung: {
|
||||
content: data.fasilitaspendukung.content,
|
||||
},
|
||||
prosedurPendaftaran: {
|
||||
content: data.prosedurpendaftaran.content,
|
||||
},
|
||||
tarifDanLayanan: {
|
||||
layanan: data.tarifdanlayanan.layanan,
|
||||
tarif: data.tarifdanlayanan.tarif,
|
||||
// map relasi -> array of IDs
|
||||
layananUnggulan: {
|
||||
content: data.layananunggulan.content,
|
||||
},
|
||||
dokterdanTenagaMedis: data.dokterdantenagamedis?.map((v: DokterItem) => v.id) ?? [],
|
||||
tarifDanLayanan: data.tarifdanlayanan?.map((v: TarifItem) => v.id) ?? [],
|
||||
};
|
||||
},
|
||||
async submit() {
|
||||
@@ -238,22 +245,15 @@ const fasilitasKesehatan = proxy({
|
||||
layananUnggulan: {
|
||||
content: fasilitasKesehatan.edit.form.layananUnggulan.content,
|
||||
},
|
||||
dokterdanTenagaMedis: {
|
||||
name: fasilitasKesehatan.edit.form.dokterdanTenagaMedis.name,
|
||||
specialist:
|
||||
fasilitasKesehatan.edit.form.dokterdanTenagaMedis.specialist,
|
||||
jadwal: fasilitasKesehatan.edit.form.dokterdanTenagaMedis.jadwal,
|
||||
},
|
||||
dokterdanTenagaMedis:
|
||||
fasilitasKesehatan.edit.form.dokterdanTenagaMedis,
|
||||
fasilitasPendukung: {
|
||||
content: fasilitasKesehatan.edit.form.fasilitasPendukung.content,
|
||||
},
|
||||
prosedurPendaftaran: {
|
||||
content: fasilitasKesehatan.edit.form.prosedurPendaftaran.content,
|
||||
},
|
||||
tarifDanLayanan: {
|
||||
layanan: fasilitasKesehatan.edit.form.tarifDanLayanan.layanan,
|
||||
tarif: fasilitasKesehatan.edit.form.tarifDanLayanan.tarif,
|
||||
},
|
||||
tarifDanLayanan: fasilitasKesehatan.edit.form.tarifDanLayanan,
|
||||
};
|
||||
|
||||
const res = await fetch(
|
||||
@@ -320,12 +320,26 @@ const templateDokterForm = z.object({
|
||||
name: z.string().min(1, "Nama tidak boleh kosong"),
|
||||
specialist: z.string().min(1, "Spesialis 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 = {
|
||||
name: "",
|
||||
specialist: "",
|
||||
jadwal: "",
|
||||
jadwalLibur: "",
|
||||
jamBukaOperasional: "",
|
||||
jamTutupOperasional: "",
|
||||
jamBukaLibur: "",
|
||||
jamTutupLibur: "",
|
||||
};
|
||||
|
||||
const dokter = proxy({
|
||||
@@ -463,6 +477,11 @@ const dokter = proxy({
|
||||
name: data.name,
|
||||
specialist: data.specialist,
|
||||
jadwal: data.jadwal,
|
||||
jadwalLibur: data.jadwalLibur,
|
||||
jamBukaOperasional: data.jamBukaOperasional,
|
||||
jamTutupOperasional: data.jamTutupOperasional,
|
||||
jamBukaLibur: data.jamBukaLibur,
|
||||
jamTutupLibur: data.jamTutupLibur,
|
||||
};
|
||||
return data; // Return the loaded data
|
||||
} else {
|
||||
@@ -487,6 +506,11 @@ const dokter = proxy({
|
||||
name: this.form.name,
|
||||
specialist: this.form.specialist,
|
||||
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);
|
||||
@@ -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({
|
||||
fasilitasKesehatan,
|
||||
dokter,
|
||||
tarif
|
||||
});
|
||||
|
||||
export default fasilitasKesehatanState;
|
||||
|
||||
@@ -5,58 +5,117 @@ import { toast } from "react-toastify";
|
||||
import { proxy } from "valtio";
|
||||
import { z } from "zod";
|
||||
|
||||
const templateapbDesaForm = z.object({
|
||||
name: z.string().min(1, "Judul minimal 1 karakter"),
|
||||
jumlah: z.string().min(1, "Deskripsi minimal 1 karakter"),
|
||||
imageId: z.string().min(1, "File minimal 1"),
|
||||
fileId: z.string().min(1, "File minimal 1"),
|
||||
// --- Zod Schema ---
|
||||
const ApbdesItemSchema = z.object({
|
||||
kode: z.string().min(1, "Kode wajib diisi"),
|
||||
uraian: z.string().min(1, "Uraian wajib diisi"),
|
||||
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 = {
|
||||
name: "",
|
||||
jumlah: "",
|
||||
const ApbdesFormSchema = z.object({
|
||||
tahun: z.number().int().min(2000, "Tahun tidak valid"),
|
||||
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: "",
|
||||
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({
|
||||
create: {
|
||||
form: { ...defaultapbdesForm },
|
||||
form: { ...defaultApbdesForm },
|
||||
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();
|
||||
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) {
|
||||
console.log(error);
|
||||
toast.error("Gagal menambahkan data");
|
||||
} catch (error: any) {
|
||||
console.error("Create APBDes error:", error);
|
||||
toast.error(error?.message || "Terjadi kesalahan saat membuat APBDes");
|
||||
} finally {
|
||||
apbdes.create.loading = false;
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
findMany: {
|
||||
data: null as
|
||||
| Prisma.APBDesGetPayload<{
|
||||
include: {
|
||||
image: true;
|
||||
file: true;
|
||||
};
|
||||
include: { image: true; file: true; items: true };
|
||||
}>[]
|
||||
| null,
|
||||
page: 1,
|
||||
@@ -64,194 +123,202 @@ const apbdes = proxy({
|
||||
total: 0,
|
||||
loading: false,
|
||||
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.search = search;
|
||||
|
||||
try {
|
||||
const query: any = { page, limit };
|
||||
const query: Record<string, string> = { page: String(page), limit: String(limit) };
|
||||
if (search) query.search = search;
|
||||
|
||||
const res = await ApiFetch.api.landingpage.apbdes[
|
||||
"findMany"
|
||||
].get({
|
||||
query
|
||||
});
|
||||
|
||||
if (res.status === 200 && res.data?.success) {
|
||||
|
||||
const res = await ApiFetch.api.landingpage.apbdes["findMany"].get({ query });
|
||||
|
||||
if (res.data?.success) {
|
||||
apbdes.findMany.data = res.data.data || [];
|
||||
apbdes.findMany.total = res.data.total || 0;
|
||||
apbdes.findMany.totalPages = res.data.totalPages || 1;
|
||||
apbdes.findMany.total = res.data.meta?.total || 0;
|
||||
apbdes.findMany.totalPages = res.data.meta?.totalPages || 1;
|
||||
} else {
|
||||
console.error("Failed to load pegawai:", res.data?.message);
|
||||
apbdes.findMany.data = [];
|
||||
apbdes.findMany.total = 0;
|
||||
apbdes.findMany.totalPages = 1;
|
||||
toast.error(res.data?.message || "Gagal memuat data");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading pegawai:", error);
|
||||
console.error("FindMany error:", error);
|
||||
apbdes.findMany.data = [];
|
||||
apbdes.findMany.total = 0;
|
||||
apbdes.findMany.totalPages = 1;
|
||||
toast.error("Gagal memuat daftar APBDes");
|
||||
} finally {
|
||||
apbdes.findMany.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
findUnique: {
|
||||
data: null as Prisma.APBDesGetPayload<{
|
||||
include: {
|
||||
image: true;
|
||||
file: true;
|
||||
};
|
||||
}> | null,
|
||||
data: null as
|
||||
| Prisma.APBDesGetPayload<{
|
||||
include: { image: true; file: true; items: true };
|
||||
}>
|
||||
| null,
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
|
||||
async load(id: string) {
|
||||
if (!id || id.trim() === '') {
|
||||
this.data = null;
|
||||
this.error = "ID tidak valid";
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/landingpage/apbdes/${id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
apbdes.findUnique.data = data.data ?? null;
|
||||
// Pastikan URL-nya benar
|
||||
const url = `/api/landingpage/apbdes/${id}`;
|
||||
console.log("🌐 Fetching:", url);
|
||||
|
||||
// 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 {
|
||||
console.error("Failed to fetch data", res.status, res.statusText);
|
||||
apbdes.findUnique.data = null;
|
||||
this.data = null;
|
||||
this.error = res.message || "Gagal memuat detail APBDes";
|
||||
toast.error(this.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
apbdes.findUnique.data = null;
|
||||
console.error("❌ FindUnique error:", error);
|
||||
this.data = null;
|
||||
this.error = "Gagal memuat detail APBDes";
|
||||
toast.error(this.error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
delete: {
|
||||
loading: false,
|
||||
async byId(id: string) {
|
||||
if (!id) return toast.warn("ID tidak valid");
|
||||
|
||||
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}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result?.success) {
|
||||
toast.success(result.message || "apbdes berhasil dihapus");
|
||||
await apbdes.findMany.load(); // refresh list
|
||||
if (res.data?.success) {
|
||||
toast.success("APBDes berhasil dihapus");
|
||||
apbdes.findMany.load();
|
||||
} else {
|
||||
toast.error(result?.message || "Gagal menghapus apbdes");
|
||||
toast.error(res.data?.message || "Gagal menghapus APBDes");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Gagal delete:", error);
|
||||
toast.error("Terjadi kesalahan saat menghapus apbdes");
|
||||
} catch (error: any) {
|
||||
console.error("Delete error:", error);
|
||||
toast.error(error?.message || "Terjadi kesalahan saat menghapus");
|
||||
} finally {
|
||||
apbdes.delete.loading = false;
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
edit: {
|
||||
id: "",
|
||||
form: { ...defaultapbdesForm },
|
||||
form: { ...defaultApbdesForm },
|
||||
loading: false,
|
||||
|
||||
async load(id: string) {
|
||||
if (!id) {
|
||||
toast.warn("ID tidak valid");
|
||||
return null;
|
||||
}
|
||||
if (!id) return toast.warn("ID tidak valid");
|
||||
|
||||
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}`, {
|
||||
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;
|
||||
if (res.data?.success) {
|
||||
const data = res.data.data;
|
||||
this.id = data.id;
|
||||
this.form = {
|
||||
name: data.name,
|
||||
jumlah: data.jumlah,
|
||||
imageId: data.imageId,
|
||||
fileId: data.fileId,
|
||||
tahun: data.tahun || new Date().getFullYear(),
|
||||
imageId: data.imageId || "",
|
||||
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;
|
||||
} else {
|
||||
throw new Error(result?.message || "Gagal memuat data");
|
||||
throw new Error(res.data?.message || "Gagal memuat data");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading apbdes:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Gagal memuat data"
|
||||
);
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
console.error("Edit load error:", error);
|
||||
toast.error(error.message || "Gagal memuat data untuk diedit");
|
||||
} finally {
|
||||
apbdes.edit.loading = false;
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async update() {
|
||||
const cek = templateapbDesaForm.safeParse(apbdes.edit.form);
|
||||
if (!cek.success) {
|
||||
const err = `[${cek.error.issues
|
||||
.map((v) => `${v.path.join(".")}`)
|
||||
.join("\n")}] required`;
|
||||
return toast.error(err);
|
||||
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 false;
|
||||
}
|
||||
|
||||
try {
|
||||
apbdes.edit.loading = true;
|
||||
const response = await fetch(`/api/landingpage/apbdes/${this.id}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: this.form.name,
|
||||
jumlah: this.form.jumlah,
|
||||
imageId: this.form.imageId,
|
||||
fileId: this.form.fileId,
|
||||
}),
|
||||
});
|
||||
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
|
||||
this.loading = true;
|
||||
// Include the ID in the request body
|
||||
const requestData = {
|
||||
...parsed.data,
|
||||
id: this.id, // Add the ID to the request body
|
||||
};
|
||||
|
||||
const res = await (ApiFetch.api.landingpage.apbdes as any)[this.id].put(requestData);
|
||||
|
||||
if (res.data?.success) {
|
||||
toast.success("APBDes berhasil diperbarui");
|
||||
apbdes.findMany.load();
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(result.message || "Gagal mengupdate apbdes");
|
||||
throw new Error(res.data?.message || "Gagal memperbarui APBDes");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating apbdes:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Gagal mengupdate apbdes"
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error("Update error:", error);
|
||||
toast.error(error.message || "Gagal memperbarui APBDes");
|
||||
return false;
|
||||
} 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() {
|
||||
apbdes.edit.id = "";
|
||||
apbdes.edit.form = { ...defaultapbdesForm };
|
||||
this.id = "";
|
||||
this.form = { ...defaultApbdesForm };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default apbdes;
|
||||
export default apbdes;
|
||||
@@ -27,7 +27,7 @@ const programInovasi = proxy({
|
||||
name: "",
|
||||
description: "",
|
||||
imageId: "",
|
||||
link: ""
|
||||
link: "",
|
||||
} as ProgramInovasiForm,
|
||||
loading: false,
|
||||
async create() {
|
||||
@@ -71,20 +71,21 @@ const programInovasi = proxy({
|
||||
total: 0,
|
||||
loading: false,
|
||||
search: "",
|
||||
load: async (page = 1, limit = 10, search = "") => { // Change to arrow function
|
||||
programInovasi.findMany.loading = true; // Use the full path to access the property
|
||||
load: async (page = 1, limit = 10, search = "") => {
|
||||
// Change to arrow function
|
||||
programInovasi.findMany.loading = true; // Use the full path to access the property
|
||||
programInovasi.findMany.page = page;
|
||||
programInovasi.findMany.search = search;
|
||||
try {
|
||||
const query: any = { page, limit };
|
||||
if (search) query.search = search;
|
||||
|
||||
|
||||
const res = await ApiFetch.api.landingpage.programinovasi[
|
||||
"findMany"
|
||||
].get({
|
||||
query
|
||||
query,
|
||||
});
|
||||
|
||||
|
||||
if (res.status === 200 && res.data?.success) {
|
||||
programInovasi.findMany.data = res.data.data || [];
|
||||
programInovasi.findMany.total = res.data.total || 0;
|
||||
@@ -389,7 +390,10 @@ const pejabatDesa = proxy({
|
||||
|
||||
try {
|
||||
// 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(), {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
@@ -438,16 +442,19 @@ const pejabatDesa = proxy({
|
||||
|
||||
const templateMediaSosial = z.object({
|
||||
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"),
|
||||
icon: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
type MediaSosialForm = {
|
||||
name: string;
|
||||
imageId: string;
|
||||
imageId: string | null; // boleh null
|
||||
iconUrl: string;
|
||||
icon: string | null; // boleh null
|
||||
};
|
||||
|
||||
|
||||
const mediaSosial = proxy({
|
||||
create: {
|
||||
form: {} as MediaSosialForm,
|
||||
@@ -455,9 +462,10 @@ const mediaSosial = proxy({
|
||||
async create() {
|
||||
// Ensure all required fields are non-null
|
||||
const formData = {
|
||||
name: mediaSosial.create.form.name || "",
|
||||
imageId: mediaSosial.create.form.imageId || "",
|
||||
iconUrl: mediaSosial.create.form.iconUrl || "",
|
||||
name: mediaSosial.create.form.name ?? "",
|
||||
imageId: mediaSosial.create.form.imageId ?? null, // FIXED
|
||||
iconUrl: mediaSosial.create.form.iconUrl ?? "",
|
||||
icon: mediaSosial.create.form.icon ?? null, // FIXED
|
||||
};
|
||||
|
||||
const cek = templateMediaSosial.safeParse(formData);
|
||||
@@ -492,20 +500,19 @@ const mediaSosial = proxy({
|
||||
total: 0,
|
||||
loading: false,
|
||||
search: "",
|
||||
load: async (page = 1, limit = 10, search = "") => { // Change to arrow function
|
||||
mediaSosial.findMany.loading = true; // Use the full path to access the property
|
||||
load: async (page = 1, limit = 10, search = "") => {
|
||||
// Change to arrow function
|
||||
mediaSosial.findMany.loading = true; // Use the full path to access the property
|
||||
mediaSosial.findMany.page = page;
|
||||
mediaSosial.findMany.search = search;
|
||||
try {
|
||||
try {
|
||||
const query: any = { page, limit };
|
||||
if (search) query.search = search;
|
||||
|
||||
const res = await ApiFetch.api.landingpage.mediasosial[
|
||||
"findMany"
|
||||
].get({
|
||||
|
||||
const res = await ApiFetch.api.landingpage.mediasosial["findMany"].get({
|
||||
query,
|
||||
});
|
||||
|
||||
|
||||
if (res.status === 200 && res.data?.success) {
|
||||
mediaSosial.findMany.data = res.data.data || [];
|
||||
mediaSosial.findMany.total = res.data.total || 0;
|
||||
@@ -537,7 +544,7 @@ const mediaSosial = proxy({
|
||||
toast.warn("ID tidak valid");
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
mediaSosial.update.loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/landingpage/mediasosial/${id}`);
|
||||
@@ -586,66 +593,72 @@ const mediaSosial = proxy({
|
||||
},
|
||||
},
|
||||
update: {
|
||||
id: "",
|
||||
form: {} as MediaSosialForm,
|
||||
loading: false,
|
||||
|
||||
async load(id: string) {
|
||||
if (!id) {
|
||||
toast.warn("ID tidak valid");
|
||||
return null;
|
||||
id: "",
|
||||
form: {} as MediaSosialForm,
|
||||
loading: false,
|
||||
|
||||
async load(id: string) {
|
||||
if (!id) {
|
||||
toast.warn("ID tidak valid");
|
||||
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
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/landingpage/mediasosial/${id}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
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
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result?.success) {
|
||||
const data = result.data;
|
||||
this.id = data.id;
|
||||
this.form = {
|
||||
name: data.name || "",
|
||||
imageId: data.imageId || null,
|
||||
iconUrl: data.iconUrl || "",
|
||||
icon: data.icon || null,
|
||||
|
||||
};
|
||||
return data;
|
||||
} else {
|
||||
throw new Error(
|
||||
result?.message || "Gagal mengambil data media sosial"
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async update() {
|
||||
const cek = templateMediaSosial.safeParse(mediaSosial.update.form);
|
||||
if (!cek.success) {
|
||||
const err = `[${cek.error.issues
|
||||
.map((v) => `${v.path.join(".")}`)
|
||||
.join("\n")}] required`;
|
||||
toast.error(err);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
mediaSosial.update.loading = true;
|
||||
|
||||
const response = await fetch(`/api/landingpage/mediasosial/${this.id}`, {
|
||||
} 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
|
||||
}
|
||||
},
|
||||
|
||||
async update() {
|
||||
const cek = templateMediaSosial.safeParse(mediaSosial.update.form);
|
||||
if (!cek.success) {
|
||||
const err = `[${cek.error.issues
|
||||
.map((v) => `${v.path.join(".")}`)
|
||||
.join("\n")}] required`;
|
||||
toast.error(err);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
mediaSosial.update.loading = true;
|
||||
|
||||
const response = await fetch(
|
||||
`/api/landingpage/mediasosial/${this.id}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -654,38 +667,40 @@ const mediaSosial = proxy({
|
||||
name: this.form.name,
|
||||
imageId: this.form.imageId,
|
||||
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 (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"
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
errorData.message || `HTTP error! status: ${response.status}`
|
||||
);
|
||||
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({
|
||||
|
||||
@@ -9,34 +9,32 @@ import { z } from "zod";
|
||||
|
||||
const templateBeasiswaPendaftar = z.object({
|
||||
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"),
|
||||
tanggalLahir: z.string().min(1, "Tanggal lahir harus diisi"),
|
||||
jenisKelamin: z.string().min(1, "Jenis kelamin harus diisi"),
|
||||
kewarganegaraan: z.string().min(1, "Kewarganegaraan harus diisi"),
|
||||
agama: z.string().min(1, "Agama harus diisi"),
|
||||
alamatKTP: z.string().min(1, "Alamat KTP harus diisi"),
|
||||
alamatDomisili: z.string().min(1, "Alamat domisili harus diisi"),
|
||||
namaOrtu: z.string().min(1, "Nama ortu harus diisi"),
|
||||
nik: z.string().min(1, "NIK harus diisi"),
|
||||
pekerjaanOrtu: z.string().min(1, "Pekerjaan ortu harus diisi"),
|
||||
penghasilan: z.string().min(1, "Penghasilan ortu 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 = {
|
||||
namaLengkap: "",
|
||||
nik: "",
|
||||
nis: "",
|
||||
kelas: "",
|
||||
jenisKelamin: "",
|
||||
alamatDomisili: "",
|
||||
tempatLahir: "",
|
||||
tanggalLahir: "",
|
||||
jenisKelamin: "",
|
||||
kewarganegaraan: "",
|
||||
agama: "",
|
||||
alamatKTP: "",
|
||||
alamatDomisili: "",
|
||||
namaOrtu: "",
|
||||
nik: "",
|
||||
pekerjaanOrtu: "",
|
||||
penghasilan: "",
|
||||
noHp: "",
|
||||
email: "",
|
||||
statusPernikahan: "",
|
||||
ukuranBaju: "",
|
||||
};
|
||||
|
||||
const beasiswaPendaftar = proxy({
|
||||
@@ -200,18 +198,17 @@ const beasiswaPendaftar = proxy({
|
||||
this.id = data.id;
|
||||
this.form = {
|
||||
namaLengkap: data.namaLengkap,
|
||||
nik: data.nik,
|
||||
nis: data.nis,
|
||||
kelas: data.kelas,
|
||||
jenisKelamin: data.jenisKelamin,
|
||||
alamatDomisili: data.alamatDomisili,
|
||||
tempatLahir: data.tempatLahir,
|
||||
tanggalLahir: data.tanggalLahir,
|
||||
jenisKelamin: data.jenisKelamin,
|
||||
kewarganegaraan: data.kewarganegaraan,
|
||||
agama: data.agama,
|
||||
alamatKTP: data.alamatKTP,
|
||||
alamatDomisili: data.alamatDomisili,
|
||||
namaOrtu: data.namaOrtu,
|
||||
nik: data.nik,
|
||||
pekerjaanOrtu: data.pekerjaanOrtu,
|
||||
penghasilan: data.penghasilan,
|
||||
noHp: data.noHp,
|
||||
email: data.email,
|
||||
statusPernikahan: data.statusPernikahan,
|
||||
ukuranBaju: data.ukuranBaju,
|
||||
};
|
||||
return data; // Return the loaded data
|
||||
} else {
|
||||
@@ -249,17 +246,17 @@ const beasiswaPendaftar = proxy({
|
||||
},
|
||||
body: JSON.stringify({
|
||||
namaLengkap: this.form.namaLengkap,
|
||||
nik: this.form.nik,
|
||||
tanggalLahir: this.form.tanggalLahir,
|
||||
nis: this.form.nis,
|
||||
kelas: this.form.kelas,
|
||||
jenisKelamin: this.form.jenisKelamin,
|
||||
kewarganegaraan: this.form.kewarganegaraan,
|
||||
agama: this.form.agama,
|
||||
alamatKTP: this.form.alamatKTP,
|
||||
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,
|
||||
email: this.form.email,
|
||||
statusPernikahan: this.form.statusPernikahan,
|
||||
ukuranBaju: this.form.ukuranBaju,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -6,145 +6,176 @@ import { z } from "zod";
|
||||
|
||||
const templateForm = z.object({
|
||||
name: z.string().min(3, "Nama minimal 3 karakter"),
|
||||
nik: z.string().min(3, "NIK minimal 3 karakter"),
|
||||
notelp: z.string().min(3, "Nomor Telepon minimal 3 karakter"),
|
||||
nik: z
|
||||
.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"),
|
||||
email: z.string().min(3, "Email minimal 3 karakter"),
|
||||
jenisInformasiDimintaId: z.string().nonempty(),
|
||||
caraMemperolehInformasiId: z.string().nonempty(),
|
||||
caraMemperolehSalinanInformasiId: z.string().nonempty(),
|
||||
})
|
||||
});
|
||||
|
||||
const jenisInformasiDiminta = proxy({
|
||||
findMany: {
|
||||
data: null as
|
||||
| null
|
||||
| Prisma.JenisInformasiDimintaGetPayload<{ omit: { isActive: true } }>[],
|
||||
async load(){
|
||||
const res = await ApiFetch.api.ppid.permohonaninformasipublik.jenisInformasi["find-many"].get();
|
||||
if (res.status === 200) {
|
||||
jenisInformasiDiminta.findMany.data = res.data?.data ?? [];
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
findMany: {
|
||||
data: null as
|
||||
| null
|
||||
| Prisma.JenisInformasiDimintaGetPayload<{ omit: { isActive: true } }>[],
|
||||
async load() {
|
||||
const res =
|
||||
await ApiFetch.api.ppid.permohonaninformasipublik.jenisInformasi[
|
||||
"find-many"
|
||||
].get();
|
||||
if (res.status === 200) {
|
||||
jenisInformasiDiminta.findMany.data = res.data?.data ?? [];
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const caraMemperolehInformasi = proxy({
|
||||
findMany: {
|
||||
data: null as
|
||||
| null
|
||||
| Prisma.CaraMemperolehInformasiGetPayload<{ omit: { isActive: true } }>[],
|
||||
async load() {
|
||||
const res = await ApiFetch.api.ppid.permohonaninformasipublik.memperolehInformasi["find-many"].get();
|
||||
if (res.status === 200) {
|
||||
caraMemperolehInformasi.findMany.data = res.data?.data ?? [];
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
findMany: {
|
||||
data: null as
|
||||
| null
|
||||
| Prisma.CaraMemperolehInformasiGetPayload<{
|
||||
omit: { isActive: true };
|
||||
}>[],
|
||||
async load() {
|
||||
const res =
|
||||
await ApiFetch.api.ppid.permohonaninformasipublik.memperolehInformasi[
|
||||
"find-many"
|
||||
].get();
|
||||
if (res.status === 200) {
|
||||
caraMemperolehInformasi.findMany.data = res.data?.data ?? [];
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const caraMemperolehSalinanInformasi = proxy({
|
||||
findMany: {
|
||||
data: null as
|
||||
| null
|
||||
| Prisma.CaraMemperolehSalinanInformasiGetPayload<{ omit: { isActive: true } }>[],
|
||||
async load() {
|
||||
const res = await ApiFetch.api.ppid.permohonaninformasipublik.salinanInformasi["find-many"].get();
|
||||
if (res.status === 200) {
|
||||
caraMemperolehSalinanInformasi.findMany.data = res.data?.data ?? [];
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
console.log(caraMemperolehSalinanInformasi)
|
||||
findMany: {
|
||||
data: null as
|
||||
| null
|
||||
| Prisma.CaraMemperolehSalinanInformasiGetPayload<{
|
||||
omit: { isActive: true };
|
||||
}>[],
|
||||
async load() {
|
||||
const res =
|
||||
await ApiFetch.api.ppid.permohonaninformasipublik.salinanInformasi[
|
||||
"find-many"
|
||||
].get();
|
||||
if (res.status === 200) {
|
||||
caraMemperolehSalinanInformasi.findMany.data = res.data?.data ?? [];
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log(caraMemperolehSalinanInformasi);
|
||||
|
||||
type PermohonanInformasiPublikForm = Prisma.PermohonanInformasiPublikGetPayload<{
|
||||
type PermohonanInformasiPublikForm =
|
||||
Prisma.PermohonanInformasiPublikGetPayload<{
|
||||
select: {
|
||||
name: true;
|
||||
nik: true;
|
||||
notelp: true;
|
||||
alamat: true;
|
||||
email: true;
|
||||
jenisInformasiDimintaId: true;
|
||||
caraMemperolehInformasiId: true;
|
||||
caraMemperolehSalinanInformasiId: true;
|
||||
name: true;
|
||||
nik: true;
|
||||
notelp: true;
|
||||
alamat: true;
|
||||
email: true;
|
||||
jenisInformasiDimintaId: true;
|
||||
caraMemperolehInformasiId: true;
|
||||
caraMemperolehSalinanInformasiId: true;
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
|
||||
const statepermohonanInformasiPublik = proxy({
|
||||
create: {
|
||||
form: {} as PermohonanInformasiPublikForm,
|
||||
loading: false,
|
||||
async create(){
|
||||
const cek = templateForm.safeParse(statepermohonanInformasiPublik.create.form);
|
||||
if(!cek.success) {
|
||||
const err = `[${cek.error.issues
|
||||
.map((v) => `${v.path.join(".")}`)
|
||||
.join("\n")}] required`;
|
||||
return toast.error(err);
|
||||
}
|
||||
try {
|
||||
statepermohonanInformasiPublik.create.loading = true;
|
||||
const res = await ApiFetch.api.ppid.permohonaninformasipublik["create"].post(statepermohonanInformasiPublik.create.form);
|
||||
if (res.status === 200) {
|
||||
statepermohonanInformasiPublik.findMany.load();
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
return toast.error("failed create");
|
||||
} catch (error) {
|
||||
console.log((error as Error).message);
|
||||
} finally {
|
||||
statepermohonanInformasiPublik.create.loading = false;
|
||||
}
|
||||
create: {
|
||||
form: {} as PermohonanInformasiPublikForm,
|
||||
loading: false,
|
||||
async create() {
|
||||
const cek = templateForm.safeParse(
|
||||
statepermohonanInformasiPublik.create.form
|
||||
);
|
||||
|
||||
if (!cek.success) {
|
||||
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);
|
||||
|
||||
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 {
|
||||
statepermohonanInformasiPublik.create.loading = false;
|
||||
}
|
||||
},
|
||||
findMany: {
|
||||
data: null as
|
||||
| Prisma.PermohonanInformasiPublikGetPayload<{ include: {
|
||||
caraMemperolehSalinanInformasi: true,
|
||||
jenisInformasiDiminta: true,
|
||||
caraMemperolehInformasi: true,
|
||||
} }>[]
|
||||
| null,
|
||||
async load() {
|
||||
const res = await ApiFetch.api.ppid.permohonaninformasipublik["find-many"].get();
|
||||
if (res.status === 200) {
|
||||
statepermohonanInformasiPublik.findMany.data = res.data?.data ?? [];
|
||||
}
|
||||
}
|
||||
},
|
||||
findUnique: {
|
||||
data: null as Prisma.PermohonanInformasiPublikGetPayload<{
|
||||
},
|
||||
findMany: {
|
||||
data: null as
|
||||
| Prisma.PermohonanInformasiPublikGetPayload<{
|
||||
include: {
|
||||
jenisInformasiDiminta: true,
|
||||
caraMemperolehInformasi: true,
|
||||
caraMemperolehSalinanInformasi: true,
|
||||
caraMemperolehSalinanInformasi: true;
|
||||
jenisInformasiDiminta: true;
|
||||
caraMemperolehInformasi: 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;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
})
|
||||
}>[]
|
||||
| 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: {
|
||||
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({
|
||||
statepermohonanInformasiPublik,
|
||||
jenisInformasiDiminta,
|
||||
caraMemperolehInformasi,
|
||||
caraMemperolehSalinanInformasi,
|
||||
})
|
||||
statepermohonanInformasiPublik,
|
||||
jenisInformasiDiminta,
|
||||
caraMemperolehInformasi,
|
||||
caraMemperolehSalinanInformasi,
|
||||
});
|
||||
|
||||
export default statepermohonanInformasiPublikForm;
|
||||
|
||||
@@ -5,82 +5,99 @@ import { proxy } from "valtio";
|
||||
import { z } from "zod";
|
||||
|
||||
const templateForm = z.object({
|
||||
name: z.string().min(3, "Nama minimal 3 karakter"),
|
||||
email: z.string().min(3, "Email minimal 3 karakter"),
|
||||
notelp: z.string().min(3, "Nomor Telepon minimal 3 karakter"),
|
||||
alasan: z.string().min(3, "Alasan minimal 3 karakter"),
|
||||
})
|
||||
name: z.string().min(3, "Nama minimal 3 karakter"),
|
||||
email: z.string().min(3, "Email minimal 3 karakter"),
|
||||
notelp: z
|
||||
.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: {
|
||||
name: true;
|
||||
email: true;
|
||||
notelp: true;
|
||||
alasan: true;
|
||||
name: true;
|
||||
email: true;
|
||||
notelp: true;
|
||||
alasan: true;
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
|
||||
const permohonanKeberatanInformasi = proxy({
|
||||
create: {
|
||||
form: {} as PermohonanKeberatanInformasiForm,
|
||||
loading: false,
|
||||
async create(){
|
||||
const cek = templateForm.safeParse(permohonanKeberatanInformasi.create.form);
|
||||
if(!cek.success) {
|
||||
const err = `[${cek.error.issues
|
||||
.map((v) => `${v.path.join(".")}`)
|
||||
.join("\n")}] required`;
|
||||
return toast.error(err);
|
||||
}
|
||||
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;
|
||||
}
|
||||
},
|
||||
create: {
|
||||
form: {} as PermohonanKeberatanInformasiForm,
|
||||
loading: false,
|
||||
async create() {
|
||||
const cek = templateForm.safeParse(
|
||||
permohonanKeberatanInformasi.create.form
|
||||
);
|
||||
if (!cek.success) {
|
||||
toast.error(cek.error.issues.map((i) => i.message).join("\n"));
|
||||
return false; // ⬅️ tambahkan return false
|
||||
}
|
||||
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;
|
||||
|
||||
|
||||
@@ -90,42 +90,96 @@ const userState = proxy({
|
||||
}
|
||||
},
|
||||
},
|
||||
updateActive: {
|
||||
deleteUser: {
|
||||
loading: false,
|
||||
async submit(id: string, isActive: boolean) {
|
||||
this.loading = true;
|
||||
|
||||
async delete(id: string) {
|
||||
if (!id) return toast.warn("ID tidak valid");
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/user/updt`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, isActive }),
|
||||
userState.deleteUser.loading = true;
|
||||
|
||||
const response = await fetch(`/api/user/delUser/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.status === 200 && data.success) {
|
||||
toast.success(data.message);
|
||||
userState.findMany.load(userState.findMany.page, 10, userState.findMany.search);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result?.success) {
|
||||
toast.success(result.message || "User berhasil dihapus permanen");
|
||||
await userState.findMany.load(); // refresh list user setelah delete
|
||||
} else {
|
||||
toast.error(data.message || "Gagal update status user");
|
||||
toast.error(result?.message || "Gagal menghapus user");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast.error("Gagal update status user");
|
||||
} catch (error) {
|
||||
console.error("Gagal delete user:", error);
|
||||
toast.error("Terjadi kesalahan saat menghapus user");
|
||||
} 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({
|
||||
name: z.string().min(1, "Nama harus diisi"),
|
||||
permissions: z.array(z.string()).min(1, "Permission harus diisi"),
|
||||
});
|
||||
|
||||
const defaultRole = {
|
||||
name: "",
|
||||
permissions: [] as string[],
|
||||
};
|
||||
|
||||
const roleState = proxy({
|
||||
@@ -237,7 +291,7 @@ const roleState = proxy({
|
||||
toast.warn("ID tidak valid");
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/role/${id}`, {
|
||||
method: "GET",
|
||||
@@ -245,31 +299,25 @@ const roleState = proxy({
|
||||
"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 = {
|
||||
|
||||
// langsung set melalui root state, bukan this
|
||||
roleState.update.id = data.id;
|
||||
roleState.update.form = {
|
||||
name: data.name,
|
||||
permissions: data.permissions,
|
||||
};
|
||||
return data; // Return the loaded data
|
||||
} else {
|
||||
throw new Error(result?.message || "Gagal memuat data");
|
||||
|
||||
return data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading role:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Gagal memuat data"
|
||||
);
|
||||
return null;
|
||||
toast.error("Gagal memuat data");
|
||||
}
|
||||
},
|
||||
},
|
||||
async update() {
|
||||
const cek = templateRole.safeParse(roleState.update.form);
|
||||
if (!cek.success) {
|
||||
@@ -290,7 +338,6 @@ const roleState = proxy({
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: this.form.name,
|
||||
permissions: this.form.permissions,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,104 +1,103 @@
|
||||
'use client'
|
||||
import { apiFetchLogin } from '@/app/admin/auth/_lib/api_fetch_auth';
|
||||
'use client';
|
||||
import { apiFetchLogin } from '@/app/api/auth/_lib/api_fetch_auth';
|
||||
import colors from '@/con/colors';
|
||||
import { Box, Button, Center, Flex, Image, Paper, Stack, Text, Title } from '@mantine/core';
|
||||
import Link from 'next/link';
|
||||
import { Box, Button, Center, Image, Paper, Stack, Title } from '@mantine/core';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { PhoneInput } from "react-international-phone";
|
||||
import "react-international-phone/style.css";
|
||||
import { PhoneInput } from 'react-international-phone';
|
||||
import 'react-international-phone/style.css';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
|
||||
|
||||
function Login() {
|
||||
const router = useRouter()
|
||||
const [phone, setPhone] = useState("")
|
||||
const [isError, setError] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const router = useRouter();
|
||||
const [phone, setPhone] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Login.tsx
|
||||
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 {
|
||||
setLoading(true);
|
||||
const response = await apiFetchLogin({ nomor: nomor })
|
||||
if (response && response.success) {
|
||||
localStorage.setItem("hipmi_auth_code_id", response.kodeId);
|
||||
toast.success(response.message);
|
||||
router.push("/validasi", { scroll: false });
|
||||
const response = await apiFetchLogin({ nomor: cleanPhone });
|
||||
|
||||
console.log(response);
|
||||
|
||||
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 {
|
||||
setLoading(false);
|
||||
toast.error(response?.message);
|
||||
// ❌ User baru: langsung ke registrasi (tanpa kodeId)
|
||||
router.push('/registrasi');
|
||||
}
|
||||
} catch (error) {
|
||||
setLoading(false)
|
||||
console.log("Error Login", error)
|
||||
toast.error("Terjadi kesalahan saat login")
|
||||
console.error('Error Login:', error);
|
||||
toast.error('Terjadi kesalahan saat login');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack pos={"relative"} bg={colors.Bg}>
|
||||
<Stack pos="relative" bg={colors.Bg}>
|
||||
<Box px={{ base: 'md', md: 100 }} pb={50}>
|
||||
<Stack align='center' justify='center' h={"100vh"}>
|
||||
<Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}>
|
||||
<Stack align='center' gap={"lg"}>
|
||||
<Stack align="center" justify="center" h="100vh">
|
||||
<Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '100%', sm: 400 }}>
|
||||
<Stack align="center" gap="lg">
|
||||
<Box>
|
||||
<Title ta={"center"} order={2} fw={'bold'} c={colors['blue-button']}>
|
||||
<Title ta="center" order={2} fw="bold" c={colors['blue-button']}>
|
||||
Login
|
||||
</Title>
|
||||
<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>
|
||||
</Box>
|
||||
<Box>
|
||||
{/* <Box mb={10}>
|
||||
<Text c={colors['blue-button']} ta={"center"} fz={"sm"} fw={'bold'}>Masuk Untuk Akses Admin</Text>
|
||||
<TextInput
|
||||
label='Username'
|
||||
placeholder='Username'
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</Box> */}
|
||||
<Box w="100%">
|
||||
<PhoneInput
|
||||
countrySelectorStyleProps={{
|
||||
buttonStyle: {
|
||||
backgroundColor: colors['blue-button'],
|
||||
},
|
||||
}}
|
||||
inputStyle={{ width: "100%"}}
|
||||
inputStyle={{ width: '100%' }}
|
||||
defaultCountry="id"
|
||||
onChange={(val) => {
|
||||
setPhone(val);
|
||||
}}
|
||||
value={phone}
|
||||
onChange={(val) => setPhone(val)}
|
||||
/>
|
||||
|
||||
{isError ? (
|
||||
toast.error("Masukan nomor telepon anda")
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<Box py={20} >
|
||||
<Box py={20}>
|
||||
<Button
|
||||
fullWidth
|
||||
bg={colors['blue-button']}
|
||||
radius={'xl'}
|
||||
radius="xl"
|
||||
onClick={onLogin}
|
||||
loading={loading ? true : false}
|
||||
>Masuk
|
||||
loading={loading}
|
||||
>
|
||||
Masuk
|
||||
</Button>
|
||||
</Box>
|
||||
<Flex justify={'center'} align={'center'}>
|
||||
<Text>Belum punya akun? </Text>
|
||||
<Button variant='transparent' component={Link} href={'/registrasi'}>
|
||||
<Text c={colors['blue-button']} fw={'bold'}>Registrasi</Text>
|
||||
</Button>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
@@ -108,4 +107,4 @@ function Login() {
|
||||
);
|
||||
}
|
||||
|
||||
export default Login;
|
||||
export default Login;
|
||||
@@ -1,113 +1,153 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-expressions */
|
||||
'use client'
|
||||
import { apiFetchRegister } from '@/app/admin/auth/_lib/api_fetch_auth';
|
||||
// app/registrasi/page.tsx
|
||||
'use client';
|
||||
|
||||
import { apiFetchRegister } from '@/app/api/auth/_lib/api_fetch_auth';
|
||||
import BackButton from '@/app/darmasaba/(pages)/desa/layanan/_com/BackButto';
|
||||
import colors from '@/con/colors';
|
||||
import { Box, Button, Center, Checkbox, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core';
|
||||
import {
|
||||
Box, Button, Center, Checkbox, Image, Paper, Stack, Text, TextInput, Title,
|
||||
} from '@mantine/core';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { PhoneInput } from "react-international-phone";
|
||||
import "react-international-phone/style.css";
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PhoneInput } from 'react-international-phone';
|
||||
import 'react-international-phone/style.css';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
function Registrasi() {
|
||||
const [phone, setPhone] = useState("")
|
||||
const router = useRouter()
|
||||
const [value, setValue] = useState("")
|
||||
const [isValue, setIsValue] = useState(false);
|
||||
export default function Registrasi() {
|
||||
const router = useRouter();
|
||||
const [username, setUsername] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [phone, setPhone] = useState(''); // ✅ tambahkan state untuk phone
|
||||
const [agree, setAgree] = useState(false)
|
||||
|
||||
async function onRegistarsi() {
|
||||
if (value.length < 5) {
|
||||
toast.error("Username minimal 5 karakter!");
|
||||
// Ambil data dari localStorage (dari login)
|
||||
useEffect(() => {
|
||||
const storedNomor = localStorage.getItem('auth_nomor');
|
||||
if (!storedNomor) {
|
||||
toast.error('Akses tidak valid');
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.includes(" ")) {
|
||||
toast.error("Username tidak boleh ada spasi!");
|
||||
setPhone(storedNomor);
|
||||
}, [router]);
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!username || username.trim().length < 5) {
|
||||
toast.error('Username minimal 5 karakter!');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!phone) {
|
||||
toast.error("Nomor telepon wajib diisi!");
|
||||
if (username.includes(' ')) {
|
||||
toast.error('Username tidak boleh ada spasi!');
|
||||
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 {
|
||||
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) {
|
||||
router.push("/login", { scroll: false });
|
||||
toast.success(respone.message);
|
||||
if (response.success) {
|
||||
// Simpan sementara
|
||||
localStorage.setItem('auth_kodeId', response.kodeId);
|
||||
localStorage.setItem('auth_username', username); // simpan username
|
||||
|
||||
} else {
|
||||
setLoading(false);
|
||||
toast.error(respone.message);
|
||||
toast.success('Kode verifikasi dikirim!');
|
||||
router.push('/validasi'); // ✅ ke halaman validasi
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error Registrasi:', error);
|
||||
toast.error('Gagal mengirim OTP');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
console.log("Error Registrasi", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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 }}>
|
||||
<BackButton />
|
||||
</Box>
|
||||
<Box px={{ base: 'md', md: 100 }}>
|
||||
<Stack justify='center' align='center' h={"80vh"}>
|
||||
<Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}>
|
||||
<Stack align='center'>
|
||||
<Title order={2} fw={'bold'} c={colors['blue-button']}>
|
||||
<Stack justify="center" align="center" h="80vh">
|
||||
<Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '100%', sm: 400 }}>
|
||||
<Stack align="center">
|
||||
<Title order={2} fw="bold" c={colors['blue-button']}>
|
||||
Registrasi
|
||||
</Title>
|
||||
<Center>
|
||||
<Image loading="lazy" src={"/darmasaba-icon.png"} alt="" w={80} />
|
||||
<Image loading="lazy" src="/darmasaba-icon.png" alt="" w={80} />
|
||||
</Center>
|
||||
<Box>
|
||||
<TextInput placeholder='Username'
|
||||
label='Username'
|
||||
maxLength={50}
|
||||
|
||||
<Box w="100%">
|
||||
<TextInput
|
||||
label="Username"
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.currentTarget.value)}
|
||||
error={
|
||||
value.length > 0 && value.length < 5
|
||||
? "Minimal 5 karakter !"
|
||||
: value.includes(" ")
|
||||
? "Tidak boleh ada spasi"
|
||||
: isValue
|
||||
? "Masukan username anda"
|
||||
: ""
|
||||
username.length > 0 && username.length < 5
|
||||
? 'Minimal 5 karakter!'
|
||||
: username.includes(' ')
|
||||
? 'Tidak boleh ada spasi'
|
||||
: ''
|
||||
}
|
||||
onChange={(val) => {
|
||||
val.currentTarget.value.length > 0 ? setIsValue(false) : "";
|
||||
setValue(val.currentTarget.value);
|
||||
}}
|
||||
required
|
||||
|
||||
/>
|
||||
<Box py={10}>
|
||||
<Text fz={"sm"} >Nomor Telepon</Text>
|
||||
|
||||
<Box pt="md">
|
||||
<Text fz="sm">Nomor Telepon</Text>
|
||||
<PhoneInput
|
||||
countrySelectorStyleProps={{
|
||||
buttonStyle: {
|
||||
backgroundColor: colors['blue-button'],
|
||||
},
|
||||
}}
|
||||
inputStyle={{ width: "100%" }}
|
||||
defaultCountry="id"
|
||||
onChange={(val) => {
|
||||
setPhone(val);
|
||||
}}
|
||||
value={phone}
|
||||
disabled
|
||||
/>
|
||||
</Box>
|
||||
<Box pb={10}>
|
||||
|
||||
<Box pt="md">
|
||||
<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 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>
|
||||
</Stack>
|
||||
@@ -116,6 +156,4 @@ function Registrasi() {
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default Registrasi;
|
||||
}
|
||||
@@ -1,31 +1,306 @@
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import { Box, Button, Paper, PinInput, Stack, Text, Title } from '@mantine/core';
|
||||
import { useRouter } from 'next/navigation';
|
||||
'use client';
|
||||
|
||||
import colors from '@/con/colors';
|
||||
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 (
|
||||
<Stack pos={"relative"} bg={colors.Bg}>
|
||||
<Stack pos="relative" bg={colors.Bg}>
|
||||
<Box px={{ base: 'md', md: 100 }} pb={50}>
|
||||
<Stack align='center' justify='center' h={"100vh"}>
|
||||
<Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}>
|
||||
<Stack align='center' gap={"lg"}>
|
||||
<Stack align="center" justify="center" h="100vh">
|
||||
<Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '100%', sm: 400 }}>
|
||||
<Stack align="center" gap="lg">
|
||||
<Box>
|
||||
<Title ta={"center"} order={2} fw={'bold'} c={colors['blue-button']}>
|
||||
Kode Verifikasi
|
||||
<Title ta="center" order={2} fw="bold" c={colors['blue-button']}>
|
||||
{isRegistrationFlow ? 'Verifikasi Registrasi' : 'Verifikasi Login'}
|
||||
</Title>
|
||||
<Text ta="center" size="sm" c="dimmed" mt="xs">
|
||||
Kami telah mengirim kode ke nomor <strong>{nomor}</strong>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Box mb={10}>
|
||||
<Text c={colors['blue-button']} ta={"center"} fz={"sm"} fw={'bold'}>Masukkan Kode Verifikasi</Text>
|
||||
<PinInput type={/^[0-9]*$/} inputType="tel" inputMode="numeric" />
|
||||
<Box w="100%">
|
||||
<Box mb={20}>
|
||||
<Text c={colors['blue-button']} ta="center" fz="sm" fw="bold">
|
||||
Masukkan Kode Verifikasi
|
||||
</Text>
|
||||
<Center>
|
||||
<PinInput
|
||||
length={4}
|
||||
value={otp}
|
||||
onChange={setOtp}
|
||||
onComplete={handleVerify}
|
||||
inputMode="numeric"
|
||||
size="lg"
|
||||
/>
|
||||
</Center>
|
||||
</Box>
|
||||
<Box py={20} >
|
||||
<Button onClick={() => router.push("/admin/landing-page/profile/program-inovasi")}>
|
||||
Page
|
||||
|
||||
<Button
|
||||
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>
|
||||
</Box>
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
@@ -33,6 +308,4 @@ function Validasi() {
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default Validasi;
|
||||
}
|
||||
@@ -111,7 +111,7 @@ function EditKategoriBerita() {
|
||||
{/* Form Wrapper */}
|
||||
<Paper
|
||||
w={{ base: '100%', md: '50%' }}
|
||||
bg={colors['white-1']}
|
||||
bg={colors['white-1']}
|
||||
p="lg"
|
||||
radius="md"
|
||||
shadow="sm"
|
||||
|
||||
303
src/app/admin/(dashboard)/desa/gallery/foto/[id]/edit/page.tsx
Normal file
303
src/app/admin/(dashboard)/desa/gallery/foto/[id]/edit/page.tsx
Normal 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;
|
||||
175
src/app/admin/(dashboard)/desa/gallery/foto/[id]/page.tsx
Normal file
175
src/app/admin/(dashboard)/desa/gallery/foto/[id]/page.tsx
Normal 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;
|
||||
228
src/app/admin/(dashboard)/desa/gallery/foto/create/page.tsx
Normal file
228
src/app/admin/(dashboard)/desa/gallery/foto/create/page.tsx
Normal 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;
|
||||
@@ -1,157 +1,163 @@
|
||||
"use client";
|
||||
import stateFileStorage from "@/state/state-list-image";
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Card,
|
||||
Flex,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Image,
|
||||
Pagination,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Table,
|
||||
TableTbody,
|
||||
TableTd,
|
||||
TableTh,
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
TextInput,
|
||||
Title
|
||||
} from "@mantine/core";
|
||||
import { useShallowEffect } from "@mantine/hooks";
|
||||
import { IconSearch, IconTrash, IconX } from "@tabler/icons-react";
|
||||
import { motion } from "framer-motion";
|
||||
import toast from "react-simple-toasts";
|
||||
import { useSnapshot } from "valtio";
|
||||
|
||||
export default function ListImage() {
|
||||
const { list, total } = useSnapshot(stateFileStorage);
|
||||
|
||||
useShallowEffect(() => {
|
||||
stateFileStorage.load();
|
||||
}, []);
|
||||
|
||||
let timeOut: NodeJS.Timer;
|
||||
} from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { IconDeviceImac, IconPlus, IconSearch } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import HeaderSearch from '../../../_com/header';
|
||||
import stateGallery from '../../../_state/desa/gallery';
|
||||
|
||||
function Foto() {
|
||||
const [search, setSearch] = useState("");
|
||||
return (
|
||||
<Stack p="lg" gap="lg">
|
||||
<Flex justify="space-between" align="center" wrap="wrap" gap="md">
|
||||
<Title order={2} fw={700}>
|
||||
Galeri Foto
|
||||
</Title>
|
||||
<TextInput
|
||||
radius="xl"
|
||||
size="md"
|
||||
placeholder="Cari foto berdasarkan nama..."
|
||||
leftSection={<IconSearch size={18} />}
|
||||
rightSection={
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="gray"
|
||||
radius="xl"
|
||||
onClick={() => stateFileStorage.load()}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
}
|
||||
onChange={(e) => {
|
||||
if (timeOut) clearTimeout(timeOut);
|
||||
timeOut = setTimeout(() => {
|
||||
stateFileStorage.load({ search: e.target.value });
|
||||
}, 300);
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Paper withBorder radius="lg" p="md" shadow="sm">
|
||||
{list && list.length > 0 ? (
|
||||
<SimpleGrid
|
||||
cols={{ base: 2, sm: 3, md: 5, lg: 8 }}
|
||||
spacing="md"
|
||||
verticalSpacing="md"
|
||||
>
|
||||
{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>
|
||||
<Box>
|
||||
<HeaderSearch
|
||||
title='Foto'
|
||||
placeholder='Cari judul atau deskripsi foto...'
|
||||
searchIcon={<IconSearch size={20} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
/>
|
||||
<ListFoto search={search} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ListFoto({ search }: { search: string }) {
|
||||
const FotoState = useProxy(stateGallery.foto)
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
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;
|
||||
|
||||
@@ -11,21 +11,21 @@ function LayoutTabsDetail({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
const tabs = [
|
||||
{
|
||||
label: "Profile Desa",
|
||||
value: "profiledesa",
|
||||
href: "/admin/desa/profile/profile-desa",
|
||||
label: "Profil Desa",
|
||||
value: "profildesa",
|
||||
href: "/admin/desa/profil/profil-desa",
|
||||
icon: <IconUser size={18} stroke={1.8} />
|
||||
},
|
||||
{
|
||||
label: "Profile Perbekel",
|
||||
value: "profileperbekel",
|
||||
href: "/admin/desa/profile/profile-perbekel",
|
||||
label: "Profil Perbekel",
|
||||
value: "profilperbekel",
|
||||
href: "/admin/desa/profil/profil-perbekel",
|
||||
icon: <IconUsers size={18} stroke={1.8} />
|
||||
},
|
||||
{
|
||||
label: "Profile Perbekel Dari Masa Ke Masa",
|
||||
value: "profile-perbekel-dari-masa-ke-masa",
|
||||
href: "/admin/desa/profile/profile-perbekel-dari-masa-ke-masa",
|
||||
label: "Profil Perbekel Dari Masa Ke Masa",
|
||||
value: "profilperbekeldarimasakemasa",
|
||||
href: "/admin/desa/profil/profil-perbekel-dari-masa-ke-masa",
|
||||
icon: <IconCalendar size={18} stroke={1.8} />
|
||||
}
|
||||
];
|
||||
@@ -12,22 +12,22 @@ function LayoutTabsEdit({ children }: { children: React.ReactNode }) {
|
||||
{
|
||||
label: "Sejarah Desa",
|
||||
value: "sejarahdesa",
|
||||
href: "/admin/desa/profile/edit/sejarah_desa"
|
||||
href: "/admin/desa/profil/edit/sejarah_desa"
|
||||
},
|
||||
{
|
||||
label: "Visi Misi Desa",
|
||||
value: "visimisidesa",
|
||||
href: "/admin/desa/profile/edit/visi_misi_desa"
|
||||
href: "/admin/desa/profil/edit/visi_misi_desa"
|
||||
},
|
||||
{
|
||||
label: "Lambang Desa",
|
||||
value: "lambangdesa",
|
||||
href: "/admin/desa/profile/edit/lambang_desa"
|
||||
href: "/admin/desa/profil/edit/lambang_desa"
|
||||
},
|
||||
{
|
||||
label: "Maskot Desa",
|
||||
value: "maskotdesa",
|
||||
href: "/admin/desa/profile/edit/maskot_desa"
|
||||
href: "/admin/desa/profil/edit/maskot_desa"
|
||||
},
|
||||
];
|
||||
const curentTab = tabs.find(tab => tab.href === pathname)
|
||||
@@ -43,7 +43,7 @@ function Page() {
|
||||
const id = params?.id as string;
|
||||
if (!id) {
|
||||
toast.error('ID tidak valid');
|
||||
router.push('/admin/desa/profile/profile-desa');
|
||||
router.push('/admin/desa/profil/profil-desa');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ function Page() {
|
||||
|
||||
if (success) {
|
||||
toast.success('Data berhasil disimpan');
|
||||
router.push('/admin/desa/profile/profile-desa');
|
||||
router.push('/admin/desa/profil/profil-desa');
|
||||
} else {
|
||||
toast.error('Gagal menyimpan data');
|
||||
}
|
||||
@@ -156,7 +156,7 @@ function Page() {
|
||||
<Alert icon={<IconAlertCircle size={20} />} color="red" title="Terjadi Kesalahan" radius="md">
|
||||
{loadError}
|
||||
</Alert>
|
||||
<Button onClick={() => router.push('/admin/desa/profile/profile-desa')} variant="outline">
|
||||
<Button onClick={() => router.push('/admin/desa/profil/profil-desa')} variant="outline">
|
||||
Kembali ke Halaman Utama
|
||||
</Button>
|
||||
</Stack>
|
||||
@@ -40,7 +40,7 @@ function Page() {
|
||||
const id = params?.id as string;
|
||||
if (!id) {
|
||||
toast.error("ID tidak valid");
|
||||
router.push("/admin/desa/profile/profile-desa");
|
||||
router.push("/admin/desa/profil/profil-desa");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ function Page() {
|
||||
|
||||
if (success) {
|
||||
toast.success("Maskot berhasil diperbarui!");
|
||||
router.push("/admin/desa/profile/profile-desa");
|
||||
router.push("/admin/desa/profil/profil-desa");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error update maskot:", error);
|
||||
@@ -50,7 +50,7 @@ function Page() {
|
||||
const id = params?.id as string;
|
||||
if (!id) {
|
||||
toast.error('ID tidak valid');
|
||||
router.push('/admin/desa/profile/profile-desa');
|
||||
router.push('/admin/desa/profil/profil-desa');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ function Page() {
|
||||
|
||||
if (success) {
|
||||
toast.success('Data berhasil disimpan');
|
||||
router.push('/admin/desa/profile/profile-desa');
|
||||
router.push('/admin/desa/profil/profil-desa');
|
||||
} else {
|
||||
toast.error('Gagal menyimpan data');
|
||||
}
|
||||
@@ -179,7 +179,7 @@ function Page() {
|
||||
{loadError}
|
||||
</Alert>
|
||||
<Button
|
||||
onClick={() => router.push('/admin/desa/profile/profile-desa')}
|
||||
onClick={() => router.push('/admin/desa/profil/profil-desa')}
|
||||
variant="outline"
|
||||
>
|
||||
Kembali ke Halaman Utama
|
||||
@@ -42,7 +42,7 @@ function Page() {
|
||||
const id = params?.id as string;
|
||||
if (!id) {
|
||||
toast.error('ID tidak valid');
|
||||
router.push('/admin/desa/profile/profile-desa');
|
||||
router.push('/admin/desa/profil/profil-desa');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ function Page() {
|
||||
|
||||
if (success) {
|
||||
toast.success('Data berhasil disimpan');
|
||||
router.push('/admin/desa/profile/profile-desa');
|
||||
router.push('/admin/desa/profil/profil-desa');
|
||||
} else {
|
||||
toast.error('Gagal menyimpan data');
|
||||
}
|
||||
@@ -156,7 +156,7 @@ function Page() {
|
||||
{loadError}
|
||||
</Alert>
|
||||
<Button
|
||||
onClick={() => router.push('/admin/desa/profile/profile-desa')}
|
||||
onClick={() => router.push('/admin/desa/profil/profil-desa')}
|
||||
variant="outline"
|
||||
>
|
||||
Kembali ke Halaman Utama
|
||||
@@ -27,7 +27,7 @@ function Page() {
|
||||
return (
|
||||
<Paper bg={colors['white-1']} p="lg" radius="md" shadow="sm">
|
||||
<Stack gap="lg">
|
||||
<Title order={2} c={colors['blue-button']}>Preview Profile Desa</Title>
|
||||
<Title order={2} c={colors['blue-button']}>Preview Profil Desa</Title>
|
||||
|
||||
{/* Sejarah Desa */}
|
||||
{sejarah && (
|
||||
@@ -42,7 +42,7 @@ function Page() {
|
||||
variant="light"
|
||||
leftSection={<IconEdit size={18} stroke={2} />}
|
||||
radius="md"
|
||||
onClick={() => router.push(`/admin/desa/profile/profile-desa/${sejarah.id}/sejarah_desa`)}
|
||||
onClick={() => router.push(`/admin/desa/profil/profil-desa/${sejarah.id}/sejarah_desa`)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
@@ -87,7 +87,7 @@ function Page() {
|
||||
variant="light"
|
||||
leftSection={<IconEdit size={18} stroke={2} />}
|
||||
radius="md"
|
||||
onClick={() => router.push(`/admin/desa/profile/profile-desa/${visiMisi.id}/visi_misi_desa`)}
|
||||
onClick={() => router.push(`/admin/desa/profil/profil-desa/${visiMisi.id}/visi_misi_desa`)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
@@ -135,7 +135,7 @@ function Page() {
|
||||
variant="light"
|
||||
leftSection={<IconEdit size={18} stroke={2} />}
|
||||
radius="md"
|
||||
onClick={() => router.push(`/admin/desa/profile/profile-desa/${lambang.id}/lambang_desa`)}
|
||||
onClick={() => router.push(`/admin/desa/profil/profil-desa/${lambang.id}/lambang_desa`)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
@@ -180,7 +180,7 @@ function Page() {
|
||||
variant="light"
|
||||
leftSection={<IconEdit size={18} stroke={2} />}
|
||||
radius="md"
|
||||
onClick={() => router.push(`/admin/desa/profile/profile-desa/${maskot.id}/maskot_desa`)}
|
||||
onClick={() => router.push(`/admin/desa/profil/profil-desa/${maskot.id}/maskot_desa`)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
@@ -117,7 +117,7 @@ function EditPerbekelDariMasaKeMasa() {
|
||||
|
||||
await state.update.update();
|
||||
toast.success('Perbekel dari masa ke masa berhasil diperbarui!');
|
||||
router.push('/admin/desa/profile/profile-perbekel-dari-masa-ke-masa');
|
||||
router.push('/admin/desa/profil/profil-perbekel-dari-masa-ke-masa');
|
||||
} catch (error) {
|
||||
console.error('Error updating perbekel dari masa ke masa:', error);
|
||||
toast.error('Terjadi kesalahan saat memperbarui perbekel dari masa ke masa');
|
||||
@@ -25,7 +25,7 @@ function DetailPerbekelDariMasa() {
|
||||
state.delete.byId(selectedId);
|
||||
setModalHapus(false);
|
||||
setSelectedId(null);
|
||||
router.push("/admin/desa/profile/profile-perbekel-dari-masa-ke-masa");
|
||||
router.push("/admin/desa/profil/profil-perbekel-dari-masa-ke-masa");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -113,7 +113,7 @@ function DetailPerbekelDariMasa() {
|
||||
|
||||
<Button
|
||||
color="green"
|
||||
onClick={() => router.push(`/admin/desa/profile/profile-perbekel-dari-masa-ke-masa/${data.id}/edit`)}
|
||||
onClick={() => router.push(`/admin/desa/profil/profil-perbekel-dari-masa-ke-masa/${data.id}/edit`)}
|
||||
variant="light"
|
||||
radius="md"
|
||||
size="md"
|
||||
@@ -46,7 +46,7 @@ function CreatePerbekelDariMasaKeMasa() {
|
||||
state.create.form.imageId = uploaded.id;
|
||||
await state.create.create();
|
||||
resetForm();
|
||||
router.push('/admin/desa/profile/profile-perbekel-dari-masa-ke-masa');
|
||||
router.push('/admin/desa/profil/profil-perbekel-dari-masa-ke-masa');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Gagal menambahkan perbekel dari masa ke masa');
|
||||
@@ -53,7 +53,7 @@ function ListPerbekelDariMasaKeMasa({ search }: { search: string }) {
|
||||
leftSection={<IconPlus size={18} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
onClick={() => router.push('/admin/desa/profile/profile-perbekel-dari-masa-ke-masa/create')}
|
||||
onClick={() => router.push('/admin/desa/profil/profil-perbekel-dari-masa-ke-masa/create')}
|
||||
>
|
||||
Tambah Baru
|
||||
</Button>
|
||||
@@ -90,7 +90,7 @@ function ListPerbekelDariMasaKeMasa({ search }: { search: string }) {
|
||||
variant="light"
|
||||
color="blue"
|
||||
leftSection={<IconDeviceImacCog size={16} />}
|
||||
onClick={() => router.push(`/admin/desa/profile/profile-perbekel-dari-masa-ke-masa/${item.id}`)}
|
||||
onClick={() => router.push(`/admin/desa/profil/profil-perbekel-dari-masa-ke-masa/${item.id}`)}
|
||||
>
|
||||
Detail
|
||||
</Button>
|
||||
@@ -25,7 +25,7 @@ function ProfilePerbekel() {
|
||||
const id = params?.id as string;
|
||||
if (!id) {
|
||||
toast.error("ID tidak valid");
|
||||
router.push("/admin/desa/profile/profile-perbekel");
|
||||
router.push("/admin/desa/profil/profil-perbekel");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ function ProfilePerbekel() {
|
||||
const success = await perbekelState.edit.submit()
|
||||
if (success) {
|
||||
toast.success("Data berhasil disimpan");
|
||||
router.push("/admin/desa/profile/profile-perbekel");
|
||||
router.push("/admin/desa/profil/profil-perbekel");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error update sejarah desa:", error);
|
||||
@@ -41,7 +41,7 @@ function Page() {
|
||||
variant="light"
|
||||
leftSection={<IconEdit size={18} stroke={2} />}
|
||||
radius="md"
|
||||
onClick={() => router.push(`/admin/desa/profile/profile-perbekel/${perbekel.id}`)}
|
||||
onClick={() => router.push(`/admin/desa/profil/profil-perbekel/${perbekel.id}`)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
@@ -20,9 +20,9 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
|
||||
icon: <IconActivity size={18} stroke={1.8} />
|
||||
},
|
||||
{
|
||||
label: "Grafik Hasil Kepuasan Masyarakat",
|
||||
value: "grafikhasilkepuasan",
|
||||
href: "/admin/kesehatan/data-kesehatan-warga/grafik_hasil_kepuasan",
|
||||
label: "Penderita Penyakit",
|
||||
value: "penderitapenyakit",
|
||||
href: "/admin/kesehatan/data-kesehatan-warga/penderita_penyakit",
|
||||
icon: <IconGauge size={18} stroke={1.8} />
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
'use client';
|
||||
|
||||
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
|
||||
@@ -10,19 +8,22 @@ import {
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
MultiSelect,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { IconArrowBack } from '@tabler/icons-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
|
||||
interface FasilitasKesehatanFormBase {
|
||||
// Tipe form yang SESUAI dengan logika relasi (array ID)
|
||||
interface EditFasilitasKesehatanForm {
|
||||
name: string;
|
||||
informasiUmum: {
|
||||
fasilitas: string;
|
||||
@@ -30,128 +31,92 @@ interface FasilitasKesehatanFormBase {
|
||||
jamOperasional: string;
|
||||
};
|
||||
layananUnggulan: { content: string };
|
||||
dokterdanTenagaMedis: {
|
||||
name: string;
|
||||
specialist: string;
|
||||
jadwal: string;
|
||||
};
|
||||
dokterdanTenagaMedis: string[]; // ← ARRAY ID
|
||||
fasilitasPendukung: { content: string };
|
||||
prosedurPendaftaran: { content: string };
|
||||
tarifDanLayanan: {
|
||||
layanan: string;
|
||||
tarif: string;
|
||||
};
|
||||
tarifDanLayanan: string[]; // ← ARRAY ID
|
||||
}
|
||||
|
||||
function EditFasilitasKesehatan() {
|
||||
const state = useProxy(fasilitasKesehatanState.fasilitasKesehatan);
|
||||
const dokterState = useProxy(fasilitasKesehatanState.dokter);
|
||||
const tarifState = useProxy(fasilitasKesehatanState.tarif);
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const params = useParams<{ id: string }>();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState<FasilitasKesehatanFormBase>({
|
||||
const [formData, setFormData] = useState<EditFasilitasKesehatanForm>({
|
||||
name: '',
|
||||
informasiUmum: { fasilitas: '', alamat: '', jamOperasional: '' },
|
||||
layananUnggulan: { content: '' },
|
||||
dokterdanTenagaMedis: { name: '', specialist: '', jadwal: '' },
|
||||
dokterdanTenagaMedis: [],
|
||||
fasilitasPendukung: { content: '' },
|
||||
prosedurPendaftaran: { content: '' },
|
||||
tarifDanLayanan: { layanan: '', tarif: '' },
|
||||
tarifDanLayanan: [],
|
||||
});
|
||||
|
||||
const [originalData, setOriginalData] = useState<FasilitasKesehatanFormBase>({
|
||||
name: '',
|
||||
informasiUmum: { fasilitas: '', alamat: '', jamOperasional: '' },
|
||||
layananUnggulan: { content: '' },
|
||||
dokterdanTenagaMedis: { name: '', specialist: '', jadwal: '' },
|
||||
fasilitasPendukung: { content: '' },
|
||||
prosedurPendaftaran: { content: '' },
|
||||
tarifDanLayanan: { layanan: '', tarif: '' },
|
||||
});
|
||||
// Load data fasilitas & daftar dokter/tarif
|
||||
useShallowEffect(() => {
|
||||
const loadAll = async () => {
|
||||
const id = params?.id;
|
||||
if (!id) return;
|
||||
|
||||
// Helper untuk update nested state
|
||||
const updateForm = <K extends keyof FasilitasKesehatanFormBase>(
|
||||
key: K,
|
||||
value: FasilitasKesehatanFormBase[K]
|
||||
) => setFormData(prev => ({ ...prev, [key]: value }));
|
||||
// Load dokter & tarif (untuk opsi MultiSelect)
|
||||
await Promise.all([
|
||||
dokterState.findMany.load(),
|
||||
tarifState.findMany.load(),
|
||||
]);
|
||||
|
||||
const updateNested = <
|
||||
K extends keyof FasilitasKesehatanFormBase,
|
||||
N extends keyof FasilitasKesehatanFormBase[K]
|
||||
>(key: K, nestedKey: N, value: FasilitasKesehatanFormBase[K][N]) =>
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[key]: { ...prev[key] as object, [nestedKey]: value },
|
||||
}));
|
||||
// Load data fasilitas
|
||||
await state.edit.load(id);
|
||||
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 || [],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const deepClone = (obj: any): any => {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
} catch (error) {
|
||||
console.warn('Gagal deep clone dengan JSON fallback:', error);
|
||||
return obj; // fallback (berisiko shared reference)
|
||||
loadAll();
|
||||
}, [params?.id]);
|
||||
|
||||
const updateForm = <K extends keyof EditFasilitasKesehatanForm>(
|
||||
field: K,
|
||||
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
|
||||
useEffect(() => {
|
||||
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 () => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
state.edit.form = { ...state.edit.form, ...formData };
|
||||
|
||||
// Update state Valtio
|
||||
state.edit.form = { ...formData };
|
||||
|
||||
const success = await state.edit.submit();
|
||||
if (success) {
|
||||
toast.success('Fasilitas kesehatan berhasil diperbarui!');
|
||||
@@ -159,14 +124,14 @@ function EditFasilitasKesehatan() {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error('Terjadi kesalahan saat memperbarui data fasilitas kesehatan');
|
||||
toast.error('Gagal memperbarui data');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md" component="form" onSubmit={handleSubmit}>
|
||||
{/* Header */}
|
||||
<Group mb="md">
|
||||
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
|
||||
@@ -189,7 +154,7 @@ function EditFasilitasKesehatan() {
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Nama Fasilitas Kesehatan"
|
||||
placeholder="Masukkan nama fasilitas kesehatan"
|
||||
placeholder="Masukkan nama"
|
||||
value={formData.name}
|
||||
onChange={(e) => updateForm('name', e.target.value)}
|
||||
required
|
||||
@@ -197,118 +162,108 @@ function EditFasilitasKesehatan() {
|
||||
|
||||
{/* Informasi Umum */}
|
||||
<Box>
|
||||
<Text fw="bold" mb={5}>
|
||||
Informasi Umum
|
||||
</Text>
|
||||
<Text fw="bold" mb={5}>Informasi Umum</Text>
|
||||
<TextInput
|
||||
label="Fasilitas"
|
||||
value={formData.informasiUmum.fasilitas}
|
||||
onChange={(e) => updateNested('informasiUmum', 'fasilitas', e.target.value)}
|
||||
onChange={(e) =>
|
||||
updateForm('informasiUmum', {
|
||||
...formData.informasiUmum,
|
||||
fasilitas: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Alamat"
|
||||
value={formData.informasiUmum.alamat}
|
||||
onChange={(e) => updateNested('informasiUmum', 'alamat', e.target.value)}
|
||||
onChange={(e) =>
|
||||
updateForm('informasiUmum', {
|
||||
...formData.informasiUmum,
|
||||
alamat: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Jam Operasional"
|
||||
value={formData.informasiUmum.jamOperasional}
|
||||
onChange={(e) => updateNested('informasiUmum', 'jamOperasional', e.target.value)}
|
||||
onChange={(e) =>
|
||||
updateForm('informasiUmum', {
|
||||
...formData.informasiUmum,
|
||||
jamOperasional: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Layanan Unggulan */}
|
||||
<Box>
|
||||
<Text fw="bold" mb={5}>
|
||||
Layanan Unggulan
|
||||
</Text>
|
||||
<Text fw="bold" mb={5}>Layanan Unggulan</Text>
|
||||
<EditEditor
|
||||
value={formData.layananUnggulan.content}
|
||||
onChange={(v) => updateNested('layananUnggulan', 'content', v)}
|
||||
onChange={(v) => updateForm('layananUnggulan', { content: v })}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Dokter dan Tenaga Medis */}
|
||||
<Box>
|
||||
<Text fw="bold" mb={5}>
|
||||
Dokter dan Tenaga Medis
|
||||
</Text>
|
||||
<TextInput
|
||||
label="Nama Dokter"
|
||||
value={formData.dokterdanTenagaMedis.name}
|
||||
onChange={(e) => updateNested('dokterdanTenagaMedis', 'name', e.target.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Specialist"
|
||||
value={formData.dokterdanTenagaMedis.specialist}
|
||||
onChange={(e) =>
|
||||
updateNested('dokterdanTenagaMedis', 'specialist', e.target.value)
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Jadwal"
|
||||
value={formData.dokterdanTenagaMedis.jadwal}
|
||||
onChange={(e) => updateNested('dokterdanTenagaMedis', 'jadwal', e.target.value)}
|
||||
/>
|
||||
</Box>
|
||||
{/* Dokter & Tenaga Medis — MultiSelect */}
|
||||
<MultiSelect
|
||||
label="Dokter & Tenaga Medis"
|
||||
placeholder="Pilih dokter/tenaga medis"
|
||||
data={
|
||||
dokterState.findMany.data?.map((d) => ({
|
||||
value: d.id,
|
||||
label: `${d.name} (${d.specialist})`,
|
||||
})) || []
|
||||
}
|
||||
value={formData.dokterdanTenagaMedis}
|
||||
onChange={(val) => updateForm('dokterdanTenagaMedis', val)}
|
||||
searchable
|
||||
clearable
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Fasilitas Pendukung */}
|
||||
<Box>
|
||||
<Text fw="bold" mb={5}>
|
||||
Fasilitas Pendukung
|
||||
</Text>
|
||||
<Text fw="bold" mb={5}>Fasilitas Pendukung</Text>
|
||||
<EditEditor
|
||||
value={formData.fasilitasPendukung.content}
|
||||
onChange={(v) => updateNested('fasilitasPendukung', 'content', v)}
|
||||
onChange={(v) => updateForm('fasilitasPendukung', { content: v })}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Prosedur Pendaftaran */}
|
||||
<Box>
|
||||
<Text fw="bold" mb={5}>
|
||||
Prosedur Pendaftaran
|
||||
</Text>
|
||||
<Text fw="bold" mb={5}>Prosedur Pendaftaran</Text>
|
||||
<EditEditor
|
||||
value={formData.prosedurPendaftaran.content}
|
||||
onChange={(v) => updateNested('prosedurPendaftaran', 'content', v)}
|
||||
onChange={(v) => updateForm('prosedurPendaftaran', { content: v })}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Tarif dan Layanan */}
|
||||
<Box>
|
||||
<Text fw="bold" mb={5}>
|
||||
Tarif dan Layanan
|
||||
</Text>
|
||||
<TextInput
|
||||
label="Tarif"
|
||||
value={formData.tarifDanLayanan.tarif}
|
||||
onChange={(e) => updateNested('tarifDanLayanan', 'tarif', e.target.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Layanan"
|
||||
value={formData.tarifDanLayanan.layanan}
|
||||
onChange={(e) => updateNested('tarifDanLayanan', 'layanan', e.target.value)}
|
||||
/>
|
||||
</Box>
|
||||
{/* Tarif & Layanan — MultiSelect */}
|
||||
<MultiSelect
|
||||
label="Tarif & Layanan"
|
||||
placeholder="Pilih layanan"
|
||||
data={
|
||||
tarifState.findMany.data?.map((t) => ({
|
||||
value: t.id,
|
||||
label: `${t.layanan} - ${t.tarif}`,
|
||||
})) || []
|
||||
}
|
||||
value={formData.tarifDanLayanan}
|
||||
onChange={(val) => updateForm('tarifDanLayanan', val)}
|
||||
searchable
|
||||
clearable
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
{/* Aksi */}
|
||||
<Group justify="right">
|
||||
{/* Tombol Batal */}
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
onClick={handleResetForm}
|
||||
>
|
||||
<Button variant="outline" color="gray" radius="md" onClick={handleReset}>
|
||||
Batal
|
||||
</Button>
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
type="submit"
|
||||
radius="md"
|
||||
size="md"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||
color: '#fff',
|
||||
@@ -324,4 +279,4 @@ function EditFasilitasKesehatan() {
|
||||
);
|
||||
}
|
||||
|
||||
export default EditFasilitasKesehatan;
|
||||
export default EditFasilitasKesehatan;
|
||||
@@ -9,6 +9,12 @@ import {
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Table,
|
||||
TableTbody,
|
||||
TableTd,
|
||||
TableTh,
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
@@ -108,21 +114,79 @@ function DetailFasilitasKesehatan() {
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Dokter & Tenaga Medis</Text>
|
||||
<Text fz="md" fw="bold">Nama</Text>
|
||||
<Text fz="md" c="dimmed">{data.dokterdantenagamedis?.name || '-'}</Text>
|
||||
<Text fz="md" fw="bold">Spesialis</Text>
|
||||
<Text fz="md" c="dimmed">{data.dokterdantenagamedis?.specialist || '-'}</Text>
|
||||
<Text fz="md" fw="bold">Jadwal</Text>
|
||||
<Text fz="md" c="dimmed">{data.dokterdantenagamedis?.jadwal || '-'}</Text>
|
||||
<Text fz="lg" fw="bold" mb="sm">Dokter & Tenaga Medis</Text>
|
||||
{Array.isArray(data.dokterdantenagamedis) && data.dokterdantenagamedis.length > 0 ? (
|
||||
<Box style={{ overflowX: 'auto', width: '100%' }}>
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<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>
|
||||
<Text fz="lg" fw="bold">Tarif & Layanan</Text>
|
||||
<Text fz="md" fw="bold">Layanan</Text>
|
||||
<Text fz="md" c="dimmed">{data.tarifdanlayanan?.layanan || '-'}</Text>
|
||||
<Text fz="md" fw="bold">Tarif</Text>
|
||||
<Text fz="md" c="dimmed">{data.tarifdanlayanan?.tarif || '-'}</Text>
|
||||
<Box mt="xl">
|
||||
<Text fz="lg" fw="bold" mb="sm">Tarif & Layanan</Text>
|
||||
{Array.isArray(data.tarifdanlayanan) && data.tarifdanlayanan.length > 0 ? (
|
||||
<Box style={{ overflowX: 'auto', width: '100%' }}>
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<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>
|
||||
|
||||
{/* Aksi */}
|
||||
|
||||
@@ -7,19 +7,20 @@ import {
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
MultiSelect,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
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 CreateFasilitasKesehatan() {
|
||||
const stateFasilitasKesehatan = useProxy(fasilitasKesehatanState.fasilitasKesehatan);
|
||||
const router = useRouter();
|
||||
@@ -34,23 +35,16 @@ function CreateFasilitasKesehatan() {
|
||||
jamOperasional: '',
|
||||
},
|
||||
layananUnggulan: {
|
||||
content: '',
|
||||
},
|
||||
dokterdanTenagaMedis: {
|
||||
name: '',
|
||||
specialist: '',
|
||||
jadwal: '',
|
||||
content: ''
|
||||
},
|
||||
dokterdanTenagaMedis: [] as string[],
|
||||
fasilitasPendukung: {
|
||||
content: '',
|
||||
},
|
||||
prosedurPendaftaran: {
|
||||
content: '',
|
||||
},
|
||||
tarifDanLayanan: {
|
||||
layanan: '',
|
||||
tarif: '',
|
||||
},
|
||||
tarifDanLayanan: [] as string[],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -70,6 +64,11 @@ function CreateFasilitasKesehatan() {
|
||||
}
|
||||
};
|
||||
|
||||
useShallowEffect(() => {
|
||||
fasilitasKesehatanState.dokter.findMany.load();
|
||||
fasilitasKesehatanState.tarif.findMany.load();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md" component="form" onSubmit={handleSubmit}>
|
||||
{/* Header */}
|
||||
@@ -140,31 +139,25 @@ function CreateFasilitasKesehatan() {
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
{/* Dokter dan Tenaga Medis */}
|
||||
<Box>
|
||||
<Text fz="md" fw="bold" mb={5}>Dokter dan Tenaga Medis</Text>
|
||||
<TextInput
|
||||
label="Nama Dokter"
|
||||
placeholder="Masukkan nama dokter"
|
||||
value={stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.name}
|
||||
onChange={(e) => (stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.name = e.target.value)}
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label="Spesialis"
|
||||
placeholder="Masukkan spesialis"
|
||||
value={stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.specialist}
|
||||
onChange={(e) => (stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.specialist = e.target.value)}
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label="Jadwal"
|
||||
placeholder="Masukkan jadwal"
|
||||
value={stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.jadwal}
|
||||
onChange={(e) => (stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.jadwal = e.target.value)}
|
||||
required
|
||||
/>
|
||||
</Box>
|
||||
<MultiSelect
|
||||
label="Dokter & Tenaga Medis"
|
||||
placeholder="Pilih dokter / tenaga medis"
|
||||
data={
|
||||
fasilitasKesehatanState.dokter.findMany.data?.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
})) || []
|
||||
}
|
||||
value={stateFasilitasKesehatan.create.form.dokterdanTenagaMedis}
|
||||
onChange={(val: string[]) => {
|
||||
stateFasilitasKesehatan.create.form.dokterdanTenagaMedis = val;
|
||||
}}
|
||||
searchable
|
||||
clearable
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Fasilitas Pendukung */}
|
||||
<Box>
|
||||
@@ -175,6 +168,24 @@ function CreateFasilitasKesehatan() {
|
||||
/>
|
||||
</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 */}
|
||||
<Box>
|
||||
<Text fz="md" fw="bold" mb={5}>Prosedur Pendaftaran</Text>
|
||||
@@ -184,24 +195,6 @@ function CreateFasilitasKesehatan() {
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Tarif dan Layanan */}
|
||||
<Box>
|
||||
<Text fz="md" fw="bold" mb={5}>Tarif dan Layanan</Text>
|
||||
<TextInput
|
||||
label="Tarif"
|
||||
placeholder="Masukkan tarif"
|
||||
value={stateFasilitasKesehatan.create.form.tarifDanLayanan.tarif}
|
||||
onChange={(e) => (stateFasilitasKesehatan.create.form.tarifDanLayanan.tarif = e.target.value)}
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label="Layanan"
|
||||
placeholder="Masukkan layanan"
|
||||
value={stateFasilitasKesehatan.create.form.tarifDanLayanan.layanan}
|
||||
onChange={(e) => (stateFasilitasKesehatan.create.form.tarifDanLayanan.layanan = e.target.value)}
|
||||
required
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Submit */}
|
||||
<Group justify="right">
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
Page
|
||||
</div>
|
||||
<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 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;
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
Page
|
||||
</div>
|
||||
<Box py={10}>
|
||||
{/* Tombol Back */}
|
||||
<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;
|
||||
|
||||
@@ -1,71 +1,184 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
'use client'
|
||||
import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
|
||||
import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan';
|
||||
import colors from '@/con/colors';
|
||||
import { Box, Button, Paper, Stack, Text, TextInput, Title } from '@mantine/core';
|
||||
import { IconArrowBack } from '@tabler/icons-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
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 { useRouter } from 'next/navigation';
|
||||
import { useRef, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
|
||||
|
||||
function CreateDokter() {
|
||||
const params = useParams()
|
||||
const createState = useProxy(fasilitasKesehatanState.dokter)
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const resetForm = () => {
|
||||
createState.create.create.form = {
|
||||
name: "",
|
||||
specialist: "",
|
||||
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 () => {
|
||||
await createState.create.create.create();
|
||||
resetForm();
|
||||
router.push(`/admin/kesehatan/fasilitas-kesehatan/${params?.id}/dokter-tenaga-medis`)
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
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 (
|
||||
<Box component="form" onSubmit={handleSubmit}>
|
||||
<Box mb={10}>
|
||||
<Button variant="subtle" onClick={() => router.back()}>
|
||||
<IconArrowBack color={colors['blue-button']} size={25} />
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md" component="form" onSubmit={handleSubmit}>
|
||||
{/* Header */}
|
||||
<Group mb="md">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => router.back()}
|
||||
p="xs"
|
||||
radius="md"
|
||||
>
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</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%' }}>
|
||||
<Stack gap="xs">
|
||||
<Title order={3}>Create Dokter</Title>
|
||||
{/* 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={<Text fz="sm" fw="bold">Nama Dokter</Text>}
|
||||
placeholder="masukkan nama dokter"
|
||||
label={"Nama Dokter"}
|
||||
placeholder="Masukkan nama dokter"
|
||||
value={createState.create.create.form.name}
|
||||
onChange={(e) => {
|
||||
createState.create.create.form.name = e.target.value;
|
||||
}}
|
||||
onChange={(e) => (createState.create.create.form.name = e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Text fz="md" fw="bold">Specialist</Text>
|
||||
|
||||
{/* Informasi Umum */}
|
||||
|
||||
<TextInput
|
||||
label={<Text fz="sm" fw="bold">Specialist</Text>}
|
||||
placeholder="masukkan specialist"
|
||||
label="Specialist"
|
||||
placeholder="Masukkan specialist"
|
||||
value={createState.create.create.form.specialist}
|
||||
onChange={(e) => {
|
||||
createState.create.create.form.specialist = e.target.value;
|
||||
}}
|
||||
onChange={(e) => (createState.create.create.form.specialist = e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Box>
|
||||
<Text fz="md" fw="bold">Jadwal</Text>
|
||||
<CreateEditor
|
||||
value={createState.create.create.form.jadwal}
|
||||
onChange={(htmlContent) => {
|
||||
createState.create.create.form.jadwal = htmlContent;
|
||||
|
||||
<TextInput
|
||||
label="Jadwal"
|
||||
placeholder="Masukkan jadwal"
|
||||
value={createState.create.create.form.jadwal}
|
||||
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>
|
||||
<Button onClick={handleSubmit} bg={colors['blue-button']}>
|
||||
Simpan
|
||||
</Button>
|
||||
>
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import { Box, Button, Center, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text } from '@mantine/core';
|
||||
import { Box, Button, Center, Group, Pagination, Paper, Skeleton, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core';
|
||||
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 { useProxy } from 'valtio/utils';
|
||||
|
||||
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 { useState } from 'react';
|
||||
|
||||
@@ -18,7 +17,7 @@ function DokterTenagaMedis() {
|
||||
return (
|
||||
<Box>
|
||||
<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} />
|
||||
</Button>
|
||||
</Box>
|
||||
@@ -60,49 +59,101 @@ function ListDokterTenagaMedis({ search }: { search: string }) {
|
||||
}
|
||||
return (
|
||||
<Box py={10}>
|
||||
<Paper bg={colors['white-1']} p={'md'}>
|
||||
<Stack>
|
||||
<JudulList
|
||||
title='List Fasilitas Kesehatan'
|
||||
href={`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis/create`}
|
||||
/>
|
||||
<Box style={{ overflowX: "auto" }}>
|
||||
<Table striped withRowBorders withTableBorder style={{ minWidth: '700px' }}>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>Fasilitas Kesehatan</TableTh>
|
||||
<TableTh>Alamat</TableTh>
|
||||
<TableTh>Jam Operasional</TableTh>
|
||||
<TableTh>Detail</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{filteredData.map((item) => (
|
||||
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
|
||||
{/* Judul + Tombol Tambah */}
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={4}>Daftar Dokter dan Tenaga Medis</Title>
|
||||
<Button
|
||||
leftSection={<IconPlus size={18} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
'/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis/create'
|
||||
)
|
||||
}
|
||||
>
|
||||
Tambah Baru
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* 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}>
|
||||
<TableTd>{item.name}</TableTd>
|
||||
<TableTd>{item.specialist}</TableTd>
|
||||
<TableTd>
|
||||
<Text dangerouslySetInnerHTML={{ __html: item.jadwal }} />
|
||||
<Box w={150}>
|
||||
<Text fw={500} truncate="end" lineClamp={1}>
|
||||
{item.name}
|
||||
</Text>
|
||||
</Box>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Button onClick={() => router.push(`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/${item.id}`)}>
|
||||
<IconDeviceImacCog size={25} />
|
||||
<Box w={150}>
|
||||
{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>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Stack>
|
||||
))
|
||||
) : (
|
||||
<TableTr>
|
||||
<TableTd colSpan={4}>
|
||||
<Center py={20}>
|
||||
<Text c="dimmed">
|
||||
Tidak ada fasilitas kesehatan yang cocok
|
||||
</Text>
|
||||
</Center>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
)}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Pagination */}
|
||||
<Center>
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={(newPage) => load(newPage)} // ini penting!
|
||||
onChange={(newPage) => {
|
||||
load(newPage, 10, search);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
total={totalPages}
|
||||
mt="md"
|
||||
mb="md"
|
||||
color="blue"
|
||||
radius="md"
|
||||
/>
|
||||
</Center>
|
||||
</Box>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Grid,
|
||||
GridCol,
|
||||
Group,
|
||||
Pagination,
|
||||
Paper,
|
||||
@@ -16,30 +19,52 @@ import {
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
Title
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip
|
||||
} from '@mantine/core';
|
||||
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 { useState } from 'react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import HeaderSearch from '../../../_com/header';
|
||||
import fasilitasKesehatanState from '../../../_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan';
|
||||
|
||||
|
||||
function FasilitasKesehatan() {
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const router = useRouter()
|
||||
return (
|
||||
<Box>
|
||||
{/* Header Search */}
|
||||
<HeaderSearch
|
||||
title='Fasilitas Kesehatan'
|
||||
placeholder='Cari nama, alamat, atau jam operasional...'
|
||||
searchIcon={<IconSearch size={20} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
/>
|
||||
<Grid mb={10}>
|
||||
<GridCol span={{ base: 12, md: 8 }}>
|
||||
<Title order={3}>Fasilitas Kesehatan</Title>
|
||||
</GridCol>
|
||||
<GridCol span={{ base: 12, md: 4 }}>
|
||||
<Group gap={"xs"}>
|
||||
<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} />
|
||||
</Box>
|
||||
@@ -54,6 +79,7 @@ function ListFasilitasKesehatan({ search }: { search: string }) {
|
||||
const { data, page, totalPages, loading, load } = stateFasilitasKesehatan.findMany;
|
||||
|
||||
useShallowEffect(() => {
|
||||
|
||||
load(page, 10, search);
|
||||
}, [page, search]);
|
||||
|
||||
@@ -93,8 +119,8 @@ function ListFasilitasKesehatan({ search }: { search: string }) {
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>Fasilitas Kesehatan</TableTh>
|
||||
<TableTh>Dokter</TableTh>
|
||||
<TableTh>Layanan</TableTh>
|
||||
<TableTh>Jumlah Dokter</TableTh>
|
||||
<TableTh>Jumlah Layanan</TableTh>
|
||||
<TableTh>Aksi</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
@@ -111,13 +137,17 @@ function ListFasilitasKesehatan({ search }: { search: string }) {
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Box w={150}>
|
||||
{item.dokterdantenagamedis?.name || '-'}
|
||||
{item.dokterdantenagamedis?.length
|
||||
? `${item.dokterdantenagamedis.length} dokter`
|
||||
: '-'}
|
||||
</Box>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Box w={150}>
|
||||
<Text truncate="end" lineClamp={1}>
|
||||
{item.tarifdanlayanan?.layanan || '-'}
|
||||
{item.tarifdanlayanan?.length
|
||||
? `${item.tarifdanlayanan.length} layanan`
|
||||
: '-'}
|
||||
</Text>
|
||||
</Box>
|
||||
</TableTd>
|
||||
@@ -141,7 +171,7 @@ function ListFasilitasKesehatan({ search }: { search: string }) {
|
||||
<TableTr>
|
||||
<TableTd colSpan={4}>
|
||||
<Center py={20}>
|
||||
<Text color="dimmed">
|
||||
<Text c="dimmed">
|
||||
Tidak ada fasilitas kesehatan yang cocok
|
||||
</Text>
|
||||
</Center>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,13 +1,13 @@
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import { Box, Button, Center, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text } from '@mantine/core';
|
||||
import { Box, Button, Center, Group, Pagination, Paper, Skeleton, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core';
|
||||
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 { useProxy } from 'valtio/utils';
|
||||
|
||||
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 { useState } from 'react';
|
||||
|
||||
@@ -18,12 +18,12 @@ function TarifLayanan() {
|
||||
return (
|
||||
<Box>
|
||||
<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} />
|
||||
</Button>
|
||||
</Box>
|
||||
<HeaderSearch
|
||||
title='Dokter dan Tenaga Medis'
|
||||
title='Tarif dan Layanan'
|
||||
placeholder='pencarian'
|
||||
searchIcon={<IconSearch size={20} />}
|
||||
value={search}
|
||||
@@ -35,8 +35,11 @@ function TarifLayanan() {
|
||||
}
|
||||
|
||||
function ListTarifLayanan({ search }: { search: string }) {
|
||||
const stateFasilitasKesehatan = useProxy(fasilitasKesehatanState.dokter)
|
||||
const stateFasilitasKesehatan = useProxy(fasilitasKesehatanState.tarif);
|
||||
const router = useRouter();
|
||||
const [modalHapus, setModalHapus] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
@@ -49,6 +52,15 @@ function ListTarifLayanan({ search }: { search: string }) {
|
||||
load(page, 10, search)
|
||||
}, [page, search])
|
||||
|
||||
const handleDelete = () => {
|
||||
if (selectedId) {
|
||||
stateFasilitasKesehatan.delete.byId(selectedId);
|
||||
setModalHapus(false);
|
||||
setSelectedId(null);
|
||||
load(page, 10, search);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredData = data || []
|
||||
|
||||
if (loading || !data) {
|
||||
@@ -60,51 +72,116 @@ function ListTarifLayanan({ search }: { search: string }) {
|
||||
}
|
||||
return (
|
||||
<Box py={10}>
|
||||
<Paper bg={colors['white-1']} p={'md'}>
|
||||
<Stack>
|
||||
<JudulList
|
||||
title='List Fasilitas Kesehatan'
|
||||
href={`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis/create`}
|
||||
/>
|
||||
<Box style={{ overflowX: "auto" }}>
|
||||
<Table striped withRowBorders withTableBorder style={{ minWidth: '700px' }}>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>Fasilitas Kesehatan</TableTh>
|
||||
<TableTh>Alamat</TableTh>
|
||||
<TableTh>Jam Operasional</TableTh>
|
||||
<TableTh>Detail</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{filteredData.map((item) => (
|
||||
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
|
||||
{/* Judul + Tombol Tambah */}
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={4}>Daftar Tarif dan Layanan</Title>
|
||||
<Button
|
||||
leftSection={<IconPlus size={18} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
'/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/tarif-layanan/create'
|
||||
)
|
||||
}
|
||||
>
|
||||
Tambah Baru
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* 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}>
|
||||
<TableTd>{item.name}</TableTd>
|
||||
<TableTd>{item.specialist}</TableTd>
|
||||
<TableTd>
|
||||
<Text dangerouslySetInnerHTML={{ __html: item.jadwal }} />
|
||||
<Box w={150}>
|
||||
{item.layanan || '-'}
|
||||
</Box>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Button onClick={() => router.push(`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/${item.id}`)}>
|
||||
<IconDeviceImacCog size={25} />
|
||||
<Box w={150}>
|
||||
<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>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Stack>
|
||||
))
|
||||
) : (
|
||||
<TableTr>
|
||||
<TableTd colSpan={4}>
|
||||
<Center py={20}>
|
||||
<Text c="dimmed">
|
||||
Tidak ada fasilitas kesehatan yang cocok
|
||||
</Text>
|
||||
</Center>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
)}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Pagination */}
|
||||
<Center>
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={(newPage) => load(newPage)} // ini penting!
|
||||
onChange={(newPage) => {
|
||||
load(newPage, 10, search);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
total={totalPages}
|
||||
mt="md"
|
||||
mb="md"
|
||||
color="blue"
|
||||
radius="md"
|
||||
/>
|
||||
</Center>
|
||||
<ModalKonfirmasiHapus
|
||||
opened={modalHapus}
|
||||
onClose={() => setModalHapus(false)}
|
||||
onConfirm={handleDelete}
|
||||
text="Apakah anda yakin ingin menghapus tarif layanan ini?"
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,8 +70,8 @@ function EditGrafikHasilKepuasan() {
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error loading grafik hasil kepuasan:", err);
|
||||
toast.error("Gagal memuat data grafik hasil kepuasan");
|
||||
console.error("Error loading penderita penyakit:", err);
|
||||
toast.error("Gagal memuat data penderita penyakit");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,11 +99,11 @@ function EditGrafikHasilKepuasan() {
|
||||
setIsSubmitting(true);
|
||||
editState.update.form = { ...editState.update.form, ...formData };
|
||||
await editState.update.submit();
|
||||
toast.success('Grafik hasil kepuasan berhasil diperbarui!');
|
||||
router.push('/admin/kesehatan/data-kesehatan-warga/grafik_hasil_kepuasan');
|
||||
toast.success('penderita penyakit berhasil diperbarui!');
|
||||
router.push('/admin/kesehatan/data-kesehatan-warga/penderita_penyakit');
|
||||
} catch (err) {
|
||||
console.error('Error updating grafik hasil kepuasan:', err);
|
||||
toast.error('Terjadi kesalahan saat memperbarui grafik hasil kepuasan');
|
||||
console.error('Error updating penderita penyakit:', err);
|
||||
toast.error('Terjadi kesalahan saat memperbarui penderita penyakit');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -122,7 +122,7 @@ function EditGrafikHasilKepuasan() {
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
<Title order={4} ml="sm" c="dark">
|
||||
Edit Grafik Hasil Kepuasan
|
||||
Edit Penderita Penyakit
|
||||
</Title>
|
||||
</Group>
|
||||
|
||||
@@ -26,7 +26,7 @@ function DetailGrafikHasilKepuasan() {
|
||||
state.delete.byId(selectedId);
|
||||
setModalHapus(false);
|
||||
setSelectedId(null);
|
||||
router.push("/admin/kesehatan/data-kesehatan-warga/grafik_hasil_kepuasan");
|
||||
router.push("/admin/kesehatan/data-kesehatan-warga/penderita_penyakit");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -63,7 +63,7 @@ function DetailGrafikHasilKepuasan() {
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text fz="2xl" fw="bold" c={colors['blue-button']}>
|
||||
Detail Data Grafik Hasil Kepuasan
|
||||
Detail Data Penderita Penyakit
|
||||
</Text>
|
||||
|
||||
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
|
||||
@@ -118,7 +118,7 @@ function DetailGrafikHasilKepuasan() {
|
||||
color="green"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/admin/kesehatan/data-kesehatan-warga/grafik_hasil_kepuasan/${data.id}/edit`
|
||||
`/admin/kesehatan/data-kesehatan-warga/penderita_penyakit/${data.id}/edit`
|
||||
)
|
||||
}
|
||||
variant="light"
|
||||
@@ -40,7 +40,7 @@ function CreateGrafikHasilKepuasanMasyarakat() {
|
||||
setIsSubmitting(true);
|
||||
await stateGrafikKepuasan.create.create();
|
||||
resetForm();
|
||||
router.push("/admin/kesehatan/data-kesehatan-warga/grafik_hasil_kepuasan");
|
||||
router.push("/admin/kesehatan/data-kesehatan-warga/penderita_penyakit");
|
||||
} catch (error) {
|
||||
console.error("Error creating grafik kepuasan:", error);
|
||||
toast.error("Terjadi kesalahan saat membuat grafik kepuasan");
|
||||
@@ -62,7 +62,7 @@ function CreateGrafikHasilKepuasanMasyarakat() {
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
<Title order={4} ml="sm" c="dark">
|
||||
Tambah Grafik Hasil Kepuasan Masyarakat
|
||||
Tambah Penderita Penyakit
|
||||
</Title>
|
||||
</Group>
|
||||
|
||||
@@ -36,7 +36,7 @@ function GrafikHasilKepuasanMasyarakat() {
|
||||
<Box>
|
||||
{/* Header Search */}
|
||||
<HeaderSearch
|
||||
title='Grafik Hasil Kepuasan Masyarakat'
|
||||
title='Penderita Penyakit'
|
||||
placeholder='Cari nama atau alamat...'
|
||||
searchIcon={<IconSearch size={20} />}
|
||||
value={search}
|
||||
@@ -115,14 +115,14 @@ function ListGrafikHasilKepuasanMasyarakat({ search }: { search: string }) {
|
||||
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
|
||||
{/* Judul + Tombol Tambah */}
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={4}>Daftar Grafik Hasil Kepuasan Masyarakat</Title>
|
||||
<Title order={4}>Daftar Penderita Penyakit</Title>
|
||||
<Button
|
||||
leftSection={<IconPlus size={18} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
'/admin/kesehatan/data-kesehatan-warga/grafik_hasil_kepuasan/create'
|
||||
'/admin/kesehatan/data-kesehatan-warga/penderita_penyakit/create'
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -176,7 +176,7 @@ function ListGrafikHasilKepuasanMasyarakat({ search }: { search: string }) {
|
||||
color="blue"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/admin/kesehatan/data-kesehatan-warga/grafik_hasil_kepuasan/${item.id}`
|
||||
`/admin/kesehatan/data-kesehatan-warga/penderita_penyakit/${item.id}`
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -221,7 +221,7 @@ function ListGrafikHasilKepuasanMasyarakat({ search }: { search: string }) {
|
||||
{/* Chart */}
|
||||
<Box mt="lg" style={{ width: '100%', minWidth: 300, height: 420, minHeight: 300 }}>
|
||||
<Paper withBorder bg={colors['white-1']} p={'md'}>
|
||||
<Title pb={10} order={4}>Grafik Hasil Kepuasan Masyarakat</Title>
|
||||
<Title pb={10} order={4}>Penderita Penyakit</Title>
|
||||
{mounted && diseaseChartData.length > 0 ? (
|
||||
<Center>
|
||||
<BarChart
|
||||
@@ -1,94 +1,102 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
'use client';
|
||||
|
||||
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes';
|
||||
import colors from '@/con/colors';
|
||||
import ApiFetch from '@/lib/api-fetch';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Loader,
|
||||
ActionIcon
|
||||
} from '@mantine/core';
|
||||
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 { useEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
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() {
|
||||
const apbdesState = useProxy(apbdes);
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
jumlah: '',
|
||||
imageId: '',
|
||||
fileId: ''
|
||||
});
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [originalData, setOriginalData] = useState({
|
||||
name: "",
|
||||
jumlah: "",
|
||||
imageId: "",
|
||||
fileId: "",
|
||||
imageUrl: "",
|
||||
docUrl: "",
|
||||
});
|
||||
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
const [previewDoc, setPreviewDoc] = useState<string | null>(null);
|
||||
const [imageFile, setImageFile] = 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(() => {
|
||||
const loadData = async () => {
|
||||
const id = params?.id as string;
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
const data = await apbdesState.edit.load(id);
|
||||
const id = params?.id as string;
|
||||
if (id) {
|
||||
apbdesState.edit.load(id).then((response) => {
|
||||
const data = response as unknown as APBDesResponse;
|
||||
if (data) {
|
||||
const newForm = {
|
||||
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 || "",
|
||||
});
|
||||
|
||||
// ✅ Ambil link langsung dari response
|
||||
setPreviewImage(data.image?.link || null);
|
||||
setPreviewDoc(data.file?.link || null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error('Gagal memuat data APBDes');
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
});
|
||||
}
|
||||
}, [params?.id]);
|
||||
|
||||
// Generic Dropzone handler
|
||||
const handleDrop = (fileType: 'image' | 'doc') => (files: File[]) => {
|
||||
const file = files[0];
|
||||
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 () => {
|
||||
if (apbdesState.edit.form.items.length === 0) {
|
||||
return toast.warn('Minimal harus ada 1 item APBDes');
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
// Update global state with local form data first
|
||||
apbdesState.edit.form = { ...apbdesState.edit.form, ...formData };
|
||||
|
||||
// Helper function for uploading file
|
||||
const uploadFile = async (file: File | null) => {
|
||||
if (!file) return null;
|
||||
const res = await ApiFetch.api.fileStorage.create.post({ file, name: file.name });
|
||||
const uploaded = res.data?.data;
|
||||
if (!uploaded?.id) throw new Error('Upload gagal');
|
||||
return uploaded.id;
|
||||
};
|
||||
// Upload file baru jika ada
|
||||
if (imageFile) {
|
||||
const res = await ApiFetch.api.fileStorage.create.post({
|
||||
file: imageFile,
|
||||
name: imageFile.name,
|
||||
});
|
||||
const imageId = res.data?.data?.id;
|
||||
if (imageId) apbdesState.edit.form.imageId = imageId;
|
||||
}
|
||||
|
||||
// Upload files if selected
|
||||
const uploadedImageId = await uploadFile(imageFile);
|
||||
const uploadedDocId = await uploadFile(docFile);
|
||||
if (docFile) {
|
||||
const res = await ApiFetch.api.fileStorage.create.post({
|
||||
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;
|
||||
if (uploadedDocId) apbdesState.edit.form.fileId = uploadedDocId;
|
||||
|
||||
await apbdesState.edit.update();
|
||||
toast.success('APBDes berhasil diperbarui!');
|
||||
router.push('/admin/landing-page/APBDes');
|
||||
const success = await apbdesState.edit.update();
|
||||
if (success) {
|
||||
router.push('/admin/landing-page/apbdes');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error('Terjadi kesalahan saat memperbarui APBDes');
|
||||
console.error('Update error:', err);
|
||||
toast.error('Gagal memperbarui APBDes');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetForm = () => {
|
||||
setFormData({
|
||||
name: originalData.name,
|
||||
jumlah: originalData.jumlah,
|
||||
imageId: originalData.imageId,
|
||||
fileId: originalData.fileId,
|
||||
});
|
||||
setPreviewImage(originalData.imageUrl || null);
|
||||
setImageFile(null);
|
||||
setPreviewDoc(originalData.docUrl || null);
|
||||
setDocFile(null);
|
||||
toast.info("Form dikembalikan ke data awal");
|
||||
const handleReset = () => {
|
||||
const id = params?.id as string;
|
||||
if (id) {
|
||||
apbdesState.edit.load(id);
|
||||
setImageFile(null);
|
||||
setDocFile(null);
|
||||
setNewItem({
|
||||
kode: '',
|
||||
uraian: '',
|
||||
anggaran: 0,
|
||||
realisasi: 0,
|
||||
level: 1,
|
||||
tipe: 'pendapatan',
|
||||
});
|
||||
toast.info('Form dikembalikan ke data awal');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -160,163 +214,277 @@ function EditAPBDes() {
|
||||
</Title>
|
||||
</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">
|
||||
{/* Controlled Inputs */}
|
||||
<TextInput
|
||||
label="Nama APBDes"
|
||||
placeholder="Masukkan nama APBDes"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
{/* Header Form */}
|
||||
<NumberInput
|
||||
label="Tahun"
|
||||
value={apbdesState.edit.form.tahun || new Date().getFullYear()}
|
||||
onChange={(val) =>
|
||||
(apbdesState.edit.form.tahun = Number(val) || new Date().getFullYear())
|
||||
}
|
||||
min={2000}
|
||||
max={2100}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Jumlah Anggaran"
|
||||
placeholder="Masukkan jumlah anggaran"
|
||||
value={formData.jumlah}
|
||||
onChange={(e) => setFormData({ ...formData, jumlah: e.target.value })}
|
||||
required
|
||||
/>
|
||||
{/* Gambar & Dokumen */}
|
||||
<Stack gap="xs">
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Gambar APBDes
|
||||
</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>
|
||||
<Text fw="bold" fz="sm" mb={6}>Gambar APBDes</Text>
|
||||
<Dropzone
|
||||
onDrop={handleDrop('image')}
|
||||
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>
|
||||
{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"
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Dokumen APBDes
|
||||
</Text>
|
||||
<Dropzone
|
||||
onDrop={handleDrop('doc')}
|
||||
onReject={() => toast.error('File dokumen tidak valid')}
|
||||
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={180}>
|
||||
<Dropzone.Idle>
|
||||
<IconFile size={48} color="#868e96" stroke={1.5} />
|
||||
</Dropzone.Idle>
|
||||
<Stack gap="xs" align="center">
|
||||
<Text size="md" fw={500}>
|
||||
{previewDoc ? 'Ganti dokumen' : 'Unggah dokumen'}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Dropzone>
|
||||
{previewDoc && (
|
||||
<Box mt="sm">
|
||||
<Button
|
||||
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>
|
||||
</Dropzone>
|
||||
{previewDoc && (
|
||||
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
|
||||
<Text size="sm" c="dimmed" mb="xs">Dokumen terpilih: {docFile?.name || 'Dokumen'}</Text>
|
||||
<Button component="a" href={previewDoc} target="_blank" rel="noopener noreferrer" variant="light" leftSection={<IconFile size={16} />} size="sm">
|
||||
Lihat Dokumen
|
||||
</Button>
|
||||
{/* Tombol hapus (pojok kanan atas) */}
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{/* 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">
|
||||
{/* Tombol Batal */}
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
onClick={handleResetForm}
|
||||
>
|
||||
<Button variant="outline" color="gray" radius="md" onClick={handleReset}>
|
||||
Batal
|
||||
</Button>
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
size="md"
|
||||
disabled={apbdesState.edit.form.items.length === 0 || apbdesState.edit.loading}
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||
color: '#fff',
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan Perubahan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -325,4 +493,4 @@ function EditAPBDes() {
|
||||
);
|
||||
}
|
||||
|
||||
export default EditAPBDes;
|
||||
export default EditAPBDes;
|
||||
@@ -1,36 +1,53 @@
|
||||
'use client'
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
'use client';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
|
||||
import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text } from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import {
|
||||
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 { useParams, useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import colors from '@/con/colors';
|
||||
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
|
||||
import apbdes from '../../../_state/landing-page/apbdes';
|
||||
|
||||
|
||||
|
||||
function DetailAPBDes() {
|
||||
const apbdesState = useProxy(apbdes)
|
||||
const [modalHapus, setModalHapus] = useState(false)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
|
||||
useShallowEffect(() => {
|
||||
apbdesState.findUnique.load(params?.id as string)
|
||||
}, [])
|
||||
const apbdesState = useProxy(apbdes);
|
||||
const [modalHapus, setModalHapus] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!params?.id) return;
|
||||
apbdesState.findUnique.load(params.id as string);
|
||||
}, [params?.id]);
|
||||
|
||||
const handleHapus = () => {
|
||||
if (selectedId) {
|
||||
apbdesState.delete.byId(selectedId)
|
||||
setModalHapus(false)
|
||||
setSelectedId(null)
|
||||
router.push("/admin/landing-page/APBDes")
|
||||
apbdesState.delete.byId(selectedId);
|
||||
setModalHapus(false);
|
||||
setSelectedId(null);
|
||||
router.push('/admin/landing-page/apbdes');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!apbdesState.findUnique.data) {
|
||||
return (
|
||||
@@ -42,6 +59,11 @@ function DetailAPBDes() {
|
||||
|
||||
const data = apbdesState.findUnique.data;
|
||||
|
||||
// Helper: indentasi berdasarkan level
|
||||
const getIndent = (level: number) => ({
|
||||
paddingLeft: `${(level - 1) * 20}px`,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box py={10}>
|
||||
<Button
|
||||
@@ -55,7 +77,7 @@ function DetailAPBDes() {
|
||||
|
||||
<Paper
|
||||
withBorder
|
||||
w={{ base: "100%", md: "60%" }}
|
||||
w={{ base: '100%', md: '100%' }}
|
||||
bg={colors['white-1']}
|
||||
p="lg"
|
||||
radius="md"
|
||||
@@ -66,16 +88,21 @@ function DetailAPBDes() {
|
||||
Detail APBDes
|
||||
</Text>
|
||||
|
||||
{/* Info Header */}
|
||||
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
|
||||
<Stack gap="md">
|
||||
<Box>
|
||||
<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>
|
||||
<Text fz="lg" fw="bold">Jumlah Anggaran</Text>
|
||||
<Text fz="md" c="dimmed">Rp. {data.jumlah || '-'}</Text>
|
||||
<Text fz="lg" fw="bold">Tahun</Text>
|
||||
<Text fz="md" c="dimmed">
|
||||
{data.tahun || '-'}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
@@ -117,32 +144,80 @@ function DetailAPBDes() {
|
||||
</Box>
|
||||
|
||||
<Group gap="sm" mt="md">
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
setSelectedId(data.id);
|
||||
setModalHapus(true);
|
||||
}}
|
||||
disabled={apbdesState.delete.loading}
|
||||
variant="light"
|
||||
radius="md"
|
||||
size="md"
|
||||
>
|
||||
<IconTrash size={20} />
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
setSelectedId(data.id);
|
||||
setModalHapus(true);
|
||||
}}
|
||||
disabled={apbdesState.delete.loading}
|
||||
variant="light"
|
||||
radius="md"
|
||||
size="md"
|
||||
>
|
||||
<IconTrash size={20} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color="green"
|
||||
onClick={() => router.push(`/admin/landing-page/APBDes/${data.id}/edit`)}
|
||||
variant="light"
|
||||
radius="md"
|
||||
size="md"
|
||||
>
|
||||
<IconEdit size={20} />
|
||||
</Button>
|
||||
<Button
|
||||
color="green"
|
||||
onClick={() => router.push(`/admin/landing-page/apbdes/${data.id}/edit`)}
|
||||
variant="light"
|
||||
radius="md"
|
||||
size="md"
|
||||
>
|
||||
<IconEdit size={20} />
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</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>
|
||||
</Paper>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
'use client';
|
||||
import colors from '@/con/colors';
|
||||
@@ -13,46 +14,76 @@ import {
|
||||
TextInput,
|
||||
Title,
|
||||
Loader,
|
||||
ActionIcon
|
||||
ActionIcon,
|
||||
NumberInput,
|
||||
Select,
|
||||
Table,
|
||||
Badge,
|
||||
} from '@mantine/core';
|
||||
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 { useEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
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() {
|
||||
const router = useRouter();
|
||||
const stateAPBDes = useProxy(apbdes)
|
||||
const stateAPBDes = useProxy(apbdes);
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
const [previewDoc, setPreviewDoc] = useState<string | null>(null);
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [docFile, setDocFile] = useState<File | null>(null);
|
||||
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(() => {
|
||||
stateAPBDes.findMany.load();
|
||||
}, []);
|
||||
|
||||
const resetForm = () => {
|
||||
stateAPBDes.create.form = {
|
||||
name: "",
|
||||
jumlah: "",
|
||||
imageId: "",
|
||||
fileId: "",
|
||||
};
|
||||
stateAPBDes.create.reset();
|
||||
setImageFile(null);
|
||||
setDocFile(null);
|
||||
setPreviewImage(null);
|
||||
setPreviewDoc(null);
|
||||
setNewItem({
|
||||
kode: '',
|
||||
uraian: '',
|
||||
anggaran: 0,
|
||||
realisasi: 0,
|
||||
level: 1,
|
||||
tipe: 'pendapatan',
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!imageFile || !docFile) {
|
||||
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 {
|
||||
setIsSubmitting(true);
|
||||
@@ -68,6 +99,7 @@ function CreateAPBDes() {
|
||||
return toast.error("Gagal mengupload file");
|
||||
}
|
||||
|
||||
// Update form dengan ID file
|
||||
stateAPBDes.create.form.imageId = imageId;
|
||||
stateAPBDes.create.form.fileId = fileId;
|
||||
|
||||
@@ -75,7 +107,7 @@ function CreateAPBDes() {
|
||||
|
||||
toast.success("Berhasil menambahkan APBDes");
|
||||
resetForm();
|
||||
router.push("/admin/landing-page/APBDes");
|
||||
router.push("/admin/landing-page/apbdes");
|
||||
} catch (error) {
|
||||
console.error("Gagal submit:", error);
|
||||
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 (
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||
<Group mb="md">
|
||||
@@ -104,199 +174,290 @@ function CreateAPBDes() {
|
||||
style={{ border: '1px solid #e0e0e0' }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{/* Gambar APBDes */}
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Gambar Program Inovasi
|
||||
</Text>
|
||||
<Dropzone
|
||||
onDrop={(files) => {
|
||||
const selectedFile = files[0];
|
||||
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'] }}
|
||||
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>
|
||||
{/* Gambar & Dokumen (dipendekkan untuk fokus pada items) */}
|
||||
<Stack gap={"xs"}>
|
||||
{/* Gambar APBDes */}
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Gambar APBDes
|
||||
</Text>
|
||||
<Dropzone
|
||||
onDrop={(files) => {
|
||||
const selectedFile = files[0];
|
||||
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'] }}
|
||||
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="#868e96" stroke={1.5} />
|
||||
</Dropzone.Idle>
|
||||
<Stack gap="xs" align="center">
|
||||
<Text size="md" fw={500}>
|
||||
Seret gambar atau klik untuk memilih
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</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 */}
|
||||
{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);
|
||||
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>
|
||||
{/* 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>
|
||||
</Group>
|
||||
</Dropzone>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{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' }}
|
||||
{/* 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')}
|
||||
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
|
||||
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>
|
||||
|
||||
{/* Form Input */}
|
||||
<TextInput
|
||||
label="Nama APBDes"
|
||||
placeholder="Masukkan nama APBDes"
|
||||
value={stateAPBDes.create.form.name || ''}
|
||||
onChange={(e) => (stateAPBDes.create.form.name = e.target.value)}
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label="Jumlah Anggaran"
|
||||
placeholder="14 M / 1 T / 200 JT / 900 RB"
|
||||
value={stateAPBDes.create.form.jumlah || ''}
|
||||
onChange={(e) => (stateAPBDes.create.form.jumlah = e.target.value)}
|
||||
required
|
||||
/>
|
||||
{/* Tabel Items */}
|
||||
{stateAPBDes.create.form.items.length > 0 && (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Title order={6} mb="sm">Daftar Item ({stateAPBDes.create.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: 50 }}>Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stateAPBDes.create.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>
|
||||
<Badge size="sm" color={item.tipe === 'pendapatan' ? 'teal' : 'red'}>
|
||||
{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">
|
||||
{/* Tombol Batal */}
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
onClick={resetForm}
|
||||
>
|
||||
<Button variant="outline" color="gray" radius="md" onClick={resetForm}>
|
||||
Reset
|
||||
</Button>
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
size="md"
|
||||
disabled={stateAPBDes.create.form.items.length === 0}
|
||||
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'}
|
||||
@@ -308,4 +469,4 @@ function CreateAPBDes() {
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateAPBDes;
|
||||
export default CreateAPBDes;
|
||||
@@ -1,6 +1,23 @@
|
||||
'use client'
|
||||
'use client';
|
||||
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 { IconDeviceImacCog, IconFile, IconPlus, IconSearch } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
@@ -9,14 +26,13 @@ import { useProxy } from 'valtio/utils';
|
||||
import HeaderSearch from '../../_com/header';
|
||||
import apbdes from '../../_state/landing-page/apbdes';
|
||||
|
||||
|
||||
function APBDes() {
|
||||
const [search, setSearch] = useState('');
|
||||
return (
|
||||
<Box>
|
||||
<HeaderSearch
|
||||
title='APBDes'
|
||||
placeholder='Cari APBDes...'
|
||||
title="APBDes"
|
||||
placeholder="Cari APBDes..."
|
||||
searchIcon={<IconSearch size={20} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
@@ -27,22 +43,16 @@ function APBDes() {
|
||||
}
|
||||
|
||||
function ListAPBDes({ search }: { search: string }) {
|
||||
const listState = useProxy(apbdes)
|
||||
const listState = useProxy(apbdes);
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
data,
|
||||
page,
|
||||
totalPages,
|
||||
loading,
|
||||
load,
|
||||
} = listState.findMany
|
||||
const { data, page, totalPages, loading, load } = listState.findMany;
|
||||
|
||||
useShallowEffect(() => {
|
||||
load(page, 10, search)
|
||||
}, [page, search])
|
||||
load(page, 10, search);
|
||||
}, [page, search]);
|
||||
|
||||
const filteredData = data || []
|
||||
const filteredData = data || [];
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
@@ -61,7 +71,7 @@ function ListAPBDes({ search }: { search: string }) {
|
||||
leftSection={<IconPlus size={18} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
onClick={() => router.push('/admin/landing-page/APBDes/create')}
|
||||
onClick={() => router.push('/admin/landing-page/apbdes/create')}
|
||||
>
|
||||
Tambah Baru
|
||||
</Button>
|
||||
@@ -71,8 +81,8 @@ function ListAPBDes({ search }: { search: string }) {
|
||||
<Table highlightOnHover>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh style={{ width: '30%'}}>Nama APBDes</TableTh>
|
||||
<TableTh style={{ width: '30%' }}>Jumlah</TableTh>
|
||||
<TableTh style={{ width: '25%' }}>APBDes</TableTh>
|
||||
<TableTh style={{ width: '25%' }}>Tahun</TableTh>
|
||||
<TableTh style={{ width: '25%' }}>Dokumen</TableTh>
|
||||
<TableTh style={{ width: '25%' }}>Aksi</TableTh>
|
||||
</TableTr>
|
||||
@@ -81,53 +91,54 @@ function ListAPBDes({ search }: { search: string }) {
|
||||
{filteredData.length > 0 ? (
|
||||
filteredData.map((item) => (
|
||||
<TableTr key={item.id}>
|
||||
<TableTd style={{ width: '30%' }}>
|
||||
<Text fw={500} truncate="end">{item.name}</Text>
|
||||
</TableTd>
|
||||
<TableTd style={{ width: '30%' }}>
|
||||
<Box w={150}>
|
||||
<Text>Rp. {item.jumlah}</Text>
|
||||
</Box>
|
||||
<TableTd style={{ width: '25%' }}>
|
||||
<Text fw={500} lineClamp={1}>
|
||||
APBDes {item.tahun}
|
||||
</Text>
|
||||
</TableTd>
|
||||
<TableTd style={{ width: '25%' }}>
|
||||
<Box w={150}>
|
||||
{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>
|
||||
<Text fw={500}>{item.tahun || '-'}</Text>
|
||||
</TableTd>
|
||||
<TableTd style={{ width: '25%' }}>
|
||||
<Box w={80}>
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="blue"
|
||||
leftSection={<IconDeviceImacCog size={16} />}
|
||||
onClick={() => router.push(`/admin/landing-page/APBDes/${item.id}`)}
|
||||
fullWidth
|
||||
>
|
||||
Detail
|
||||
</Button>
|
||||
</Box>
|
||||
{item.file?.link ? (
|
||||
<Button
|
||||
component="a"
|
||||
href={item.file.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="light"
|
||||
leftSection={<IconFile size={16} />}
|
||||
size="xs"
|
||||
radius="sm"
|
||||
>
|
||||
Lihat Dokumen
|
||||
</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>
|
||||
</TableTr>
|
||||
))
|
||||
) : (
|
||||
<TableTr>
|
||||
<TableTd colSpan={4}>
|
||||
<TableTd colSpan={5}>
|
||||
<Center py={20}>
|
||||
<Text color="dimmed">Tidak ada data APBDes yang cocok</Text>
|
||||
</Center>
|
||||
@@ -152,7 +163,7 @@ function ListAPBDes({ search }: { search: string }) {
|
||||
/>
|
||||
</Center>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default APBDes;
|
||||
export default APBDes;
|
||||
12
src/app/admin/(dashboard)/landing-page/profil/_lib/sosmed.ts
Normal file
12
src/app/admin/(dashboard)/landing-page/profil/_lib/sosmed.ts
Normal 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 },
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
'use client';
|
||||
|
||||
import SelectSocialMediaEdit from '@/app/admin/(dashboard)/_com/selectSocialMediaEdit';
|
||||
import profileLandingPageState from '@/app/admin/(dashboard)/_state/landing-page/profile';
|
||||
import colors from '@/con/colors';
|
||||
import ApiFetch from '@/lib/api-fetch';
|
||||
@@ -14,7 +16,7 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Loader
|
||||
Loader,
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
|
||||
@@ -23,15 +25,45 @@ import { useEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
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() {
|
||||
const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial);
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
|
||||
const [selectedSosmed, setSelectedSosmed] = useState<SosmedKey>('facebook');
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
icon: '',
|
||||
iconUrl: '',
|
||||
imageId: '',
|
||||
});
|
||||
@@ -39,13 +71,14 @@ function EditMediaSosial() {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [originalData, setOriginalData] = useState({
|
||||
name: "",
|
||||
iconUrl: "",
|
||||
imageId: "",
|
||||
imageUrl: "",
|
||||
name: '',
|
||||
icon: '',
|
||||
iconUrl: '',
|
||||
imageId: '',
|
||||
imageUrl: '',
|
||||
});
|
||||
|
||||
// Load data by ID
|
||||
// Load Data by ID
|
||||
useEffect(() => {
|
||||
const id = params?.id as string;
|
||||
if (!id) return;
|
||||
@@ -54,81 +87,97 @@ function EditMediaSosial() {
|
||||
try {
|
||||
const data = await stateMediaSosial.update.load(id);
|
||||
|
||||
if (data) {
|
||||
// isi form awal
|
||||
const newForm = {
|
||||
name: data.name || "",
|
||||
iconUrl: data.iconUrl || "",
|
||||
imageId: data.imageId || "",
|
||||
};
|
||||
setFormData(newForm);
|
||||
if (!data) return;
|
||||
|
||||
// simpan juga versi original
|
||||
setOriginalData({
|
||||
...newForm,
|
||||
imageUrl: data.image?.link || "",
|
||||
});
|
||||
|
||||
setPreviewImage(data.image?.link || null);
|
||||
// Tentukan default/custom icon
|
||||
// Tentukan default/custom icon
|
||||
if (data.imageId) {
|
||||
setSelectedSosmed('custom');
|
||||
} else {
|
||||
// ✅ Gunakan langsung data.icon jika ada dan valid
|
||||
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);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Gagal mengambil data media sosial'
|
||||
);
|
||||
|
||||
const newForm = {
|
||||
name: data.name || '',
|
||||
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();
|
||||
}, [params?.id]);
|
||||
|
||||
const handleChange = (field: string, value: string) => {
|
||||
const handleChange = (field: keyof typeof formData, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// update global state hanya saat submit
|
||||
stateMediaSosial.update.form = { ...stateMediaSosial.update.form, ...formData };
|
||||
|
||||
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;
|
||||
|
||||
if (!uploaded?.id) return toast.error('Gagal upload gambar');
|
||||
if (!uploaded?.id) {
|
||||
toast.error('Gagal upload gambar');
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
toast.success('Media sosial berhasil diperbarui!');
|
||||
router.push('/admin/landing-page/profil/media-sosial');
|
||||
} 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');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ Tombol Batal → balikin ke data original
|
||||
const handleResetForm = () => {
|
||||
setFormData({
|
||||
name: originalData.name,
|
||||
icon: originalData.icon,
|
||||
iconUrl: originalData.iconUrl,
|
||||
imageId: originalData.imageId,
|
||||
});
|
||||
setPreviewImage(originalData.imageUrl || null);
|
||||
setFile(null);
|
||||
toast.info("Form dikembalikan ke data awal");
|
||||
toast.info('Form dikembalikan ke data awal');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
px={{ base: 'sm', md: 'lg' }}
|
||||
py="md"
|
||||
>
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||
<Group mb="md">
|
||||
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
@@ -147,80 +196,119 @@ function EditMediaSosial() {
|
||||
style={{ border: '1px solid #e0e0e0' }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{/* Upload Gambar */}
|
||||
{/* Upload / Icon */}
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Gambar Program Inovasi
|
||||
Icon / Gambar Media Sosial
|
||||
</Text>
|
||||
<Dropzone
|
||||
onDrop={(files) => {
|
||||
const selectedFile = files[0];
|
||||
if (selectedFile) {
|
||||
setFile(selectedFile);
|
||||
setPreviewImage(URL.createObjectURL(selectedFile));
|
||||
|
||||
{/* Custom Upload */}
|
||||
{/* PILIH ICON */}
|
||||
<SelectSocialMediaEdit
|
||||
value={selectedSosmed}
|
||||
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 */}
|
||||
{previewImage && (
|
||||
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
|
||||
<Image
|
||||
src={previewImage}
|
||||
alt="Preview Gambar"
|
||||
{selectedSosmed === 'custom' ? (
|
||||
<>
|
||||
<Dropzone
|
||||
onDrop={(files) => {
|
||||
const selectedFile = files[0];
|
||||
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"
|
||||
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)',
|
||||
}}
|
||||
p="xl"
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
<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 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>
|
||||
@@ -237,25 +325,17 @@ function EditMediaSosial() {
|
||||
{/* Link Media Sosial */}
|
||||
<TextInput
|
||||
label="Link Media Sosial / Nomor Telepon"
|
||||
placeholder="Masukkan link media sosial atau nomor telepon"
|
||||
placeholder="Masukkan link atau nomor telepon"
|
||||
value={formData.iconUrl}
|
||||
onChange={(e) => handleChange('iconUrl', e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Group justify="right">
|
||||
{/* Tombol Batal */}
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
onClick={handleResetForm}
|
||||
>
|
||||
<Group justify="right">
|
||||
<Button variant="outline" color="gray" radius="md" size="md" onClick={handleResetForm}>
|
||||
Batal
|
||||
</Button>
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
'use client'
|
||||
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
|
||||
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 { useState } from 'react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import { sosmedMap } from '../../_lib/sosmed';
|
||||
|
||||
function DetailMediaSosial() {
|
||||
const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial);
|
||||
@@ -16,6 +18,14 @@ function DetailMediaSosial() {
|
||||
const params = useParams();
|
||||
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(() => {
|
||||
stateMediaSosial.findUnique.load(params?.id as string);
|
||||
}, []);
|
||||
@@ -77,46 +87,47 @@ function DetailMediaSosial() {
|
||||
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Gambar</Text>
|
||||
{data.image?.link ? (
|
||||
<Image
|
||||
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"
|
||||
/>
|
||||
{(() => {
|
||||
const src = getIconSource(data);
|
||||
|
||||
) : (
|
||||
<Text fz="sm" c="dimmed">Tidak ada gambar</Text>
|
||||
)}
|
||||
if (src) {
|
||||
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>
|
||||
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
setSelectedId(data.id);
|
||||
setModalHapus(true);
|
||||
}}
|
||||
variant="light"
|
||||
radius="md"
|
||||
size="md"
|
||||
>
|
||||
<IconTrash size={20} />
|
||||
</Button>
|
||||
<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/landing-page/profil/media-sosial/${data.id}/edit`)}
|
||||
variant="light"
|
||||
radius="md"
|
||||
size="md"
|
||||
>
|
||||
<IconEdit size={20} />
|
||||
</Button>
|
||||
<Button
|
||||
color="green"
|
||||
onClick={() => router.push(`/admin/landing-page/profil/media-sosial/${data.id}/edit`)}
|
||||
variant="light"
|
||||
radius="md"
|
||||
size="md"
|
||||
>
|
||||
<IconEdit size={20} />
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
'use client';
|
||||
|
||||
import colors from '@/con/colors';
|
||||
import ApiFetch from '@/lib/api-fetch';
|
||||
import {
|
||||
@@ -22,10 +23,41 @@ import { useEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
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() {
|
||||
const router = useRouter();
|
||||
const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial);
|
||||
|
||||
const [selectedSosmed, setSelectedSosmed] = useState<SosmedKey>('facebook');
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -39,16 +71,34 @@ export default function CreateMediaSosial() {
|
||||
name: '',
|
||||
imageId: '',
|
||||
iconUrl: '',
|
||||
icon: ''
|
||||
};
|
||||
setPreviewImage(null);
|
||||
|
||||
setFile(null);
|
||||
setPreviewImage(null);
|
||||
setSelectedSosmed('facebook');
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
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) {
|
||||
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({
|
||||
@@ -59,10 +109,12 @@ export default function CreateMediaSosial() {
|
||||
const uploaded = res.data?.data;
|
||||
|
||||
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.icon = null;
|
||||
|
||||
await stateMediaSosial.create.create();
|
||||
|
||||
@@ -78,6 +130,7 @@ export default function CreateMediaSosial() {
|
||||
|
||||
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} />
|
||||
@@ -96,112 +149,110 @@ export default function CreateMediaSosial() {
|
||||
style={{ border: '1px solid #e0e0e0' }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
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>
|
||||
{/* Select Sosmed */}
|
||||
<SelectSosialMedia value={selectedSosmed} onChange={setSelectedSosmed} />
|
||||
|
||||
{/* ✅ Preview gambar + tombol X */}
|
||||
{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"
|
||||
/>
|
||||
{/* Custom icon uploader */}
|
||||
{selectedSosmed === 'custom' && (
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Upload Custom Icon
|
||||
</Text>
|
||||
|
||||
{/* 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>
|
||||
<Dropzone
|
||||
onDrop={(files) => {
|
||||
const selectedFile = files[0];
|
||||
if (selectedFile) {
|
||||
setFile(selectedFile);
|
||||
setPreviewImage(URL.createObjectURL(selectedFile));
|
||||
}
|
||||
}}
|
||||
onReject={() => toast.error('File tidak valid')}
|
||||
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 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
|
||||
label="Nama Media Sosial / Kontak"
|
||||
placeholder="Masukkan nama media sosial atau kontak"
|
||||
value={stateMediaSosial.create.form.name || ''}
|
||||
label="Nama Media Sosial"
|
||||
placeholder="Masukkan nama media sosial"
|
||||
value={stateMediaSosial.create.form.name ?? ''}
|
||||
onChange={(e) => (stateMediaSosial.create.form.name = e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Input link */}
|
||||
<TextInput
|
||||
label="Link Media Sosial / Nomor Telepon"
|
||||
placeholder="Masukkan link media sosial atau nomor telepon"
|
||||
value={stateMediaSosial.create.form.iconUrl || ''}
|
||||
label="Link / Kontak"
|
||||
placeholder="Masukkan link atau nomor"
|
||||
value={stateMediaSosial.create.form.iconUrl ?? ''}
|
||||
onChange={(e) => (stateMediaSosial.create.form.iconUrl = e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Actions */}
|
||||
<Group justify="right">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
onClick={resetForm}
|
||||
>
|
||||
<Button variant="outline" color="gray" radius="md" onClick={resetForm}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
size="md"
|
||||
onClick={handleSubmit}
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||
color: '#fff',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
'use client'
|
||||
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';
|
||||
@@ -8,6 +9,7 @@ import { useState } from 'react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import HeaderSearch from '../../../_com/header';
|
||||
import profileLandingPageState from '../../../_state/landing-page/profile';
|
||||
import { sosmedMap } from '../_lib/sosmed';
|
||||
|
||||
function MediaSosial() {
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -29,6 +31,14 @@ function ListMediaSosial({ search }: { search: string }) {
|
||||
const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial)
|
||||
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 {
|
||||
data,
|
||||
page,
|
||||
@@ -56,9 +66,9 @@ function ListMediaSosial({ search }: { search: string }) {
|
||||
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<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')}>
|
||||
Tambah Baru
|
||||
</Button>
|
||||
<Button leftSection={<IconPlus size={18} />} color="blue" variant="light" onClick={() => router.push('/admin/landing-page/profil/media-sosial/create')}>
|
||||
Tambah Baru
|
||||
</Button>
|
||||
</Group>
|
||||
<Box style={{ overflowX: "auto" }}>
|
||||
<Table highlightOnHover>
|
||||
@@ -77,13 +87,26 @@ function ListMediaSosial({ search }: { search: string }) {
|
||||
<TableTd style={{ width: '25%', }}>
|
||||
<Text fw={500} truncate="end" lineClamp={1}>{item.name}</Text>
|
||||
</TableTd>
|
||||
<TableTd style={{ width: '20%', }}>
|
||||
<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" />
|
||||
) : (
|
||||
<Box bg={colors['blue-button']} w="100%" h="100%" />
|
||||
)}
|
||||
<TableTd style={{ width: '20%' }}>
|
||||
<Box w={50} h={50} style={{ borderRadius: 8, overflow: 'hidden' }}>
|
||||
|
||||
{(() => {
|
||||
const src = getIconSource(item);
|
||||
|
||||
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>
|
||||
</TableTd>
|
||||
<TableTd style={{ width: '20%', }}>
|
||||
|
||||
@@ -30,12 +30,13 @@ function Page() {
|
||||
return (
|
||||
<Paper bg={colors['white-1']} p="lg" radius="md" shadow="sm">
|
||||
<Stack gap="md">
|
||||
<Grid align="center">
|
||||
<Grid>
|
||||
<GridCol span={{ base: 12, md: 11 }}>
|
||||
<Title order={3} c={colors['blue-button']}>Preview Profil PPID</Title>
|
||||
</GridCol>
|
||||
<GridCol span={{ base: 12, md: 1 }}>
|
||||
<Button
|
||||
w={{base: '100%', md: "110%"}}
|
||||
c="green"
|
||||
variant="light"
|
||||
leftSection={<IconEdit size={18} stroke={2} />}
|
||||
|
||||
34
src/app/admin/(dashboard)/user&role/_com/dynamicNavbar.ts
Normal file
34
src/app/admin/(dashboard)/user&role/_com/dynamicNavbar.ts
Normal 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 [];
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
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 React, { useEffect, useState } from 'react';
|
||||
|
||||
@@ -23,6 +23,12 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
|
||||
href: "/admin/user&role/role",
|
||||
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);
|
||||
|
||||
129
src/app/admin/(dashboard)/user&role/menu-access/page.tsx
Normal file
129
src/app/admin/(dashboard)/user&role/menu-access/page.tsx
Normal 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
|
||||
@@ -1,7 +1,8 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
'use client'
|
||||
|
||||
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 { useParams, useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
@@ -9,6 +10,7 @@ import { toast } from 'react-toastify';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import user from '../../../_state/user/user-state';
|
||||
|
||||
|
||||
function EditRole() {
|
||||
const stateRole = useProxy(user.roleState);
|
||||
const router = useRouter();
|
||||
@@ -17,46 +19,37 @@ function EditRole() {
|
||||
// Controlled local state
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
permissions: [] as string[],
|
||||
});
|
||||
|
||||
const [originalData, setOriginalData] = useState({
|
||||
name: '',
|
||||
permissions: [] as string[],
|
||||
});
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Load role data
|
||||
const loadRole = useCallback(async (id: string) => {
|
||||
try {
|
||||
const data = await stateRole.update.load(id);
|
||||
if (data) {
|
||||
setFormData({
|
||||
name: data.name || '',
|
||||
permissions: data.permissions || [],
|
||||
});
|
||||
setOriginalData({
|
||||
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');
|
||||
const data = await stateRole.update.load(id);
|
||||
|
||||
if (data) {
|
||||
setFormData({
|
||||
name: data.name ?? '',
|
||||
});
|
||||
|
||||
setOriginalData({
|
||||
name: data.name ?? '',
|
||||
});
|
||||
}
|
||||
}, [stateRole.update]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
stateRole.findMany.load(); // Load permissions/options
|
||||
const id = params?.id as string;
|
||||
if (id) loadRole(id);
|
||||
}, [params?.id, loadRole, stateRole.findMany]);
|
||||
stateRole.findMany.load(); // load permission
|
||||
if (params?.id) loadRole(params.id as string);
|
||||
}, [params?.id]);
|
||||
|
||||
const handleResetForm = () => {
|
||||
setFormData({
|
||||
name: originalData.name,
|
||||
permissions: originalData.permissions,
|
||||
});
|
||||
toast.info("Form dikembalikan ke data awal");
|
||||
};
|
||||
@@ -66,10 +59,6 @@ function EditRole() {
|
||||
toast.error('Nama role tidak boleh kosong');
|
||||
return;
|
||||
}
|
||||
if (!formData.permissions.length) {
|
||||
toast.error('Pilih minimal satu permission');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
@@ -77,7 +66,6 @@ function EditRole() {
|
||||
stateRole.update.form = {
|
||||
...stateRole.update.form,
|
||||
name: formData.name,
|
||||
permissions: formData.permissions,
|
||||
};
|
||||
await stateRole.update.update();
|
||||
toast.success('Role berhasil diperbarui!');
|
||||
@@ -116,24 +104,7 @@ function EditRole() {
|
||||
label={<Text fw="bold" fz="sm">Nama Role</Text>}
|
||||
placeholder="Masukkan nama role"
|
||||
/>
|
||||
<MultiSelect
|
||||
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">
|
||||
<Group justify="right">
|
||||
{/* Tombol Batal */}
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -5,9 +5,8 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
MultiSelect,
|
||||
Paper,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
TextInput,
|
||||
Title
|
||||
@@ -15,9 +14,9 @@ import {
|
||||
import { IconArrowBack } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import user from '../../../_state/user/user-state';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
|
||||
export default function CreateRole() {
|
||||
@@ -31,8 +30,7 @@ export default function CreateRole() {
|
||||
|
||||
const resetForm = () => {
|
||||
stateRole.create.form = {
|
||||
name: '',
|
||||
permissions: [],
|
||||
name: ''
|
||||
};
|
||||
};
|
||||
|
||||
@@ -80,28 +78,6 @@ export default function CreateRole() {
|
||||
required
|
||||
/>
|
||||
</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">
|
||||
<Button
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
'use client'
|
||||
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 { IconCheck, IconSearch, IconX } from '@tabler/icons-react';
|
||||
import { IconCheck, IconSearch, IconTrash, IconX } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import HeaderSearch from '../../_com/header';
|
||||
import { ModalKonfirmasiHapus } from '../../_com/modalKonfirmasiHapus';
|
||||
import user from '../../_state/user/user-state';
|
||||
|
||||
import { authStore } from '@/store/authStore';
|
||||
|
||||
function User() {
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -27,9 +27,10 @@ function User() {
|
||||
}
|
||||
|
||||
function ListUser({ search }: { search: string }) {
|
||||
const stateUser = useProxy(user.userState)
|
||||
const [modalHapus, setModalHapus] = useState(false)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const stateUser = useProxy(user.userState);
|
||||
const stateRole = useProxy(user.roleState);
|
||||
const [modalHapus, setModalHapus] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -41,20 +42,105 @@ function ListUser({ search }: { search: string }) {
|
||||
|
||||
const handleDelete = () => {
|
||||
if (selectedId) {
|
||||
stateUser.delete.submit(selectedId)
|
||||
setModalHapus(false)
|
||||
setSelectedId(null)
|
||||
|
||||
stateUser.findMany.load()
|
||||
stateUser.deleteUser.delete(selectedId);
|
||||
setModalHapus(false);
|
||||
setSelectedId(null);
|
||||
stateUser.findMany.load();
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
useShallowEffect(() => {
|
||||
load(page, 10, search)
|
||||
}, [page, search])
|
||||
stateRole.findMany.load();
|
||||
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) {
|
||||
return (
|
||||
@@ -78,25 +164,51 @@ function ListUser({ search }: { search: string }) {
|
||||
<TableTh style={{ width: '20%' }}>Nomor</TableTh>
|
||||
<TableTh style={{ width: '20%' }}>Role</TableTh>
|
||||
<TableTh style={{ width: '15%' }}>Aktif / Nonaktif</TableTh>
|
||||
<TableTh style={{ width: '15%' }}>Hapus</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{filteredData.length > 0 ? (
|
||||
filteredData.map((item) => (
|
||||
<TableTr key={item.id}>
|
||||
<TableTd style={{ width: '25%', }}>
|
||||
<Text fw={500} truncate="end" lineClamp={1}>{item.username}</Text>
|
||||
<TableTd style={{ width: '25%' }}>
|
||||
<Text fw={500} truncate="end" lineClamp={1}>
|
||||
{item.username}
|
||||
</Text>
|
||||
</TableTd>
|
||||
<TableTd style={{ width: '20%', }}>
|
||||
<TableTd style={{ width: '20%' }}>
|
||||
<Text truncate fz="sm" c="dimmed">
|
||||
{item.nomor}
|
||||
</Text>
|
||||
</TableTd>
|
||||
<TableTd style={{ width: '20%', }}>
|
||||
<Text truncate fz="sm" c="dimmed">
|
||||
{item.role.name}
|
||||
</Text>
|
||||
<TableTd style={{ width: '20%' }}>
|
||||
<Select
|
||||
placeholder="Pilih role"
|
||||
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 style={{ width: '15%' }}>
|
||||
<Tooltip
|
||||
label={item.isActive ? "Nonaktifkan user" : "Aktifkan user"}
|
||||
@@ -105,22 +217,34 @@ function ListUser({ search }: { search: string }) {
|
||||
<Button
|
||||
variant="light"
|
||||
color={item.isActive ? "green" : "red"}
|
||||
onClick={async () => {
|
||||
await stateUser.updateActive.submit(item.id, !item.isActive)
|
||||
stateUser.findMany.load(page, 10, search)
|
||||
}}
|
||||
onClick={() => handleToggleActive(item.id, item.isActive)}
|
||||
disabled={stateUser.update.loading}
|
||||
>
|
||||
{item.isActive ? <IconCheck size={20} /> : <IconX size={20} />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</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>
|
||||
<TableTd colSpan={4}>
|
||||
<TableTd colSpan={5}>
|
||||
<Center py={20}>
|
||||
<Text color="dimmed">Tidak ada data user yang cocok</Text>
|
||||
<Text c="dimmed">Tidak ada data user yang cocok</Text>
|
||||
</Center>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
@@ -129,6 +253,7 @@ function ListUser({ search }: { search: string }) {
|
||||
</Table>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Center>
|
||||
<Pagination
|
||||
value={page}
|
||||
@@ -143,6 +268,7 @@ function ListUser({ search }: { search: string }) {
|
||||
radius="md"
|
||||
/>
|
||||
</Center>
|
||||
|
||||
{/* Modal Konfirmasi Hapus */}
|
||||
<ModalKonfirmasiHapus
|
||||
opened={modalHapus}
|
||||
@@ -154,4 +280,4 @@ function ListUser({ search }: { search: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default User;
|
||||
export default User;
|
||||
@@ -1,4 +1,4 @@
|
||||
export const navBar = [
|
||||
export const devBar = [
|
||||
{
|
||||
id: "Landing Page",
|
||||
name: "Landing Page",
|
||||
@@ -27,7 +27,7 @@ export const navBar = [
|
||||
{
|
||||
id: "Landing_Page_5",
|
||||
name: "APBDes",
|
||||
path: "/admin/landing-page/APBDes"
|
||||
path: "/admin/landing-page/apbdes"
|
||||
},
|
||||
{
|
||||
id: "Landing_Page_6",
|
||||
@@ -84,7 +84,6 @@ export const navBar = [
|
||||
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
id: "Desa",
|
||||
name: "Desa",
|
||||
@@ -92,8 +91,8 @@ export const navBar = [
|
||||
children: [
|
||||
{
|
||||
id: "Desa_1",
|
||||
name: "Profile",
|
||||
path: "/admin/desa/profile/profile-desa"
|
||||
name: "Profil",
|
||||
path: "/admin/desa/profil/profil-desa"
|
||||
},
|
||||
{
|
||||
id: "Desa_2",
|
||||
@@ -336,7 +335,824 @@ export const navBar = [
|
||||
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: "Profil",
|
||||
path: "/admin/desa/profil/profil-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: "Profil",
|
||||
path: "/admin/desa/profil/profil-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",
|
||||
name: "Pendidikan",
|
||||
path: "",
|
||||
@@ -378,4 +1194,4 @@ export const navBar = [
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
9
src/app/admin/_com/navigationByRole.ts
Normal file
9
src/app/admin/_com/navigationByRole.ts
Normal 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;
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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'
|
||||
|
||||
import colors from "@/con/colors";
|
||||
import { authStore } from "@/store/authStore";
|
||||
import {
|
||||
ActionIcon,
|
||||
AppShell,
|
||||
@@ -8,9 +405,11 @@ import {
|
||||
AppShellMain,
|
||||
AppShellNavbar,
|
||||
Burger,
|
||||
Center,
|
||||
Flex,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
NavLink,
|
||||
ScrollArea,
|
||||
Text,
|
||||
@@ -26,13 +425,139 @@ import {
|
||||
import _ from "lodash";
|
||||
import Link from "next/link";
|
||||
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 }) {
|
||||
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));
|
||||
|
||||
|
||||
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 (
|
||||
<AppShell
|
||||
@@ -48,6 +573,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
}}
|
||||
padding="md"
|
||||
>
|
||||
{/* ... rest of your JSX (Header, Navbar, Main) sama seperti sebelumnya ... */}
|
||||
<AppShellHeader
|
||||
style={{
|
||||
background: "linear-gradient(90deg, #ffffff, #f9fbff)",
|
||||
@@ -66,16 +592,9 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
h={{ base: 32, sm: 40 }}
|
||||
radius="md"
|
||||
loading="lazy"
|
||||
style={{
|
||||
minWidth: '32px',
|
||||
height: 'auto',
|
||||
}}
|
||||
style={{ minWidth: '32px', height: 'auto' }}
|
||||
/>
|
||||
<Text
|
||||
fw={700}
|
||||
c={colors["blue-button"]}
|
||||
fz={{ base: 'md', sm: 'xl' }}
|
||||
>
|
||||
<Text fw={700} c={colors["blue-button"]} fz={{ base: 'md', sm: 'xl' }}>
|
||||
Admin Darmasaba
|
||||
</Text>
|
||||
</Flex>
|
||||
@@ -83,63 +602,22 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
<Group gap="xs">
|
||||
{!desktopOpened && (
|
||||
<Tooltip label="Buka Navigasi" position="bottom" withArrow>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
radius="xl"
|
||||
size="lg"
|
||||
onClick={toggleDesktop}
|
||||
color={colors["blue-button"]}
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
<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 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={() => {
|
||||
router.push("/darmasaba");
|
||||
}}
|
||||
color={colors["blue-button"]}
|
||||
radius="xl"
|
||||
size="lg"
|
||||
variant="gradient"
|
||||
gradient={{ from: colors["blue-button"], to: "#228be6" }}
|
||||
>
|
||||
<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>
|
||||
@@ -147,75 +625,17 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
</Group>
|
||||
</AppShellHeader>
|
||||
|
||||
<AppShellNavbar
|
||||
component={ScrollArea}
|
||||
style={{
|
||||
background: "#ffffff",
|
||||
borderRight: `1px solid ${colors["blue-button"]}20`,
|
||||
}}
|
||||
p={{ base: 'xs', sm: 'sm' }}
|
||||
>
|
||||
<AppShellNavbar component={ScrollArea} style={{ background: "#ffffff", borderRight: `1px solid ${colors["blue-button"]}20` }} p={{ base: 'xs', sm: 'sm' }}>
|
||||
{/* ... Navbar content sama seperti sebelumnya ... */}
|
||||
<AppShell.Section p="sm">
|
||||
{navBar.map((v, k) => {
|
||||
{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}
|
||||
>
|
||||
<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)
|
||||
);
|
||||
|
||||
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 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>
|
||||
@@ -225,18 +645,8 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
<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"]}
|
||||
>
|
||||
<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>
|
||||
@@ -244,14 +654,9 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
</AppShell.Section>
|
||||
</AppShellNavbar>
|
||||
|
||||
<AppShellMain
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #fdfdfd, #f6f9fc)",
|
||||
minHeight: "100vh",
|
||||
}}
|
||||
>
|
||||
<AppShellMain style={{ background: "linear-gradient(180deg, #fdfdfd, #f6f9fc)", minHeight: "100vh" }}>
|
||||
{children}
|
||||
</AppShellMain>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomOTP } from "../_lib/randomOTP";
|
||||
|
||||
@@ -12,52 +11,70 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
const codeOtp = randomOTP();
|
||||
const body = await req.json();
|
||||
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 (sendWa.status !== "success")
|
||||
if (!nomor || typeof nomor !== "string") {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Nomor Whatsapp Tidak Aktif" },
|
||||
{ success: false, message: "Nomor tidak valid" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const createOtpId = await prisma.kodeOtp.create({
|
||||
data: {
|
||||
nomor: nomor,
|
||||
otp: codeOtp,
|
||||
},
|
||||
// Cek apakah user sudah terdaftar
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { nomor },
|
||||
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(
|
||||
{ success: false, message: "Gagal mengirim kode OTP" },
|
||||
{ success: false, message: "Nomor WhatsApp tidak aktif" },
|
||||
{ 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(
|
||||
{
|
||||
success: true,
|
||||
message: "Kode verifikasi terkirim",
|
||||
kodeId: createOtpId.id,
|
||||
kodeId: otpRecord.id,
|
||||
isRegistered, // 🔑 Ini kunci untuk frontend tahu harus ke register atau verifikasi
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.log("Error Login", error);
|
||||
console.error("Error Login:", error);
|
||||
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 }
|
||||
);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
30
src/app/api/[[...slugs]]/_lib/auth/me/route.ts
Normal file
30
src/app/api/[[...slugs]]/_lib/auth/me/route.ts
Normal 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 });
|
||||
}
|
||||
104
src/app/api/[[...slugs]]/_lib/auth/register/route.ts
Normal file
104
src/app/api/[[...slugs]]/_lib/auth/register/route.ts
Normal 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();
|
||||
// }
|
||||
// }
|
||||
@@ -6,33 +6,24 @@ import path from "path";
|
||||
const beritaDelete = async (context: Context) => {
|
||||
const id = context.params?.id as string;
|
||||
|
||||
if (!id) {
|
||||
return {
|
||||
status: 400,
|
||||
body: "ID tidak diberikan",
|
||||
};
|
||||
}
|
||||
if (!id) return { status: 400, body: "ID tidak diberikan" };
|
||||
|
||||
const berita = await prisma.berita.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
image: true,
|
||||
kategoriBerita: true, // pastikan relasi image sudah ada di prisma schema
|
||||
},
|
||||
include: { image: true, kategoriBerita: true },
|
||||
});
|
||||
|
||||
if (!berita) {
|
||||
return {
|
||||
status: 404,
|
||||
body: "Berita tidak ditemukan",
|
||||
};
|
||||
}
|
||||
if (!berita) 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) {
|
||||
try {
|
||||
const filePath = path.join(berita.image.path, berita.image.name);
|
||||
await fs.unlink(filePath);
|
||||
|
||||
await prisma.fileStorage.delete({
|
||||
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 {
|
||||
success: true,
|
||||
message: "Berita dan file terkait berhasil dihapus",
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
export default beritaDelete;
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
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 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 {
|
||||
name,
|
||||
@@ -24,25 +22,30 @@ const fasilitasKesehatanCreate = async (context: Context) => {
|
||||
tarifDanLayanan,
|
||||
} = body;
|
||||
|
||||
// Buat masing-masing relasi terlebih dahulu
|
||||
const [createdInformasiUmum, createdLayananUnggulan, createdDokter, createdPendukung, createdProsedur, createdTarif] = await Promise.all([
|
||||
prisma.informasiUmum.create({ data: informasiUmum }),
|
||||
prisma.layananUnggulan.create({ data: layananUnggulan }),
|
||||
prisma.dokterdanTenagaMedis.create({ data: dokterdanTenagaMedis }),
|
||||
prisma.fasilitasPendukung.create({ data: fasilitasPendukung }),
|
||||
prisma.prosedurPendaftaran.create({ data: prosedurPendaftaran }),
|
||||
prisma.tarifDanLayanan.create({ data: tarifDanLayanan }),
|
||||
]);
|
||||
// CREATE SINGLE DATA
|
||||
const [createdInformasi, createdUnggulan, createdPendukung, createdProsedur] =
|
||||
await Promise.all([
|
||||
prisma.informasiUmum.create({ data: informasiUmum }),
|
||||
prisma.layananUnggulan.create({ data: layananUnggulan }),
|
||||
prisma.fasilitasPendukung.create({ data: fasilitasPendukung }),
|
||||
prisma.prosedurPendaftaran.create({ data: prosedurPendaftaran }),
|
||||
]);
|
||||
|
||||
// ✅ CUKUP CONNECT KE ID YANG SUDAH ADA
|
||||
const fasilitas = await prisma.fasilitasKesehatan.create({
|
||||
data: {
|
||||
name,
|
||||
informasiUmumId: createdInformasiUmum.id,
|
||||
layananUnggulanId: createdLayananUnggulan.id,
|
||||
dokterdanTenagaMedisId: createdDokter.id,
|
||||
informasiUmumId: createdInformasi.id,
|
||||
layananUnggulanId: createdUnggulan.id,
|
||||
fasilitasPendukungId: createdPendukung.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: {
|
||||
informasiumum: true,
|
||||
|
||||
@@ -2,42 +2,14 @@ import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
const fasilitasKesehatanDelete = async (context: Context) => {
|
||||
const id = context.params?.id as string;
|
||||
const id = context.params?.id as string;
|
||||
|
||||
if (!id) {
|
||||
return {
|
||||
status: 400,
|
||||
message: "ID tidak ditemukan",
|
||||
}
|
||||
}
|
||||
const data = await prisma.fasilitasKesehatan.findUnique({ where: { id } });
|
||||
if (!data) return { status: 404, message: "Data tidak ditemukan" };
|
||||
|
||||
const fasilitasKesehatan = await prisma.fasilitasKesehatan.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
informasiumum: true,
|
||||
layananunggulan: true,
|
||||
dokterdantenagamedis: true,
|
||||
fasilitaspendukung: true,
|
||||
prosedurpendaftaran: true,
|
||||
tarifdanlayanan: true,
|
||||
}
|
||||
})
|
||||
await prisma.fasilitasKesehatan.delete({ where: { id } });
|
||||
|
||||
if (!fasilitasKesehatan) {
|
||||
return {
|
||||
status: 404,
|
||||
message: "Fasilitas kesehatan tidak ditemukan",
|
||||
}
|
||||
}
|
||||
return { success: true, message: "Berhasil dihapus" };
|
||||
};
|
||||
|
||||
await prisma.fasilitasKesehatan.delete({
|
||||
where: { id },
|
||||
})
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
success: true,
|
||||
message: "Fasilitas kesehatan berhasil dihapus",
|
||||
}
|
||||
}
|
||||
export default fasilitasKesehatanDelete
|
||||
export default fasilitasKesehatanDelete;
|
||||
|
||||
@@ -5,6 +5,11 @@ type FormCreate = {
|
||||
name: string;
|
||||
specialist: string;
|
||||
jadwal: string;
|
||||
jadwalLibur: string;
|
||||
jamBukaOperasional: string;
|
||||
jamTutupOperasional: string;
|
||||
jamBukaLibur: string;
|
||||
jamTutupLibur: string;
|
||||
};
|
||||
|
||||
export default async function dokterTenagaMedisCreate(context: Context) {
|
||||
@@ -15,11 +20,21 @@ export default async function dokterTenagaMedisCreate(context: Context) {
|
||||
name: body.name,
|
||||
specialist: body.specialist,
|
||||
jadwal: body.jadwal,
|
||||
jadwalLibur: body.jadwalLibur,
|
||||
jamBukaOperasional: body.jamBukaOperasional,
|
||||
jamTutupOperasional: body.jamTutupOperasional,
|
||||
jamBukaLibur: body.jamBukaLibur,
|
||||
jamTutupLibur: body.jamTutupLibur,
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
specialist: true,
|
||||
jadwal: true,
|
||||
jadwalLibur: true,
|
||||
jamBukaOperasional: true,
|
||||
jamTutupOperasional: true,
|
||||
jamBukaLibur: true,
|
||||
jamTutupLibur: true,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ async function dokterTenagaMedisFindMany(context: Context) {
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
{ specialist: { contains: search, mode: 'insensitive' } },
|
||||
{ jadwal: { contains: search, mode: 'insensitive' } },
|
||||
{ jadwalLibur: { contains: search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,11 @@ const DokterTenagaMedis = new Elysia({
|
||||
name: t.String(),
|
||||
specialist: t.String(),
|
||||
jadwal: t.String(),
|
||||
jadwalLibur: t.String(),
|
||||
jamBukaOperasional: t.String(),
|
||||
jamTutupOperasional: t.String(),
|
||||
jamBukaLibur: t.String(),
|
||||
jamTutupLibur: t.String(),
|
||||
}),
|
||||
})
|
||||
.put("/:id", dokterTenagaMedisUpdate, {
|
||||
@@ -26,6 +31,11 @@ const DokterTenagaMedis = new Elysia({
|
||||
name: t.String(),
|
||||
specialist: t.String(),
|
||||
jadwal: t.String(),
|
||||
jadwalLibur: t.String(),
|
||||
jamBukaOperasional: t.String(),
|
||||
jamTutupOperasional: t.String(),
|
||||
jamBukaLibur: t.String(),
|
||||
jamTutupLibur: t.String(),
|
||||
}),
|
||||
})
|
||||
.delete("/del/:id", dokterTenagaMedisDelete)
|
||||
|
||||
@@ -5,6 +5,11 @@ type FormUpdate = {
|
||||
name: string;
|
||||
specialist: string;
|
||||
jadwal: string;
|
||||
jadwalLibur: string;
|
||||
jamBukaOperasional: string;
|
||||
jamTutupOperasional: string;
|
||||
jamBukaLibur: string;
|
||||
jamTutupLibur: string;
|
||||
}
|
||||
|
||||
export default async function dokterTenagaMedisUpdate(context: Context) {
|
||||
@@ -18,6 +23,12 @@ export default async function dokterTenagaMedisUpdate(context: Context) {
|
||||
name: body.name,
|
||||
specialist: body.specialist,
|
||||
jadwal: body.jadwal,
|
||||
jadwalLibur: body.jadwalLibur,
|
||||
jamBukaOperasional: body.jamBukaOperasional,
|
||||
jamTutupOperasional: body.jamTutupOperasional,
|
||||
jamBukaLibur: body.jamBukaLibur,
|
||||
jamTutupLibur: body.jamTutupLibur,
|
||||
|
||||
},
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import fasilitasKesehatanCreate from "./create";
|
||||
import findManyFasilitasKesehatan from "./findMany";
|
||||
import fasilitasKesehatanFindMany from "./findMany";
|
||||
import findUniqueFasilitasKesehatan from "./findUnique";
|
||||
import fasilitasKesehatanUpdate from "./updt";
|
||||
import fasilitasKesehatanDelete from "./del";
|
||||
@@ -9,42 +9,61 @@ const FasilitasKesehatan = new Elysia({
|
||||
prefix: "fasilitas-kesehatan",
|
||||
tags: ["Kesehatan/Fasilitas Kesehatan"],
|
||||
})
|
||||
|
||||
// ==========================
|
||||
// CREATE
|
||||
// ==========================
|
||||
.post("/create", fasilitasKesehatanCreate, {
|
||||
body: t.Object({
|
||||
name: t.String(),
|
||||
|
||||
informasiUmum: t.Object({
|
||||
fasilitas: t.String(),
|
||||
alamat: t.String(),
|
||||
jamOperasional: t.String(),
|
||||
}),
|
||||
|
||||
layananUnggulan: t.Object({
|
||||
content: t.String(),
|
||||
}),
|
||||
dokterdanTenagaMedis: t.Object({
|
||||
name: t.String(),
|
||||
specialist: t.String(),
|
||||
jadwal: t.String(),
|
||||
}),
|
||||
|
||||
dokterdanTenagaMedis: t.Array(t.String()), // FIX karena create pakai array of string
|
||||
|
||||
fasilitasPendukung: t.Object({
|
||||
content: t.String(),
|
||||
}),
|
||||
|
||||
prosedurPendaftaran: t.Object({
|
||||
content: t.String(),
|
||||
}),
|
||||
tarifDanLayanan: t.Object({
|
||||
layanan: t.String(),
|
||||
tarif: t.String(),
|
||||
}),
|
||||
|
||||
tarifDanLayanan: t.Array(t.String()), // FIX karena create pakai array of string
|
||||
}),
|
||||
})
|
||||
.get("/find-many", findManyFasilitasKesehatan)
|
||||
|
||||
// ==========================
|
||||
// FIND MANY
|
||||
// ==========================
|
||||
.get("/find-many", fasilitasKesehatanFindMany)
|
||||
|
||||
// ==========================
|
||||
// DELETE
|
||||
// ==========================
|
||||
.delete("/del/:id", fasilitasKesehatanDelete)
|
||||
|
||||
// ==========================
|
||||
// FIND UNIQUE
|
||||
// ==========================
|
||||
.get("/:id", async (context) => {
|
||||
const response = await findUniqueFasilitasKesehatan(
|
||||
new Request(context.request)
|
||||
);
|
||||
return response;
|
||||
})
|
||||
|
||||
// ==========================
|
||||
// UPDATE
|
||||
// ==========================
|
||||
.put(
|
||||
"/:id",
|
||||
async (context) => {
|
||||
@@ -54,29 +73,30 @@ const FasilitasKesehatan = new Elysia({
|
||||
{
|
||||
body: t.Object({
|
||||
name: t.String(),
|
||||
|
||||
informasiUmum: t.Object({
|
||||
fasilitas: t.String(),
|
||||
alamat: t.String(),
|
||||
jamOperasional: t.String(),
|
||||
}),
|
||||
|
||||
layananUnggulan: t.Object({
|
||||
content: t.String(),
|
||||
}),
|
||||
dokterdanTenagaMedis: t.Object({
|
||||
name: t.String(),
|
||||
specialist: t.String(),
|
||||
jadwal: t.String(),
|
||||
}),
|
||||
|
||||
// FIX → harus array of string (ID dokter)
|
||||
dokterdanTenagaMedis: t.Array(t.String()),
|
||||
|
||||
fasilitasPendukung: t.Object({
|
||||
content: t.String(),
|
||||
}),
|
||||
|
||||
prosedurPendaftaran: t.Object({
|
||||
content: t.String(),
|
||||
}),
|
||||
tarifDanLayanan: t.Object({
|
||||
layanan: t.String(),
|
||||
tarif: t.String(),
|
||||
}),
|
||||
|
||||
// FIX → harus array of string (ID tarif)
|
||||
tarifDanLayanan: t.Array(t.String()),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,32 +5,26 @@ type FasilitasKesehatanInput = {
|
||||
name: string;
|
||||
informasiUmum: { fasilitas: string; alamat: string; jamOperasional: string };
|
||||
layananUnggulan: { content: string };
|
||||
dokterdanTenagaMedis: { name: string; specialist: string; jadwal: string };
|
||||
dokterdanTenagaMedis: string[]; // ← ID saja
|
||||
fasilitasPendukung: { content: string };
|
||||
prosedurPendaftaran: { content: string };
|
||||
tarifDanLayanan: { layanan: string; tarif: string };
|
||||
tarifDanLayanan: string[]; // ← ID saja
|
||||
};
|
||||
|
||||
const fasilitasKesehatanUpdate = async (context: Context) => {
|
||||
const id = context.params?.id as string;
|
||||
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 body = (await context.body) as FasilitasKesehatanInput;
|
||||
|
||||
const existing = await prisma.fasilitasKesehatan.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
dokterdantenagamedis: true,
|
||||
tarifdanlayanan: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: "Data not found" }),
|
||||
{ status: 404, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
return { success: false, message: "Data tidak ditemukan" };
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -43,38 +37,46 @@ const fasilitasKesehatanUpdate = async (context: Context) => {
|
||||
tarifDanLayanan,
|
||||
} = body;
|
||||
|
||||
// Update data masing-masing relasi
|
||||
// update relasi 1-1
|
||||
await Promise.all([
|
||||
prisma.informasiUmum.update({
|
||||
where: { id: existing.informasiUmumId },
|
||||
data: informasiUmum,
|
||||
}),
|
||||
|
||||
prisma.layananUnggulan.update({
|
||||
where: { id: existing.layananUnggulanId },
|
||||
data: layananUnggulan,
|
||||
}),
|
||||
prisma.dokterdanTenagaMedis.update({
|
||||
where: { id: existing.dokterdanTenagaMedisId },
|
||||
data: dokterdanTenagaMedis,
|
||||
}),
|
||||
|
||||
prisma.fasilitasPendukung.update({
|
||||
where: { id: existing.fasilitasPendukungId },
|
||||
data: fasilitasPendukung,
|
||||
}),
|
||||
|
||||
prisma.prosedurPendaftaran.update({
|
||||
where: { id: existing.prosedurPendaftaranId },
|
||||
data: prosedurPendaftaran,
|
||||
}),
|
||||
prisma.tarifDanLayanan.update({
|
||||
where: { id: existing.tarifDanLayananId },
|
||||
data: tarifDanLayanan,
|
||||
}),
|
||||
]);
|
||||
|
||||
// Update main record
|
||||
// update m2m
|
||||
const updated = await prisma.fasilitasKesehatan.update({
|
||||
where: { id },
|
||||
data: { name },
|
||||
|
||||
data: {
|
||||
name,
|
||||
|
||||
// reset dokter lama → ganti baru
|
||||
dokterdantenagamedis: {
|
||||
set: dokterdanTenagaMedis.map((id) => ({ id })),
|
||||
},
|
||||
|
||||
tarifdanlayanan: {
|
||||
set: tarifDanLayanan.map((id) => ({ id })),
|
||||
},
|
||||
},
|
||||
|
||||
include: {
|
||||
informasiumum: true,
|
||||
layananunggulan: true,
|
||||
@@ -87,7 +89,7 @@ const fasilitasKesehatanUpdate = async (context: Context) => {
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Fasilitas berhasil diupdate",
|
||||
message: "Fasilitas diupdate",
|
||||
data: updated,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 DokterTenagaMedis from "./data_kesehatan_warga/fasilitas_kesehatan/dokter-tenaga-medis";
|
||||
import PendaftaranJadwalKegiatan from "./data_kesehatan_warga/jadwal_kegiatan/pendaftaran";
|
||||
import TarifLayanan from "./data_kesehatan_warga/fasilitas_kesehatan/tarif-layanan";
|
||||
|
||||
|
||||
const Kesehatan = new Elysia({
|
||||
@@ -46,5 +47,6 @@ const Kesehatan = new Elysia({
|
||||
.use(Kelahiran)
|
||||
.use(Kematian)
|
||||
.use(DokterTenagaMedis)
|
||||
.use(TarifLayanan)
|
||||
.use(PendaftaranJadwalKegiatan)
|
||||
export default Kesehatan;
|
||||
|
||||
@@ -1,36 +1,137 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import prisma from "@/lib/prisma";
|
||||
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 = {
|
||||
name: string;
|
||||
jumlah: string;
|
||||
tahun: number;
|
||||
imageId: string;
|
||||
fileId: string;
|
||||
items: APBDesItemInput[];
|
||||
};
|
||||
|
||||
export default async function apbdesCreate(context: Context) {
|
||||
const body = context.body as FormCreate;
|
||||
|
||||
// Log the incoming request for debugging
|
||||
console.log('Incoming request body:', JSON.stringify(body, null, 2));
|
||||
|
||||
try {
|
||||
const result = await prisma.aPBDes.create({
|
||||
data: {
|
||||
name: body.name,
|
||||
jumlah: body.jumlah,
|
||||
imageId: body.imageId,
|
||||
fileId: body.fileId,
|
||||
},
|
||||
include: {
|
||||
image: true,
|
||||
file: true,
|
||||
},
|
||||
// Validate required fields
|
||||
if (!body.tahun) {
|
||||
throw new Error('Tahun is required');
|
||||
}
|
||||
if (!body.imageId) {
|
||||
throw new Error('Image ID is required');
|
||||
}
|
||||
if (!body.fileId) {
|
||||
throw new Error('File ID is required');
|
||||
}
|
||||
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 {
|
||||
success: true,
|
||||
message: "Berhasil membuat APB Des",
|
||||
data: result,
|
||||
success: false,
|
||||
message: `Gagal membuat APBDes: ${errorMessage}`,
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,59 @@ import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
export default async function apbdesDelete(context: Context) {
|
||||
const { params } = context;
|
||||
const id = params?.id as string;
|
||||
const { id } = context.params as { id: string };
|
||||
|
||||
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({
|
||||
where: { id },
|
||||
});
|
||||
try {
|
||||
// Cek apakah ada
|
||||
const existing = await prisma.aPBDes.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Berhasil menghapus APB Des",
|
||||
data: deleted,
|
||||
};
|
||||
if (!existing) {
|
||||
context.set.status = 404;
|
||||
return {
|
||||
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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +1,57 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// /api/berita/findManyPaginated.ts
|
||||
// src/app/api/.../apbdes/findMany.ts
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
async function apbdesFindMany(context: Context) {
|
||||
const page = Number(context.query.page) || 1;
|
||||
const limit = Number(context.query.limit) || 10;
|
||||
const search = (context.query.search as string) || '';
|
||||
const skip = (page - 1) * limit;
|
||||
export default async function apbdesFindMany(context: Context) {
|
||||
const { page = "1", limit = "10", tahun } = context.query as {
|
||||
page?: string;
|
||||
limit?: string;
|
||||
tahun?: string;
|
||||
};
|
||||
|
||||
// Buat where clause
|
||||
const where: any = { isActive: true };
|
||||
const pageNumber = Math.max(1, parseInt(page, 10) || 1);
|
||||
const limitNumber = Math.min(100, Math.max(1, parseInt(limit, 10) || 10));
|
||||
|
||||
// Tambahkan pencarian (jika ada)
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
const skip = (pageNumber - 1) * limitNumber;
|
||||
|
||||
try {
|
||||
const where: any = { isActive: true };
|
||||
if (tahun) {
|
||||
where.tahun = parseInt(tahun, 10);
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.aPBDes.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limitNumber,
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
image: 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 {
|
||||
success: true,
|
||||
message: "Success fetch APB Des with pagination",
|
||||
data,
|
||||
page,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
total,
|
||||
meta: {
|
||||
page: pageNumber,
|
||||
limit: limitNumber,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limitNumber),
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Find many paginated error:", e);
|
||||
} catch (error) {
|
||||
console.error("Error fetching APBDes list:", error);
|
||||
context.set.status = 500;
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed fetch APB Des with pagination",
|
||||
message: "Gagal mengambil daftar APBDes",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default apbdesFindMany;
|
||||
}
|
||||
@@ -2,28 +2,78 @@ import prisma from "@/lib/prisma";
|
||||
import { Context } from "elysia";
|
||||
|
||||
export default async function apbdesFindUnique(context: Context) {
|
||||
const { params } = context;
|
||||
const id = params?.id as string;
|
||||
|
||||
if (!id) {
|
||||
throw new Error("ID tidak ditemukan dalam parameter");
|
||||
// ✅ Parse URL secara manual
|
||||
const url = new URL(context.request.url);
|
||||
const pathSegments = url.pathname.split('/').filter(Boolean);
|
||||
|
||||
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({
|
||||
where: { id },
|
||||
include: {
|
||||
image: true,
|
||||
file: true,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result = await prisma.aPBDes.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
items: {
|
||||
where: { isActive: true },
|
||||
orderBy: { kode: 'asc' }
|
||||
},
|
||||
image: true,
|
||||
file: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
throw new Error("APB Des tidak ditemukan");
|
||||
if (!result || !result.isActive) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/app/api/[[...slugs]]/_lib/landing_page/apbdes/index.ts
|
||||
import Elysia, { t } from "elysia";
|
||||
import apbdesCreate from "./create";
|
||||
import apbdesDelete from "./del";
|
||||
@@ -5,12 +6,24 @@ import apbdesFindMany from "./findMany";
|
||||
import apbdesFindUnique from "./findUnique";
|
||||
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({
|
||||
prefix: "/apbdes",
|
||||
tags: ["Landing Page/Profile/APB Des"],
|
||||
})
|
||||
|
||||
// ✅ Find all
|
||||
// ✅ Find all (dengan query opsional: page, limit, tahun)
|
||||
.get("/findMany", apbdesFindMany)
|
||||
|
||||
// ✅ Find by ID
|
||||
@@ -19,23 +32,27 @@ const APBDes = new Elysia({
|
||||
// ✅ Create
|
||||
.post("/create", apbdesCreate, {
|
||||
body: t.Object({
|
||||
name: t.String(),
|
||||
tahun: t.Number(),
|
||||
imageId: t.String(),
|
||||
fileId: t.String(),
|
||||
jumlah: t.String(),
|
||||
items: t.Array(ApbdesItemSchema),
|
||||
}),
|
||||
})
|
||||
|
||||
// ✅ Update
|
||||
.put("/:id", apbdesUpdate, {
|
||||
params: t.Object({ id: t.String() }),
|
||||
body: t.Object({
|
||||
name: t.String(),
|
||||
imageId: t.Optional(t.String()),
|
||||
fileId: t.Optional(t.String()),
|
||||
jumlah: t.Optional(t.String()),
|
||||
tahun: t.Number(),
|
||||
imageId: t.String(),
|
||||
fileId: 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;
|
||||
@@ -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);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user