Compare commits

...

7 Commits

Author SHA1 Message Date
0dff8f3254 Nico 20 Nov 25
Dibagian layout admin sudah disesuaikan dengan rolenya : supadmin, admin desa, admin kesehatan, admin pendidikan
Fix API User & Role Admin
2025-11-20 16:42:36 +08:00
78b8aa74cd Saat user baru registrasi maka akan diarahkan ke page waiting-room dan menunggu validasi admin 2025-11-20 14:07:26 +08:00
a0537810e8 Login, Register, Verifkasi Code Admin V1 2025-11-20 02:42:39 +08:00
b3c169a2d4 Fix create admin & progress bar persentase 2025-11-18 17:23:38 +08:00
2608a5ffdd Fix Edit di Admin APbdes, dan fix data real di apbdes user 2025-11-18 16:26:09 +08:00
6c32f3ebdb Fix Route APBdes 2025-11-18 14:27:53 +08:00
0feeb4de93 Fix SDGs Desa Barchart sudah responsive, tabel dan bar progress di menu apbdes sudah sesuai dengan data 2025-11-18 11:56:16 +08:00
64 changed files with 5551 additions and 1919 deletions

View File

@@ -1,23 +1,26 @@
[
{
"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": "SUPER ADMIN",
"description": "Administrator",
"isActive": true
},
{
"id": "1",
"name": "ADMIN DESA",
"description": "Administrator Desa",
"isActive": true
},
{
"id": "2",
"name": "ADMIN KESEHATAN",
"description": "Administrator Bidang Kesehatan",
"isActive": true
},
{
"id": "3",
"name": "ADMIN PENDIDIKAN",
"description": "Administrator Bidang Pendidikan",
"isActive": true
}
]

View File

@@ -1,23 +0,0 @@
[
{
"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
}
]

File diff suppressed because it is too large Load Diff

View File

@@ -184,18 +184,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())
@@ -1942,23 +1970,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 {
@@ -2131,7 +2164,7 @@ enum StatusPeminjaman {
model User {
id String @id @default(cuid())
username String
username String @unique
nomor String @unique
role Role @relation(fields: [roleId], references: [id])
roleId String @default("1")
@@ -2148,7 +2181,6 @@ model Role {
id String @id @default(cuid())
name String @unique // ADMIN_DESA, ADMIN_KESEHATAN, ADMIN_SEKOLAH
description String?
permissions Json // Menyimpan permission dalam format JSON
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -2167,17 +2199,6 @@ 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
@@map("permissions")
}
model UserSession {
id String @id @default(cuid())
token String

View File

@@ -54,14 +54,13 @@ 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 { safeSeedUnique } from "./safeseedUnique";
(async () => {
// =========== USER & ROLE ===========
// =========== ROLE ===========
// In your seed.ts
// =========== ROLES ===========
console.log("🔄 Seeding roles...");
@@ -69,35 +68,12 @@ import { safeSeedUnique } from "./safeseedUnique";
await safeSeedUnique("role", { id: r.id }, {
name: r.name,
description: r.description,
permissions: r.permissions,
isActive: r.isActive,
});
}
console.log("✅ Roles seeded");
// =========== USERS ===========
console.log("🔄 Seeding users...");
for (const u of users) {
// First verify the role exists
const roleExists = await prisma.role.findUnique({
where: { id: u.roleId },
});
if (!roleExists) {
console.error(`❌ Role with id ${u.roleId} not found for user ${u.nama}`);
continue;
}
await safeSeedUnique("user", { id: u.id }, {
username: u.nama,
nomor: u.nomor,
roleId: u.roleId,
isActive: u.isActive,
});
}
console.log("✅ Users seeded");
// =========== FILE STORAGE ===========
console.log("🔄 Seeding file storage...");
for (const f of fileStorage) {

View File

@@ -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;

View File

@@ -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,
}),
}
);

View File

@@ -90,27 +90,35 @@ const userState = proxy({
}
},
},
updateActive: {
update: {
loading: false,
async submit(id: string, isActive: boolean) {
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({ id, isActive }),
body: JSON.stringify(payload),
});
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);
// refresh list
userState.findMany.load(
userState.findMany.page,
10,
userState.findMany.search
);
} else {
toast.error(data.message || "Gagal update status user");
toast.error(data.message || "Gagal update user");
}
} catch (e) {
console.error(e);
toast.error("Gagal update status user");
toast.error("Gagal update user");
} finally {
this.loading = false;
}
@@ -120,12 +128,10 @@ const userState = proxy({
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 +243,7 @@ const roleState = proxy({
toast.warn("ID tidak valid");
return null;
}
try {
const response = await fetch(`/api/role/${id}`, {
method: "GET",
@@ -245,31 +251,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 +290,6 @@ const roleState = proxy({
},
body: JSON.stringify({
name: this.form.name,
permissions: this.form.permissions,
}),
});

View File

@@ -1,104 +1,98 @@
'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, '');
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 });
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 & ke validasi
localStorage.setItem('auth_kodeId', response.kodeId);
router.push('/validasi');
} 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 +102,4 @@ function Login() {
);
}
export default Login;
export default Login;

View File

@@ -1,113 +1,127 @@
/* 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
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;
}
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}>
<Checkbox
label="Saya menyetujui syarat dan ketentuan yang berlaku"
/>
<Box pt="md">
<Checkbox label="Saya menyetujui syarat dan ketentuan" defaultChecked />
</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 +130,4 @@ function Registrasi() {
</Box>
</Stack>
);
}
export default Registrasi;
}

View File

@@ -1,31 +1,185 @@
'use client'
import colors from '@/con/colors';
import { Box, Button, Paper, PinInput, Stack, Text, Title } from '@mantine/core';
import { useRouter } from 'next/navigation';
// app/validasi/page.tsx
'use client';
import { apiFetchOtpData, apiFetchVerifyOtp } from '@/app/api/auth/_lib/api_fetch_auth';
import colors from '@/con/colors';
import { Box, Button, 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);
useEffect(() => {
const storedKodeId = localStorage.getItem('auth_kodeId');
if (!storedKodeId) {
toast.error('Akses tidak valid');
router.push('/login');
return;
}
setKodeId(storedKodeId);
const fetchOtpData = async () => {
try {
const result = await apiFetchOtpData({ kodeId: storedKodeId });
if (result.success && result.data?.nomor) {
setNomor(result.data.nomor);
} else {
throw new Error('OTP tidak valid');
}
} catch (error) {
console.error('Gagal muat OTP:', error);
toast.error('Kode verifikasi tidak valid');
router.push('/login');
} finally {
setIsLoading(false);
}
};
fetchOtpData();
}, [router]);
const handleVerify = async () => {
if (!kodeId || !nomor || otp.length < 4) return;
try {
setLoading(true);
const verifyResult = await apiFetchVerifyOtp({ nomor, otp, kodeId });
if (verifyResult.success && verifyResult.user) {
// ✅ SET USER KE STORE
authStore.setUser({
id: verifyResult.user.id,
name: verifyResult.user.name,
roleId: Number(verifyResult.user.roleId),
});
cleanupStorage();
router.push('/admin/landing-page/profil/program-inovasi');
return;
}
// Hanya coba registrasi jika akun tidak ditemukan
if (verifyResult.status === 404 && verifyResult.message?.includes('Akun tidak ditemukan')) {
const username = localStorage.getItem('auth_username');
if (!username) {
toast.error('Data registrasi hilang');
return;
}
const regRes = await fetch('/api/auth/finalize-registration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor, username, otp, kodeId }),
});
const regData = await regRes.json();
if (regData.success) {
cleanupStorage();
router.push('/waiting-room'); // ✅
} else {
toast.error(regData.message || 'Registrasi gagal');
}
} else {
// Hanya tampilkan error jika bukan kasus "akun tidak ditemukan"
toast.error(verifyResult.message || 'Verifikasi gagal');
}
} catch (error) {
console.error('Verifikasi error:', error);
toast.error('Terjadi kesalahan');
} finally {
setLoading(false);
}
};
const cleanupStorage = () => {
localStorage.removeItem('auth_kodeId');
localStorage.removeItem('auth_nomor');
localStorage.removeItem('auth_username');
};
const handleResend = async () => {
if (!nomor) return;
try {
const res = await fetch('/api/auth/resend-otp', {
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');
}
} catch {
toast.error('Gagal kirim ulang');
}
};
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']}>
<Title ta="center" order={2} fw="bold" c={colors['blue-button']}>
Kode Verifikasi
</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 mb={20}>
<Text c={colors['blue-button']} ta="center" fz="sm" fw="bold">
Masukkan Kode Verifikasi
</Text>
<PinInput
length={4}
value={otp}
onChange={setOtp}
onComplete={handleVerify}
inputMode="numeric"
size="lg"
/>
</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 +187,4 @@ function Validasi() {
</Box>
</Stack>
);
}
export default Validasi;
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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,289 @@ 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={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 +468,4 @@ function CreateAPBDes() {
);
}
export default CreateAPBDes;
export default CreateAPBDes;

View File

@@ -1,6 +1,23 @@
'use client'
'use client';
import colors from '@/con/colors';
import { 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;

View File

@@ -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"

View File

@@ -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

View File

@@ -1,6 +1,6 @@
'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 { useState } from 'react';
@@ -28,6 +28,7 @@ function User() {
function ListUser({ search }: { search: string }) {
const stateUser = useProxy(user.userState)
const stateRole = useProxy(user.roleState)
const [modalHapus, setModalHapus] = useState(false)
const [selectedId, setSelectedId] = useState<string | null>(null)
@@ -51,6 +52,7 @@ function ListUser({ search }: { search: string }) {
useShallowEffect(() => {
stateRole.findMany.load()
load(page, 10, search)
}, [page, search])
@@ -92,11 +94,31 @@ function ListUser({ search }: { search: string }) {
{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.map((r) => ({
label: r.name,
value: r.id,
}))}
value={item.roleId} // ⬅ role milik user ini
onChange={async (val) => {
if (!val) return;
await stateUser.update.submit({
id: item.id,
roleId: val, // ⬅ kirim roleId
});
// reload data supaya UI up-to-date
stateUser.findMany.load(page, 10, search);
}}
searchable
clearable={false} // role harus ada
nothingFoundMessage="Role tidak ditemukan"
/>
</TableTd>
<TableTd style={{ width: '15%' }}>
<Tooltip
label={item.isActive ? "Nonaktifkan user" : "Aktifkan user"}
@@ -106,8 +128,12 @@ function ListUser({ search }: { search: string }) {
variant="light"
color={item.isActive ? "green" : "red"}
onClick={async () => {
await stateUser.updateActive.submit(item.id, !item.isActive)
stateUser.findMany.load(page, 10, search)
await stateUser.update.submit({
id: item.id,
isActive: !item.isActive, // toggle
});
stateUser.findMany.load(page, 10, search);
}}
>
{item.isActive ? <IconCheck size={20} /> : <IconX size={20} />}

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,269 @@
// 'use client'
// import colors from "@/con/colors";
// import {
// ActionIcon,
// AppShell,
// AppShellHeader,
// AppShellMain,
// AppShellNavbar,
// Burger,
// Flex,
// Group,
// Image,
// 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 { navBar } from "./_com/list_PageAdmin";
// export default function Layout({ children }: { children: React.ReactNode }) {
// const [opened, { toggle }] = useDisclosure();
// const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
// const router = useRouter();
// const segments = useSelectedLayoutSegments().map((s) => _.lowerCase(s));
// 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={() => {
// router.push("/darmasaba");
// }}
// color={colors["blue-button"]}
// radius="xl"
// size="lg"
// variant="gradient"
// gradient={{ from: colors["blue-button"], to: "#228be6" }}
// >
// <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">
// {navBar.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>
// );
// }
'use client'
import colors from "@/con/colors";
import { authStore } from "@/store/authStore";
import {
ActionIcon,
AppShell,
@@ -26,7 +289,8 @@ import {
import _ from "lodash";
import Link from "next/link";
import { useRouter, useSelectedLayoutSegments } from "next/navigation";
import { navBar } from "./_com/list_PageAdmin";
import { useSnapshot } from "valtio";
import { navigationByRole } from "./_com/navigationByRole";
export default function Layout({ children }: { children: React.ReactNode }) {
const [opened, { toggle }] = useDisclosure();
@@ -34,6 +298,15 @@ export default function Layout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const segments = useSelectedLayoutSegments().map((s) => _.lowerCase(s));
//ambil user dari authStore
const {user} = useSnapshot(authStore)
console.log("Current User:", user); // 👈 Tambahkan ini
//ambil navigation berdasarkan role
const currentNav = user?.roleId !== undefined
? navigationByRole[user.roleId as keyof typeof navigationByRole] || []
: [];
return (
<AppShell
suppressHydrationWarning
@@ -156,7 +429,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
p={{ base: 'xs', sm: 'sm' }}
>
<AppShell.Section p="sm">
{navBar.map((v, k) => {
{currentNav.map((v, k) => {
const isParentActive = segments.includes(_.lowerCase(v.name));
return (
@@ -255,3 +528,4 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</AppShell>
);
}

View File

@@ -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();
}
}
}

View File

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

View File

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

View File

@@ -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);
}
}
}

View File

@@ -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",
};
}
}

View File

@@ -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;
}

View File

@@ -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,
};
}
}

View File

@@ -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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,16 @@ const User = new Elysia({ prefix: "/api/user" })
id: t.String(),
}),
}) // pakai PUT untuk soft delete
.put("/updt", userUpdate);
.put(
"/updt",
userUpdate,
{
body: t.Object({
id: t.String(),
isActive: t.Optional(t.Boolean()),
roleId: t.Optional(t.String()),
})
}
);
export default User;

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,11 @@ import { Context } from "elysia";
export default async function userUpdate(context: Context) {
try {
const { id, isActive } = await context.body as { id: string, isActive: boolean };
const { id, isActive, roleId } = await context.body as {
id: string,
isActive?: boolean,
roleId?: string
};
if (!id) {
return {
@@ -13,28 +17,53 @@ export default async function userUpdate(context: Context) {
};
}
// Optional: cek apakah roleId valid
if (roleId) {
const cekRole = await prisma.role.findUnique({
where: { id: roleId }
});
if (!cekRole) {
return {
success: false,
message: "Role tidak ditemukan",
};
}
}
const updatedUser = await prisma.user.update({
where: { id },
data: { isActive },
data: {
...(isActive !== undefined && { isActive }),
...(roleId && { roleId }),
},
select: {
id: true,
username: true,
nomor: true,
isActive: true,
roleId: true,
updatedAt: true,
role: {
select: {
id: true,
name: true,
}
}
}
});
return {
success: true,
message: `User berhasil ${isActive ? "diaktifkan" : "dinonaktifkan"}`,
message: `User berhasil diupdate`,
data: updatedUser,
};
} catch (e: any) {
console.error("Error update user:", e);
return {
success: false,
message: "Gagal mengupdate status user",
message: "Gagal mengupdate user",
};
}
}

View File

@@ -0,0 +1,120 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
// app/api/auth/_lib/api_fetch_auth.ts
// app/api/auth/_lib/api_fetch_auth.ts
export const apiFetchLogin = async ({ nomor }: { nomor: string }) => {
if (!nomor || nomor.replace(/\D/g, '').length < 10) {
throw new Error('Nomor tidak valid');
}
const cleanPhone = nomor.replace(/\D/g, '');
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nomor: cleanPhone }),
});
// Pastikan respons bisa di-parse sebagai JSON
let data;
try {
data = await response.json();
} catch (e) {
console.error("Non-JSON response from /api/auth/login:", await response.text());
throw new Error('Respons server tidak valid');
}
if (!response.ok) {
throw new Error(data.message || 'Gagal memproses login');
}
// Validasi minimal respons
if (typeof data.success !== 'boolean' || typeof data.isRegistered !== 'boolean') {
throw new Error('Respons tidak sesuai format');
}
if (data.success) {
if (data.isRegistered && !data.kodeId) {
throw new Error('Kode verifikasi tidak ditemukan untuk user terdaftar');
}
return data; // { success, isRegistered, kodeId? }
} else {
throw new Error(data.message || 'Login gagal');
}
};
export const apiFetchRegister = async ({
username,
nomor,
}: {
username: string;
nomor: string;
}) => {
const cleanPhone = nomor.replace(/\D/g, '');
if (cleanPhone.length < 10) throw new Error('Nomor tidak valid');
const response = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: username.trim(), nomor: cleanPhone }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Gagal mengirim OTP');
return data;
};
export const apiFetchOtpData = async ({ kodeId }: { kodeId: string }) => {
if (!kodeId) {
throw new Error('Kode ID tidak valid');
}
const response = await fetch("/api/auth/otp-data", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kodeId }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Gagal memuat data OTP');
}
return data;
};
export const apiFetchVerifyOtp = async ({
nomor,
otp,
kodeId
}: {
nomor: string;
otp: string;
kodeId: string;
}) => {
if (!nomor || !otp || !kodeId) {
throw new Error('Data verifikasi tidak lengkap');
}
if (!/^\d{4,6}$/.test(otp)) {
throw new Error('Kode OTP harus 4-6 digit angka');
}
const response = await fetch('/api/auth/verify-otp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor, otp, kodeId }),
});
const data = await response.json();
// ✅ Jangan throw error untuk status 4xx — biarkan frontend handle
return {
success: response.ok,
...data,
status: response.status,
};
};

View File

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

View File

@@ -1,26 +1,23 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { SignJWT } from "jose";
export async function encrypt({
user,
exp = "7 year",
encodedKey,
exp = "7d",
jwtSecret,
}: {
user: Record<string, any>;
exp?: string;
encodedKey: string;
user: Record<string, unknown>;
exp?: string | number;
jwtSecret: string;
}): Promise<string | null> {
try {
const enc = new TextEncoder().encode(encodedKey);
const secret = new TextEncoder().encode(jwtSecret);
return new SignJWT({ user })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(exp)
.sign(enc);
.sign(secret);
} catch (error) {
console.error("Gagal mengenkripsi", error);
console.error("JWT Encrypt failed:", error);
return null;
}
}
// wibu:0.2.82
}

View File

@@ -1,36 +1,42 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { cookies } from "next/headers";
import { encrypt } from "./encrypt";
export async function sessionCreate({
sessionKey,
exp = "7 year",
encodedKey,
jwtSecret,
user,
}: {
sessionKey: string;
exp?: string;
encodedKey: string;
jwtSecret: string;
user: Record<string, unknown>;
}) {
// 🔒 Validasi kunci tidak kosong
if (!sessionKey || sessionKey.length === 0) {
throw new Error("sessionKey tidak boleh kosong");
}
if (!jwtSecret || jwtSecret.length === 0) {
throw new Error("jwtSecret tidak boleh kosong");
}
const token = await encrypt({
exp,
encodedKey,
jwtSecret,
user,
});
const cookie: any = {
key: sessionKey,
value: token,
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
},
};
if (token === null) {
throw new Error("Token generation failed");
}
const cookieStore = await cookies();
cookieStore.set(sessionKey, token, {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: process.env.NODE_ENV === "production",
});
(await cookies()).set(cookie.key, cookie.value, { ...cookie.options });
return token;
}
// wibu:0.2.82
}

View File

@@ -0,0 +1,48 @@
// app/api/auth/finalize-registration/route.ts
import prisma from "@/lib/prisma";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
// import { sessionCreate } from "../_lib/session_create";
export async function POST(req: Request) {
try {
const { nomor, username, kodeId } = await req.json();
// Verifikasi OTP (sama seperti verify-otp)
const otpRecord = await prisma.kodeOtp.findUnique({ where: { id: kodeId } });
if (!otpRecord?.isActive || otpRecord.nomor !== nomor) {
return NextResponse.json({ success: false, message: 'OTP tidak valid' }, { status: 400 });
}
// Buat user
const user = await prisma.user.create({
data: { username, nomor, isActive: false }
});
// Nonaktifkan OTP
await prisma.kodeOtp.update({ where: { id: kodeId }, data: { isActive: false } });
// Buat session
// const token = await sessionCreate({
// sessionKey: process.env.BASE_SESSION_KEY!,
// jwtSecret: process.env.BASE_TOKEN_KEY!,
// user: { id: user.id, nomor: user.nomor, username: user.username, roleId: user.roleId, isActive: true },
// });
(await cookies()).set('desadarmasaba_user_id', user.id, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 30 * 24 * 60 * 60, // 30 hari
});
const response = NextResponse.json({ success: true, roleId: user.roleId });
// response.cookies.set(process.env.BASE_SESSION_KEY!, token, { /* options */ });
return response;
} catch (error) {
console.error('Finalize Registration Error:', error);
return NextResponse.json({ success: false, message: 'Registrasi gagal' }, { status: 500 });
} finally {
await prisma.$disconnect();
}
}

View File

@@ -1,5 +1,5 @@
// app/api/auth/login/route.ts
import prisma from "@/lib/prisma";
import { NextResponse } from "next/server";
import { randomOTP } from "../_lib/randomOTP";
@@ -12,52 +12,66 @@ 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 { nomor } = await req.json();
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,
},
const existingUser = await prisma.user.findUnique({
where: { nomor },
select: { id: true, isActive: true },
});
if (!createOtpId)
return NextResponse.json(
{ success: false, message: "Gagal mengirim kode OTP" },
{ status: 400 }
);
const isRegistered = !!existingUser;
return NextResponse.json(
{
if (isRegistered) {
// ✅ User terdaftar → kirim OTP
const codeOtp = randomOTP();
const otpNumber = Number(codeOtp);
const waMessage = `Website Desa Darmasaba - Kode verifikasi Anda: ${codeOtp}`;
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(waMessage)}`;
const res = await fetch(waUrl);
const sendWa = await res.json();
if (sendWa.status !== "success") {
return NextResponse.json(
{ success: false, message: "Gagal mengirim OTP via WhatsApp" },
{ status: 400 }
);
}
const createOtpId = await prisma.kodeOtp.create({
data: { nomor, otp: otpNumber, isActive: true },
});
return NextResponse.json({
success: true,
message: "Kode verifikasi terkirim",
message: "Kode verifikasi dikirim",
kodeId: createOtpId.id,
},
{ status: 200 }
);
isRegistered: true,
});
} else {
// ❌ User belum terdaftar → JANGAN kirim OTP
return NextResponse.json({
success: true,
message: "Nomor belum terdaftar",
isRegistered: false,
// Tidak ada kodeId
});
}
} 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 kesalahan saat login" },
{ status: 500 }
);
} finally {
await prisma.$disconnect();
}
}
}

View File

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

View File

@@ -0,0 +1,41 @@
// app/api/auth/otp-data/route.ts
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
export async function POST(req: Request) {
try {
const { kodeId } = await req.json();
if (!kodeId) {
return NextResponse.json(
{ success: false, message: "Kode ID tidak diberikan" },
{ status: 400 }
);
}
const otpRecord = await prisma.kodeOtp.findUnique({
where: { id: kodeId },
select: { id: true, nomor: true, isActive: true, createdAt: true },
});
if (!otpRecord || !otpRecord.isActive) {
return NextResponse.json(
{ success: false, message: "Kode verifikasi tidak valid atau sudah digunakan" },
{ status: 400 }
);
}
return NextResponse.json({
success: true,
data: otpRecord,
});
} catch (error) {
console.error("Error fetching OTP data:", error);
return NextResponse.json(
{ success: false, message: "Gagal mengambil data OTP" },
{ status: 500 }
);
} finally {
await prisma.$disconnect();
}
}

View File

@@ -1,62 +1,51 @@
import prisma from "@/lib/prisma";
import { NextResponse } from "next/server";
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import { randomOTP } from '../_lib/randomOTP'; // pastikan ada
export async function POST(req: Request) {
if (req.method !== "POST") {
return NextResponse.json(
{ success: false, message: "Method Not Allowed" },
{ status: 405 }
);
}
try {
const { data } = await req.json();
const { username, nomor } = await req.json();
const cekUsername = await prisma.user.findUnique({
where: {
username: data.username,
nomor: data.nomor,
},
if (!username || !nomor) {
return NextResponse.json({ success: false, message: 'Data tidak lengkap' }, { status: 400 });
}
// Cek duplikat
if (await prisma.user.findUnique({ where: { nomor } })) {
return NextResponse.json({ success: false, message: 'Nomor sudah terdaftar' }, { status: 409 });
}
if (await prisma.user.findUnique({ where: { username } })) {
return NextResponse.json({ success: false, message: 'Username sudah digunakan' }, { status: 409 });
}
// ✅ Generate dan kirim OTP
const codeOtp = randomOTP();
const otpNumber = Number(codeOtp);
const waMessage = `Website Desa Darmasaba - Kode verifikasi Anda: ${codeOtp}`;
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(waMessage)}`;
const waRes = await fetch(waUrl);
const waData = await waRes.json();
if (waData.status !== "success") {
return NextResponse.json({ success: false, message: 'Gagal mengirim OTP via WhatsApp' }, { status: 400 });
}
// ✅ Simpan OTP ke database
const otpRecord = await prisma.kodeOtp.create({
data: { nomor, otp: otpNumber, isActive: true }
});
if (cekUsername)
return NextResponse.json({
success: false,
message: "Username sudah digunakan",
});
const createUser = await prisma.user.create({
data: {
username: data.username,
nomor: data.nomor,
},
// ✅ Kembalikan kodeId (jangan buat user di sini!)
return NextResponse.json({
success: true,
message: 'Kode verifikasi dikirim',
kodeId: otpRecord.id,
});
if (!createUser)
return NextResponse.json(
{ success: false, message: "Gagal Registrasi" },
{ status: 500 }
);
return NextResponse.json(
{
success: true,
message: "Registrasi Berhasil, Anda Sedang Login",
// data: createUser,
},
{ status: 201 }
);
} catch (error) {
console.error("Error registrasi:", error);
return NextResponse.json(
{
success: false,
message: "Maaf, Terjadi Keselahan",
reason: (error as Error).message,
},
{ status: 500 }
);
console.error('Register OTP Error:', error);
return NextResponse.json({ success: false, message: 'Gagal mengirim OTP' }, { status: 500 });
} finally {
await prisma.$disconnect();
}
}
}

View File

@@ -0,0 +1,71 @@
import prisma from "@/lib/prisma";
import { randomOTP } from "../_lib/randomOTP";
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 codeOtp = randomOTP();
const body = await req.json();
const { nomor } = body;
const res = await fetch(
`https://wa.wibudev.com/code?nom=${nomor}&text=HIPMI - Kode ini bersifat RAHASIA dan JANGAN DI BAGIKAN KEPADA SIAPAPUN, termasuk anggota ataupun pengurus HIPMI lainnya.
\n
>> Kode OTP anda: ${codeOtp}.
`
);
const sendWa = await res.json();
if (sendWa.status !== "success")
return NextResponse.json(
{
success: false,
message: "Nomor Whatsapp Tidak Aktif",
},
{ status: 400 }
);
const createOtpId = await prisma.kodeOtp.create({
data: {
nomor: nomor,
otp: codeOtp,
},
});
if (!createOtpId)
return NextResponse.json(
{
success: false,
message: "Gagal Membuat Kode OTP",
},
{ status: 400 }
);
return NextResponse.json(
{
success: true,
message: "Kode Verifikasi Dikirim",
kodeId: createOtpId.id,
},
{ status: 200 }
);
} catch (error) {
console.error(" Error Resend OTP", error);
return NextResponse.json(
{
success: false,
message: "Server Whatsapp Error !!",
},
{ status: 500 }
);
} finally {
await prisma.$disconnect();
}
}

View File

@@ -0,0 +1,51 @@
import prisma from "@/lib/prisma";
import { NextResponse } from "next/server";
import { randomOTP } from "../_lib/randomOTP";
export async function POST(req: Request) {
try {
const { username, nomor } = await req.json();
if (!username || !nomor) {
return NextResponse.json({ success: false, message: 'Data tidak lengkap' }, { status: 400 });
}
// Cek duplikat
if (await prisma.user.findUnique({ where: { nomor } })) {
return NextResponse.json({ success: false, message: 'Nomor sudah terdaftar' }, { status: 409 });
}
if (await prisma.user.findUnique({ where: { username } })) {
return NextResponse.json({ success: false, message: 'Username sudah digunakan' }, { status: 409 });
}
// Generate OTP
const codeOtp = randomOTP();
const otpNumber = Number(codeOtp);
// Kirim WA
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=Website Desa Darmasaba - Kode verifikasi Anda: ${codeOtp}`;
const res = await fetch(waUrl);
const sendWa = await res.json();
if (sendWa.status !== "success") {
return NextResponse.json({ success: false, message: 'Gagal mengirim OTP' }, { status: 400 });
}
// Simpan OTP
const otpRecord = await prisma.kodeOtp.create({
data: { nomor, otp: otpNumber, isActive: true }
});
return NextResponse.json({
success: true,
message: 'Kode verifikasi dikirim',
kodeId: otpRecord.id,
nomor,
});
} catch (error) {
console.error('Send OTP for Register Error:', error);
return NextResponse.json({ success: false, message: 'Gagal mengirim OTP' }, { status: 500 });
} finally {
await prisma.$disconnect();
}
}

View File

@@ -0,0 +1,78 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma";
import { NextResponse } from "next/server";
import { sessionCreate } from "../_lib/session_create";
export async function POST(req: Request) {
if (req.method !== "POST") {
return NextResponse.json(
{ success: false, message: "Method Not Allowed" },
{ status: 405 }
);
}
try {
const { nomor } = await req.json();
const dataUser = await prisma.user.findUnique({
where: {
nomor: nomor,
},
select: {
id: true,
nomor: true,
username: true,
roleId: true,
},
});
if (dataUser == null)
return NextResponse.json(
{ success: false, message: "Nomor Belum Terdaftar" },
{ status: 200 }
);
const token = await sessionCreate({
sessionKey: process.env.BASE_SESSION_KEY!,
jwtSecret: process.env.BASE_TOKEN_KEY!,
user: dataUser as any,
});
if (!token) {
return NextResponse.json(
{ success: false, message: "Gagal membuat session" },
{ status: 500 }
);
}
// Buat response dengan token dalam cookie
const response = NextResponse.json(
{
success: true,
message: "Berhasil Login",
roleId: dataUser.roleId,
},
{ status: 200 }
);
// Set cookie dengan token yang sudah dipastikan tidak null
response.cookies.set(process.env.NEXT_PUBLIC_BASE_SESSION_KEY!, token, {
path: "/",
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: 30 * 24 * 60 * 60, // 30 hari dalam detik (1 bulan)
});
return response;
} catch (error) {
console.error("API Error or Server Error", error);
return NextResponse.json(
{
success: false,
message: "Maaf, Terjadi Keselahan",
reason: (error as Error).message,
},
{ status: 500 }
);
} finally {
await prisma.$disconnect();
}
}

View File

@@ -0,0 +1,146 @@
// app/api/auth/verify-otp/route.ts
import prisma from "@/lib/prisma";
import { NextResponse } from "next/server";
import { sessionCreate } from "../_lib/session_create";
export async function POST(req: Request) {
if (req.method !== "POST") {
return NextResponse.json(
{ success: false, message: "Method Not Allowed" },
{ status: 405 }
);
}
try {
const { nomor, otp, kodeId } = await req.json();
// Validasi input
if (!nomor || !otp || !kodeId) {
return NextResponse.json(
{ success: false, message: "Data tidak lengkap" },
{ status: 400 }
);
}
// Cari OTP record
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" },
{ status: 400 }
);
}
// Pastikan tipe data cocok (OTP di DB = number)
const receivedOtp = Number(otp);
if (isNaN(receivedOtp) || otpRecord.otp !== receivedOtp) {
return NextResponse.json(
{ success: false, message: "Kode OTP salah" },
{ status: 400 }
);
}
if (otpRecord.nomor !== nomor) {
return NextResponse.json(
{ success: false, message: "Nomor tidak sesuai" },
{ status: 400 }
);
}
// Cek user berdasarkan nomor
const user = await prisma.user.findUnique({
where: { nomor },
select: {
id: true,
nomor: true,
username: true,
roleId: true,
isActive: true,
},
});
if (!user) {
return NextResponse.json(
{ success: false, message: "Akun tidak ditemukan" },
{ status: 404 }
);
}
if (!user.isActive) {
return NextResponse.json(
{ success: false, message: "Akun belum disetujui oleh admin" },
{ status: 403 }
);
}
// Buat session
const token = await sessionCreate({
sessionKey: process.env.BASE_SESSION_KEY!,
jwtSecret: process.env.BASE_TOKEN_KEY!, // ✅
user: {
id: user.id,
nomor: user.nomor,
username: user.username,
roleId: user.roleId,
isActive: user.isActive,
},
});
if (!token) {
return NextResponse.json(
{ success: false, message: "Gagal membuat session" },
{ status: 500 }
);
}
// Nonaktifkan OTP
await prisma.kodeOtp.update({
where: { id: kodeId },
data: { isActive: false },
});
const userData = {
id: user.id,
name: user.username, // atau user.nama jika ada kolom nama
roleId: user.roleId,
};
// Set cookie & respons
const response = NextResponse.json(
{
success: true,
message: "Berhasil login",
user: userData,
roleId: user.roleId,
},
{ status: 200 }
);
response.cookies.set(process.env.BASE_SESSION_KEY!, token, {
path: "/",
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
httpOnly: true, // 🔒 lebih aman
maxAge: 30 * 24 * 60 * 60,
});
return response;
} catch (error) {
console.error("Verify OTP Error:", error);
return NextResponse.json(
{ success: false, message: "Terjadi kesalahan saat verifikasi" },
{ status: 500 }
);
} finally {
await prisma.$disconnect();
}
}

View File

@@ -1,7 +1,7 @@
'use client'
import beasiswaDesaState from '@/app/admin/(dashboard)/_state/pendidikan/beasiswa-desa';
import colors from '@/con/colors';
import { Box, Button, Center, Group, Image, Modal, Pagination, Paper, Select, SimpleGrid, Skeleton, Stack, Stepper, Text, TextInput, Title } from '@mantine/core';
import { Box, Button, Center, Divider, Group, Image, Modal, Pagination, Paper, Select, SimpleGrid, Skeleton, Stack, Stepper, Text, TextInput, Title } from '@mantine/core';
import { useDisclosure, useShallowEffect } from '@mantine/hooks';
import { IconArrowRight, IconCoin, IconInfoCircle, IconSchool, IconUsers } from '@tabler/icons-react';
import { useTransitionRouter } from 'next-view-transitions';
@@ -23,18 +23,17 @@ function Page() {
const resetForm = () => {
beasiswaDesa.create.form = {
namaLengkap: "",
nik: "",
nis: "",
kelas: "",
jenisKelamin: "",
alamatDomisili: "",
tempatLahir: "",
tanggalLahir: "",
jenisKelamin: "",
kewarganegaraan: "WNI",
agama: "",
alamatKTP: "",
alamatDomisili: "",
namaOrtu: "",
nik: "",
pekerjaanOrtu: "",
penghasilan: "",
noHp: "",
email: "",
statusPernikahan: "",
ukuranBaju: "",
};
};
@@ -189,9 +188,22 @@ function Page() {
onChange={(val) => { beasiswaDesa.create.form.namaLengkap = val.target.value }} />
<TextInput
type="number"
label="NIK"
placeholder="Masukkan NIK"
onChange={(val) => { beasiswaDesa.create.form.nik = val.target.value }} />
label="NIS"
placeholder="Masukkan NIS"
onChange={(val) => { beasiswaDesa.create.form.nis = val.target.value }} />
<TextInput
label="Kelas"
placeholder="Masukkan kelas"
onChange={(val) => { beasiswaDesa.create.form.kelas = val.target.value }} />
<Select
label="Jenis Kelamin"
placeholder="Pilih jenis kelamin"
data={[{ value: "LAKI_LAKI", label: "Laki-laki" }, { value: "PEREMPUAN", label: "Perempuan" }]}
onChange={(val) => { if (val) beasiswaDesa.create.form.jenisKelamin = val }} />
<TextInput
label="Alamat Domisili"
placeholder="Masukkan alamat domisili"
onChange={(val) => { beasiswaDesa.create.form.alamatDomisili = val.target.value }} />
<TextInput
label="Tempat Lahir"
placeholder="Masukkan tempat lahir"
@@ -201,52 +213,29 @@ function Page() {
label="Tanggal Lahir"
placeholder="Pilih tanggal lahir"
onChange={(val) => { beasiswaDesa.create.form.tanggalLahir = val.target.value }} />
<Select
label="Jenis Kelamin"
placeholder="Pilih jenis kelamin"
data={[{ value: "LAKI_LAKI", label: "Laki-laki" }, { value: "PEREMPUAN", label: "Perempuan" }]}
onChange={(val) => { if (val) beasiswaDesa.create.form.jenisKelamin = val }} />
<Box pt={15} pb={10}>
<Divider />
</Box>
<TextInput
label="Kewarganegaraan"
placeholder="Masukkan kewarganegaraan"
value={beasiswaDesa.create.form.kewarganegaraan || "WNI"} // tampilkan WNI kalau kosong
onChange={(e) => {
beasiswaDesa.create.form.kewarganegaraan = e.target.value;
}}
/>
<Select
label="Agama"
placeholder="Pilih agama"
data={[{ value: "ISLAM", label: "Islam" }, { value: "KRISTEN_PROTESTAN", label: "Kristen Protestan" }, { value: "KRISTEN_KATOLIK", label: "Kristen Katolik" }, { value: "HINDU", label: "Hindu" }, { value: "BUDDHA", label: "Buddha" }, { value: "KONGHUCU", label: "Konghucu" }, { value: "LAINNYA", label: "Lainnya" }]}
onChange={(val) => { if (val) beasiswaDesa.create.form.agama = val }} />
label="Nama Orang Tua / Wali"
placeholder="Masukkan nama orang tua / wali"
onChange={(val) => { beasiswaDesa.create.form.namaOrtu = val.target.value }} />
<TextInput
label="Alamat KTP"
placeholder="Masukkan alamat sesuai KTP"
onChange={(val) => { beasiswaDesa.create.form.alamatKTP = val.target.value }} />
label="NIK Orang Tua / Wali"
placeholder="Masukkan NIK orang tua / wali"
onChange={(val) => { beasiswaDesa.create.form.nik = val.target.value }} />
<TextInput
label="Alamat Domisili"
placeholder="Masukkan alamat domisili"
onChange={(val) => { beasiswaDesa.create.form.alamatDomisili = val.target.value }} />
label="Pekerjaan Orang Tua / Wali"
placeholder="Masukkan pekerjaan orang tua / wali"
onChange={(val) => { beasiswaDesa.create.form.pekerjaanOrtu = val.target.value }} />
<TextInput
type="number"
label="Nomor HP"
placeholder="Masukkan nomor HP"
label="Penghasilan Orang Tua / Wali"
placeholder="Masukkan penghasilan orang tua / wali"
onChange={(val) => { beasiswaDesa.create.form.penghasilan = val.target.value }} />
<TextInput
label="No HP"
placeholder="Masukkan no hp"
onChange={(val) => { beasiswaDesa.create.form.noHp = val.target.value }} />
<TextInput
type="email"
label="Email"
placeholder="Masukkan alamat email"
onChange={(val) => { beasiswaDesa.create.form.email = val.target.value }} />
<Select
label="Status Pernikahan"
placeholder="Pilih status pernikahan"
data={[{ value: "BELUM_MENIKAH", label: "Belum Menikah" }, { value: "MENIKAH", label: "Menikah" }, { value: "JANDA_DUDA", label: "Janda/Duda" }]}
onChange={(val) => { if (val) beasiswaDesa.create.form.statusPernikahan = val }} />
<Select
label="Ukuran Baju"
placeholder="Pilih ukuran baju"
data={[{ value: "S", label: "S" }, { value: "M", label: "M" }, { value: "L", label: "L" }, { value: "XL", label: "XL" }, { value: "XXL", label: "XXL" }, { value: "LAINNYA", label: "Lainnya" }]}
onChange={(val) => { if (val) beasiswaDesa.create.form.ukuranBaju = val }} />
<Group justify="flex-end" mt="md">
<Button variant="default" radius="xl" onClick={close}>Batal</Button>
<Button radius="xl" bg={colors['blue-button']} onClick={handleSubmit}>Kirim</Button>

View File

@@ -4,6 +4,7 @@ import {
Box,
Button,
Container,
Divider,
Group,
Modal,
Paper,
@@ -30,18 +31,17 @@ export default function BeasiswaPage() {
const resetForm = () => {
beasiswaDesa.create.form = {
namaLengkap: "",
nik: "",
nis: "",
kelas: "",
jenisKelamin: "",
alamatDomisili: "",
tempatLahir: "",
tanggalLahir: "",
jenisKelamin: "",
kewarganegaraan: "WNI",
agama: "",
alamatKTP: "",
alamatDomisili: "",
namaOrtu: "",
nik: "",
pekerjaanOrtu: "",
penghasilan: "",
noHp: "",
email: "",
statusPernikahan: "",
ukuranBaju: "",
};
};
@@ -248,9 +248,22 @@ export default function BeasiswaPage() {
onChange={(val) => { beasiswaDesa.create.form.namaLengkap = val.target.value }} />
<TextInput
type="number"
label="NIK"
placeholder="Masukkan NIK"
onChange={(val) => { beasiswaDesa.create.form.nik = val.target.value }} />
label="NIS"
placeholder="Masukkan NIS"
onChange={(val) => { beasiswaDesa.create.form.nis = val.target.value }} />
<TextInput
label="Kelas"
placeholder="Masukkan kelas"
onChange={(val) => { beasiswaDesa.create.form.kelas = val.target.value }} />
<Select
label="Jenis Kelamin"
placeholder="Pilih jenis kelamin"
data={[{ value: "LAKI_LAKI", label: "Laki-laki" }, { value: "PEREMPUAN", label: "Perempuan" }]}
onChange={(val) => { if (val) beasiswaDesa.create.form.jenisKelamin = val }} />
<TextInput
label="Alamat Domisili"
placeholder="Masukkan alamat domisili"
onChange={(val) => { beasiswaDesa.create.form.alamatDomisili = val.target.value }} />
<TextInput
label="Tempat Lahir"
placeholder="Masukkan tempat lahir"
@@ -260,52 +273,29 @@ export default function BeasiswaPage() {
label="Tanggal Lahir"
placeholder="Pilih tanggal lahir"
onChange={(val) => { beasiswaDesa.create.form.tanggalLahir = val.target.value }} />
<Select
label="Jenis Kelamin"
placeholder="Pilih jenis kelamin"
data={[{ value: "LAKI_LAKI", label: "Laki-laki" }, { value: "PEREMPUAN", label: "Perempuan" }]}
onChange={(val) => { if (val) beasiswaDesa.create.form.jenisKelamin = val }} />
<Box pt={15} pb={10}>
<Divider />
</Box>
<TextInput
label="Kewarganegaraan"
placeholder="Masukkan kewarganegaraan"
value={beasiswaDesa.create.form.kewarganegaraan || "WNI"} // tampilkan WNI kalau kosong
onChange={(e) => {
beasiswaDesa.create.form.kewarganegaraan = e.target.value;
}}
/>
<Select
label="Agama"
placeholder="Pilih agama"
data={[{ value: "ISLAM", label: "Islam" }, { value: "KRISTEN_PROTESTAN", label: "Kristen Protestan" }, { value: "KRISTEN_KATOLIK", label: "Kristen Katolik" }, { value: "HINDU", label: "Hindu" }, { value: "BUDDHA", label: "Buddha" }, { value: "KONGHUCU", label: "Konghucu" }, { value: "LAINNYA", label: "Lainnya" }]}
onChange={(val) => { if (val) beasiswaDesa.create.form.agama = val }} />
label="Nama Orang Tua / Wali"
placeholder="Masukkan nama orang tua / wali"
onChange={(val) => { beasiswaDesa.create.form.namaOrtu = val.target.value }} />
<TextInput
label="Alamat KTP"
placeholder="Masukkan alamat sesuai KTP"
onChange={(val) => { beasiswaDesa.create.form.alamatKTP = val.target.value }} />
label="NIK Orang Tua / Wali"
placeholder="Masukkan NIK orang tua / wali"
onChange={(val) => { beasiswaDesa.create.form.nik = val.target.value }} />
<TextInput
label="Alamat Domisili"
placeholder="Masukkan alamat domisili"
onChange={(val) => { beasiswaDesa.create.form.alamatDomisili = val.target.value }} />
label="Pekerjaan Orang Tua / Wali"
placeholder="Masukkan pekerjaan orang tua / wali"
onChange={(val) => { beasiswaDesa.create.form.pekerjaanOrtu = val.target.value }} />
<TextInput
type="number"
label="Nomor HP"
placeholder="Masukkan nomor HP"
label="Penghasilan Orang Tua / Wali"
placeholder="Masukkan penghasilan orang tua / wali"
onChange={(val) => { beasiswaDesa.create.form.penghasilan = val.target.value }} />
<TextInput
label="No HP"
placeholder="Masukkan no hp"
onChange={(val) => { beasiswaDesa.create.form.noHp = val.target.value }} />
<TextInput
type="email"
label="Email"
placeholder="Masukkan alamat email"
onChange={(val) => { beasiswaDesa.create.form.email = val.target.value }} />
<Select
label="Status Pernikahan"
placeholder="Pilih status pernikahan"
data={[{ value: "BELUM_MENIKAH", label: "Belum Menikah" }, { value: "MENIKAH", label: "Menikah" }, { value: "JANDA_DUDA", label: "Janda/Duda" }]}
onChange={(val) => { if (val) beasiswaDesa.create.form.statusPernikahan = val }} />
<Select
label="Ukuran Baju"
placeholder="Pilih ukuran baju"
data={[{ value: "S", label: "S" }, { value: "M", label: "M" }, { value: "L", label: "L" }, { value: "XL", label: "XL" }, { value: "XXL", label: "XXL" }, { value: "LAINNYA", label: "Lainnya" }]}
onChange={(val) => { if (val) beasiswaDesa.create.form.ukuranBaju = val }} />
<Group justify="flex-end" mt="md">
<Button variant="default" radius="xl" onClick={close}>Batal</Button>
<Button radius="xl" bg={colors['blue-button']} onClick={handleSubmit}>Kirim</Button>

View File

@@ -0,0 +1,208 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// src/app/admin/(dashboard)/landing-page/APBDes/APBDesProgress.tsx
'use client';
import { Box, Paper, Progress, Stack, Text, Title } from '@mantine/core';
import { useProxy } from 'valtio/utils';
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes';
import colors from '@/con/colors';
function formatRupiah(value: number) {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 2,
}).format(value);
}
function APBDesProgress() {
const state = useProxy(apbdes);
const data = state.findMany.data || [];
// Ambil APBDes pertama (misalnya, jika hanya satu tahun ditampilkan)
const apbdesItem = data[0]; // 👈 sesuaikan logika jika ada banyak APBDes
if (!apbdesItem) {
return (
<Box py="md" px={{ base: 'md', md: 100 }}>
<Text c="dimmed">Belum ada data APBDes untuk ditampilkan.</Text>
</Box>
);
}
const items = apbdesItem.items || [];
const sortedItems = [...items].sort((a, b) => a.kode.localeCompare(b.kode));
// Kelompokkan berdasarkan tipe
const pendapatanItems = sortedItems.filter(item => item.tipe === 'pendapatan');
const belanjaItems = sortedItems.filter(item => item.tipe === 'belanja');
const pembiayaanItems = sortedItems.filter(item => item.tipe === 'pembiayaan'); // jika ada
// Hitung total per kategori
const calcTotal = (items: any[]) => {
const anggaran = items.reduce((sum, item) => sum + item.anggaran, 0);
const realisasi = items.reduce((sum, item) => sum + item.realisasi, 0);
const persen = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
return { anggaran, realisasi, persen };
};
const pendapatan = calcTotal(pendapatanItems);
const belanja = calcTotal(belanjaItems);
const pembiayaan = calcTotal(pembiayaanItems); // bisa kosong
// Render satu progress bar
const renderProgress = (label: string, dataset: any) => {
const isPembiayaan = label.includes('Pembiayaan');
return (
<Box key={label}>
<Text fw={600} fz="sm">{label}</Text>
<Text fw={700} mb="xs">
{formatRupiah(dataset.realisasi)} | {formatRupiah(dataset.anggaran)}
</Text>
<Progress
value={dataset.persen}
size="xl"
radius="xl"
striped={false}
styles={{
root: { backgroundColor: '#d7e3f1' },
section: {
backgroundColor: isPembiayaan
? 'green' // warna hijau untuk pembiayaan
: colors['blue-button'], // biru untuk pendapatan/belanja
position: 'relative',
'&::after': {
content: `'${dataset.persen.toFixed(2)}%'`,
position: 'absolute',
right: 10,
top: '50%',
transform: 'translateY(-50%)',
color: 'white',
fontWeight: 700,
fontSize: '0.8rem',
},
},
}}
/>
</Box>
);
};
return (
<Paper
mx={{ base: 'md', md: 100 }}
p="xl"
radius="md"
shadow="sm"
withBorder
bg={colors['white-1']}
>
<Stack gap="lg">
<Title order={4} c={colors['blue-button']} ta="center">
Grafik Pelaksanaan APBDes Tahun {apbdesItem.tahun}
</Title>
<Text ta="center" fw="bold" fz="sm" c="dimmed">
Realisasi | Anggaran
</Text>
{renderProgress('Pendapatan Desa', pendapatan)}
{renderProgress('Belanja Desa', belanja)}
{renderProgress('Pembiayaan Desa', pembiayaan)}
{pembiayaanItems.length > 0 && renderProgress('Pembiayaan Desa', pembiayaan)}
</Stack>
</Paper>
);
}
export default APBDesProgress;
// /* eslint-disable @typescript-eslint/no-explicit-any */
// 'use client';
// import { Box, Paper, Stack, Text, Title } from '@mantine/core';
// import { BarChart } from '@mantine/charts';
// import { useProxy } from 'valtio/utils';
// import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes';
// import colors from '@/con/colors';
// function APBDesProgress() {
// const state = useProxy(apbdes);
// const data = state.findMany.data || [];
// const apbdesItem = data[0];
// if (!apbdesItem) {
// return (
// <Box py="md" px={{ base: 'md', md: 100 }}>
// <Text c="dimmed">Belum ada data APBDes untuk ditampilkan.</Text>
// </Box>
// );
// }
// const items = apbdesItem.items || [];
// const sortedItems = [...items].sort((a, b) => a.kode.localeCompare(b.kode));
// const pendapatanItems = sortedItems.filter(i => i.tipe === 'pendapatan');
// const belanjaItems = sortedItems.filter(i => i.tipe === 'belanja');
// const pembiayaanItems = sortedItems.filter(i => i.tipe === 'pembiayaan');
// const total = (rows: any[]) => {
// const anggaran = rows.reduce((s, i) => s + i.anggaran, 0);
// const realisasi = rows.reduce((s, i) => s + i.realisasi, 0);
// return anggaran === 0 ? 0 : (realisasi / anggaran) * 100;
// };
// const chartData = [
// { name: 'Pendapatan', persen: total(pendapatanItems) },
// { name: 'Belanja', persen: total(belanjaItems) },
// ];
// if (pembiayaanItems.length > 0) {
// chartData.push({ name: 'Pembiayaan', persen: total(pembiayaanItems) });
// }
// return (
// <Paper
// mx={{ base: 'md', md: 100 }}
// p="xl"
// radius="md"
// shadow="sm"
// withBorder
// bg={colors['white-1']}
// >
// <Stack gap="lg">
// <Title order={4} c={colors['blue-button']} ta="center">
// Grafik Pelaksanaan APBDes Tahun {apbdesItem.tahun}
// </Title>
// <Text ta="center" fw="bold" fz="sm" c="dimmed">
// Persentase Realisasi (%) dari Anggaran
// </Text>
// <BarChart
// h={200}
// data={chartData}
// orientation="vertical"
// dataKey="name"
// barProps={{ radius: 6 }}
// series={[
// {
// name: 'persen',
// label: 'Persentase',
// color: colors['blue-button'],
// },
// ]}
// yAxisProps={{
// domain: [0, 100],
// }}
// valueFormatter={(v) => `${v.toFixed(1)}%`}
// />
// </Stack>
// </Paper>
// );
// }
// export default APBDesProgress;

View File

@@ -0,0 +1,160 @@
// src/app/admin/(dashboard)/landing-page/APBDes/APBDesTable.tsx
'use client';
import { Box, Paper, Table, Text, Title, Badge, Group } from '@mantine/core';
import { useProxy } from 'valtio/utils';
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes';
import colors from '@/con/colors';
interface APBDesItem {
id: string;
kode: string;
uraian: string;
anggaran: number;
realisasi: number;
selisih: number;
persentase: number;
level: number;
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
}
interface APBDesData {
id: string;
tahun: number;
items: APBDesItem[];
image?: { id: string; url: string } | null;
file?: { id: string; url: string } | null;
}
// Helper: Format Rupiah, tapi jika 0 → tampilkan '-'
function formatRupiahOrEmpty(value: number): string {
if (value === 0 || value === null || value === undefined) {
return '-';
}
return new Intl.NumberFormat('id-ID', {
minimumFractionDigits: 2,
style: 'decimal',
}).format(value);
}
// Helper: Format Persentase, tapi jika 0 → tampilkan '-'
function formatPersentaseOrEmpty(value: number): string {
if (value === 0 || value === null || value === undefined) {
return '-';
}
return `${value.toFixed(2)}%`;
}
function getIndent(level: number) {
return {
paddingLeft: `${(level - 1) * 20}px`,
};
}
function APBDesTable() {
const state = useProxy(apbdes);
const data = state.findMany.data || [];
// Get the first APBDes item
const apbdesItem = data[0] as unknown as APBDesData | undefined;
if (!apbdesItem) {
return (
<Box py="md" px={{ base: 'md', md: 100 }}>
<Text c="dimmed">Belum ada data APBDes untuk ditampilkan.</Text>
</Box>
);
}
const items = Array.isArray(apbdesItem.items) ? apbdesItem.items : [];
const sortedItems = [...items].sort((a, b) => a.kode.localeCompare(b.kode));
// Calculate totals
const totalAnggaran = items.reduce((sum, item) => sum + (item.anggaran || 0), 0);
const totalRealisasi = items.reduce((sum, item) => sum + (item.realisasi || 0), 0);
const totalSelisih = totalAnggaran - totalRealisasi;
const totalPersentase = totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0;
return (
<Box py="md" px={{ base: 'md', md: 100 }}>
<Title order={4} c={colors['blue-button']} mb="sm">
Rincian APBDes Tahun {apbdesItem.tahun}
</Title>
<Paper withBorder radius="md" shadow="xs" p="md">
<Box style={{overflowY: 'auto' }}>
<Table withColumnBorders highlightOnHover>
<Table.Thead bg="#2c5f78">
<Table.Tr>
<Table.Th c="white" style={{ width: '40%' }}>
Uraian
</Table.Th>
<Table.Th c="white" ta="right" style={{ width: '15%' }}>
Anggaran (Rp)
</Table.Th>
<Table.Th c="white" ta="right" style={{ width: '15%' }}>
Realisasi (Rp)
</Table.Th>
<Table.Th c="white" ta="right" style={{ width: '15%' }}>
Lebih/(Kurang) (Rp)
</Table.Th>
<Table.Th c="white" ta="center" style={{ width: '15%' }}>
Persentase (%)
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{sortedItems.map((item) => (
<Table.Tr key={item.id}>
<Table.Td style={getIndent(item.level)}>
<Group gap="xs" align="flex-start">
<Text fw={item.level === 1 ? 'bold' : 'normal'}>{item.kode}</Text>
<Text fz="sm" >
{item.uraian}
</Text>
</Group>
</Table.Td>
<Table.Td ta="right">{formatRupiahOrEmpty(item.anggaran)}</Table.Td>
<Table.Td ta="right">{formatRupiahOrEmpty(item.realisasi)}</Table.Td>
<Table.Td ta="right">
<Text c={item.selisih >= 0 ? 'green' : 'red'}>
{formatRupiahOrEmpty(item.selisih)}
</Text>
</Table.Td>
<Table.Td ta="center">
<Badge color={item.persentase >= 100 ? 'teal' : 'yellow'} size="sm">
{formatPersentaseOrEmpty(item.persentase)}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
<Table.Tfoot bg="#e6f0f7">
<Table.Tr>
<Table.Th colSpan={1}>
<Text fw={700}>JUMLAH PENDAPATAN</Text>
</Table.Th>
<Table.Th ta="right">
<Text fw={700}>{formatRupiahOrEmpty(totalAnggaran)}</Text>
</Table.Th>
<Table.Th ta="right">
<Text fw={700}>{formatRupiahOrEmpty(totalRealisasi)}</Text>
</Table.Th>
<Table.Th ta="right">
<Text fw={700} c={totalSelisih >= 0 ? 'green' : 'red'}>
{formatRupiahOrEmpty(totalSelisih)}
</Text>
</Table.Th>
<Table.Th ta="center">
<Text fw={700}>{formatPersentaseOrEmpty(totalPersentase)}</Text>
</Table.Th>
</Table.Tr>
</Table.Tfoot>
</Table>
</Box>
</Paper>
</Box>
);
}
export default APBDesTable;

View File

@@ -4,12 +4,14 @@
import PendapatanAsliDesa from '@/app/admin/(dashboard)/_state/ekonomi/PADesa'
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes'
import colors from '@/con/colors'
import { ActionIcon, BackgroundImage, Box, Center, Container, Group, Loader, Paper, Progress, SimpleGrid, Stack, Table, Text, Title } from '@mantine/core'
import { ActionIcon, BackgroundImage, Box, Center, Container, Group, Loader, SimpleGrid, Stack, Text, Title } from '@mantine/core'
import { IconDownload } from '@tabler/icons-react'
import { Link } from 'next-view-transitions'
import { useEffect, useState } from 'react'
import { useProxy } from 'valtio/utils'
import BackButton from '../../(pages)/desa/layanan/_com/BackButto'
import APBDesProgress from './lib/apbDesaProgress'
import APBDesTable from './lib/apbDesaTable'
function Page() {
const state = useProxy(apbdes)
@@ -92,200 +94,10 @@ function Page() {
))}
</SimpleGrid>
)}
<DetailAPBDesaTable />
<APBDesaProgress />
<APBDesTable />
<APBDesProgress />
</Stack>
)
}
function DetailAPBDesaTable() {
// 🔹 Dummy data
const data = {
tahun: 2024,
pendapatan: [
{ id: 1, nama: 'Pendapatan Asli Desa', anggaran: 32000000, realisasi: 6500000 },
{ id: 2, nama: 'Dana Desa', anggaran: 125000000, realisasi: 120000000 },
{ id: 3, nama: 'Bagi Hasil Pajak dan Retribusi', anggaran: 10000000, realisasi: 9000000 },
],
belanja: [
{ id: 1, nama: 'Belanja Pegawai', anggaran: 80000000, realisasi: 75000000 },
{ id: 2, nama: 'Belanja Barang & Jasa', anggaran: 50000000, realisasi: 42000000 },
],
pembiayaan: [
{ id: 1, nama: 'Penerimaan Pembiayaan', anggaran: 15000000, realisasi: 15000000 },
{ id: 2, nama: 'Pengeluaran Pembiayaan', anggaran: 10000000, realisasi: 8000000 },
],
};
const formatRupiah = (value: number) =>
new Intl.NumberFormat('id-ID', {
minimumFractionDigits: 2,
style: 'decimal',
}).format(value);
// 🔹 Helper buat render satu kategori (Pendapatan, Belanja, Pembiayaan)
const renderSection = (title: string, items: any[]) => {
const totalAnggaran = items.reduce((sum, i) => sum + Number(i.anggaran || 0), 0);
const totalRealisasi = items.reduce((sum, i) => sum + Number(i.realisasi || 0), 0);
const totalSelisih = totalAnggaran - totalRealisasi;
const totalPersen = totalAnggaran
? (totalRealisasi / totalAnggaran) * 100
: 0;
return (
<Paper withBorder radius="md" shadow="xs" p="md">
<Title order={5} mb="sm" c={colors['blue-button']}>
{title.toUpperCase()}
</Title>
<Table withColumnBorders highlightOnHover>
<Table.Thead bg={colors['blue-button']}>
<Table.Tr>
<Table.Th c="white">Uraian</Table.Th>
<Table.Th c="white" ta="right">
Anggaran (Rp)
</Table.Th>
<Table.Th c="white" ta="right">
Realisasi (Rp)
</Table.Th>
<Table.Th c="white" ta="right">
Lebih/(Kurang) (Rp)
</Table.Th>
<Table.Th c="white" ta="center">
Persentase (%)
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((item, index) => {
const selisih = Number(item.anggaran) - Number(item.realisasi);
const persen = item.anggaran
? (item.realisasi / item.anggaran) * 100
: 0;
return (
<Table.Tr key={item.id || index}>
<Table.Td>
<strong>{`${index + 1}. ${item.nama}`}</strong>
</Table.Td>
<Table.Td ta="right">{formatRupiah(item.anggaran)}</Table.Td>
<Table.Td ta="right">{formatRupiah(item.realisasi)}</Table.Td>
<Table.Td ta="right">{formatRupiah(selisih)}</Table.Td>
<Table.Td ta="center">{persen.toFixed(2)}</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
<Table.Tfoot bg="#f1f5fb">
<Table.Tr>
<Table.Th>Total {title}</Table.Th>
<Table.Th ta="right">{formatRupiah(totalAnggaran)}</Table.Th>
<Table.Th ta="right">{formatRupiah(totalRealisasi)}</Table.Th>
<Table.Th ta="right">{formatRupiah(totalSelisih)}</Table.Th>
<Table.Th ta="center">{totalPersen.toFixed(2)}</Table.Th>
</Table.Tr>
</Table.Tfoot>
</Table>
</Paper>
);
};
return (
<Box py="md" px={{ base: 'md', md: 100 }}>
<Stack gap="xl">
<Title order={4} c={colors['blue-button']}>
APB Desa Tahun {data.tahun}
</Title>
{renderSection('Pendapatan', data.pendapatan)}
{renderSection('Belanja', data.belanja)}
{renderSection('Pembiayaan', data.pembiayaan)}
</Stack>
</Box>
);
}
function APBDesaProgress() {
const data = {
tahun: 2024,
pendapatan: [
{ id: 1, nama: 'Pendapatan Asli Desa', anggaran: 32000000, realisasi: 6500000 },
{ id: 2, nama: 'Dana Desa', anggaran: 125000000, realisasi: 120000000 },
{ id: 3, nama: 'Bagi Hasil Pajak dan Retribusi', anggaran: 10000000, realisasi: 9000000 },
],
belanja: [
{ id: 1, nama: 'Belanja Pegawai', anggaran: 80000000, realisasi: 75000000 },
{ id: 2, nama: 'Belanja Barang & Jasa', anggaran: 50000000, realisasi: 42000000 },
],
pembiayaan: [
{ id: 1, nama: 'Penerimaan Pembiayaan', anggaran: 15000000, realisasi: 15000000 },
{ id: 2, nama: 'Pengeluaran Pembiayaan', anggaran: 10000000, realisasi: 8000000 },
],
};
const formatRupiah = (value: number) =>
new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 2,
}).format(value);
const calcProgress = (items: any[]) => {
const anggaran = items.reduce((sum, i) => sum + i.anggaran, 0);
const realisasi = items.reduce((sum, i) => sum + i.realisasi, 0);
const persen = anggaran ? (realisasi / anggaran) * 100 : 0;
return { anggaran, realisasi, persen };
};
const pendapatan = calcProgress(data.pendapatan);
const belanja = calcProgress(data.belanja);
const pembiayaan = calcProgress(data.pembiayaan);
const renderProgress = (label: string, dataset: any) => (
<Box>
<Text fw={600}>{label}</Text>
<Text fw={700} mb="xs">
{formatRupiah(dataset.realisasi)} | {formatRupiah(dataset.anggaran)}
</Text>
<Progress
value={dataset.persen}
size="xl"
radius="xl"
striped={false}
styles={{
root: { backgroundColor: '#d7e3f1' },
section: {
backgroundColor: colors['blue-button'],
position: 'relative',
'&::after': {
content: `'${dataset.persen.toFixed(2)}%'`,
position: 'absolute',
right: 10,
top: '50%',
transform: 'translateY(-50%)',
color: 'white',
fontWeight: 700,
fontSize: '0.8rem',
}
},
}}
/>
</Box>
);
return (
<Paper mx={{ base: 'md', md: 100 }} p="xl" radius="md" shadow="sm" withBorder bg={colors['white-1']}>
<Stack gap="lg">
<Title order={4} c={colors['blue-button']}>
Grafik APB Desa Tahun {data.tahun}
</Title>
{renderProgress('Pendapatan Desa', pendapatan)}
{renderProgress('Belanja Desa', belanja)}
{renderProgress('Pembiayaan Desa', pembiayaan)}
</Stack>
</Paper>
);
}
export default Page
export default Page

View File

@@ -209,50 +209,57 @@ function Page() {
) : null}
{/* Chart */}
<Box mt={30} style={{ width: '100%', minHeight: 400 }}>
<Paper bg={colors['white-1']} pt={50} pb={170} px={90} mb={"xl"} radius="md" withBorder>
<Stack gap={"xs"}>
<Title ta={"center"} pb={10} order={2}>
Grafik APBDes
</Title>
<Box mt={30} style={{ width: '100%' }}>
<Paper
bg={colors['white-1']}
py={90}
px={{ base: 12, sm: 24, md: 50 }}
radius="md"
withBorder
>
<Stack gap="lg">
<Title ta="center" order={2}>Grafik SDGs Desa</Title>
{mounted && chartData.length > 0 ? (
<Box style={{ padding: '0 30px' }}> {/* Tambahkan padding horizontal agar label tidak keluar */}
<BarChart
h={500}
data={chartData}
dataKey="name"
type="stacked"
withBarValueLabel
series={[
{
name: 'jumlah',
color: colors['blue-button'],
// label: 'Jumlah', → HAPUS INI AGAR LEGEND TIDAK MUNCUL
},
]}
withTooltip
tooltipProps={{
labelFormatter: (value) => value,
formatter: (value) => `${value}%`,
}}
xAxisProps={{
angle: -45,
textAnchor: 'end',
interval: 0,
fontSize: 12,
dy: 10,
}}
yAxisProps={{
domain: [0, 100],
tickCount: 6,
}}
style={{
overflowX: 'visible',
paddingBottom: 40, // Tambahkan ruang di bawah untuk label
}}
// Hilangkan legend secara eksplisit
withLegend={false} // ⭐ Ini yang menghilangkan kotak biru + teks "Jumlah"
/>
<Box
style={{
width: "100%",
overflowX: "auto",
paddingBottom: 185,
}}
>
<Box style={{ minWidth: 900 }}> {/* ⭐ batas minimum biar bisa scroll */}
<BarChart
h={350}
data={chartData}
dataKey="name"
type="stacked"
withBarValueLabel
series={[
{
name: 'jumlah',
color: colors['blue-button'],
},
]}
withTooltip
tooltipProps={{
labelFormatter: (value) => value,
formatter: (value) => `${value}%`,
}}
xAxisProps={{
angle: -45,
textAnchor: 'end',
interval: 0,
fontSize: 12,
dy: 10,
}}
yAxisProps={{
domain: [0, 100],
tickCount: 6,
}}
withLegend={false}
/>
</Box>
</Box>
) : (
<Text c="dimmed">Belum ada data untuk ditampilkan dalam grafik</Text>
@@ -260,6 +267,7 @@ function Page() {
</Stack>
</Paper>
</Box>
</Box>
</Box>
</Stack>

View File

@@ -125,7 +125,6 @@ export default function ModernNewsNotification({
position: "fixed",
bottom: "24px",
right: "24px",
zIndex: 1000,
}}
>
<ActionIcon
@@ -147,20 +146,20 @@ export default function ModernNewsNotification({
<IconBell size={28} />
{hasNewNotifications && news.length > 0 && (
<Badge
size="sm"
variant="filled"
color="red"
style={{
position: "absolute",
top: "-5px",
right: "-5px",
minWidth: "22px",
height: "22px",
padding: "0 6px",
}}
>
{news.length}
</Badge>
size="sm"
variant="filled"
color="red"
style={{
position: "absolute",
top: "6px",
right: "6px",
minWidth: "22px",
height: "22px",
padding: "0 6px",
}}
>
{news.length}
</Badge>
)}
</ActionIcon>
</Box>
@@ -178,7 +177,6 @@ export default function ModernNewsNotification({
right: "24px",
width: "380px",
maxHeight: "500px",
zIndex: 999,
boxShadow: "0 8px 32px rgba(0,0,0,0.12)",
borderRadius: "16px",
overflow: "hidden",
@@ -274,7 +272,6 @@ export default function ModernNewsNotification({
bottom: "100px",
right: "24px",
width: "380px",
zIndex: 1001,
boxShadow: "0 8px 32px rgba(0,0,0,0.15)",
borderRadius: "12px",
overflow: "hidden",

View File

@@ -1,25 +1,15 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes'
import APBDesProgress from '@/app/darmasaba/(tambahan)/apbdes/lib/apbDesaProgress'
import colors from '@/con/colors'
import { BarChart } from '@mantine/charts'
import { ActionIcon, BackgroundImage, Box, Button, Center, Flex, Group, Loader, Paper, SimpleGrid, Stack, Text, Title } from '@mantine/core'
import { ActionIcon, BackgroundImage, Box, Button, Center, Flex, Group, Loader, SimpleGrid, Stack, Text } from '@mantine/core'
import { IconDownload } from '@tabler/icons-react'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { useProxy } from 'valtio/utils'
import parseJumlah from './lib/convert'
function Apbdes() {
type APBDes = {
id: string
name: string
jumlah: number
};
const [chartData, setChartData] = useState<APBDes[]>([])
const [mounted, setMounted] = useState(false);
const state = useProxy(apbdes)
const [loading, setLoading] = useState(false)
@@ -28,22 +18,9 @@ function Apbdes() {
des: 'Transparansi APBDes Darmasaba adalah langkah nyata menuju tata kelola desa yang bersih, terbuka, dan bertanggung jawab.'
}
useEffect(() => {
if (state.findMany.data) {
setChartData(
state.findMany.data.map((item: any) => ({
id: item.id,
name: item.name,
jumlah: parseJumlah(item.jumlah),
}))
);
}
}, [state.findMany.data]);
useEffect(() => {
const loadData = async () => {
try {
setMounted(true);
setLoading(true)
await state.findMany.load()
} catch (error) {
@@ -70,56 +47,23 @@ function Apbdes() {
</Stack>
</Box>
{/* Chart */}
<Box mt={30} style={{ width: '100%', minHeight: 400 }}>
<Paper bg={colors['white-1']} py={50} px={90} mb={"xl"} radius="md" withBorder>
<Stack gap={"xs"}>
<Title ta={"center"} pb={10} order={2}>
Grafik APBDes
</Title>
{mounted && chartData.length > 0 ? (
<BarChart
orientation="vertical"
h={450}
barProps={{ radius: 50 }}
data={chartData}
dataKey="name"
type="stacked"
valueFormatter={(value: number) => {
if (value >= 1_000_000_000_000)
return `Rp ${(value / 1_000_000_000_000).toFixed(1)} T`;
if (value >= 1_000_000_000)
return `Rp ${(value / 1_000_000_000).toFixed(1)} M`;
if (value >= 1_000_000)
return `Rp ${(value / 1_000_000).toFixed(1)} JT`;
if (value >= 1_000)
return `Rp ${(value / 1_000).toFixed(1)} RB`;
return `Rp ${value}`;
}}
series={[
{
name: 'jumlah',
color: colors['blue-button'],
label: 'Jumlah',
},
]}
/>
) : (
<Text c="dimmed">Belum ada data untuk ditampilkan dalam grafik</Text>
)}
<Box py={10}>
<Group justify='center'>
<Flex align="center" gap={10}>
<Box bg={colors['blue-button']} w={20} h={20} />
<Text>Jumlah</Text>
</Flex>
</Group>
</Box>
</Stack>
</Paper>
</Box>
<Group justify="center">
<Button
component={Link}
href="/darmasaba/apbdes"
radius="xl"
size="lg"
variant="gradient"
gradient={{ from: "#26667F", to: "#124170" }}
>
Lihat Semua Data
</Button>
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="lg">
{/* Chart */}
<APBDesProgress />
<SimpleGrid mx={{ base: 'md', md: 100 }} cols={{ base: 1, sm: 3 }} spacing="lg" pb={"xl"}>
{loading ? (
<Center mih={200}>
<Loader size="lg" color="blue" />
@@ -185,18 +129,7 @@ function Apbdes() {
)}
</SimpleGrid>
<Group justify="center" pb={"xl"}>
<Button
component={Link}
href="/darmasaba/apbdes"
radius="xl"
size="lg"
variant="gradient"
gradient={{ from: "#26667F", to: "#124170" }}
>
Lihat Semua Data
</Button>
</Group>
</Stack>
)
}

View File

@@ -1,8 +1,7 @@
import { Box, Card, Image, Stack, Text, Tooltip } from '@mantine/core';
import { IconUserCircle } from '@tabler/icons-react';
import React from 'react';
import { Prisma } from '@prisma/client';
import colors from '@/con/colors';
import { Box, Card, Image, Stack, Text } from '@mantine/core';
import { Prisma } from '@prisma/client';
import { IconUserCircle } from '@tabler/icons-react';
interface ProfileViewProps {
data: Prisma.PejabatDesaGetPayload<{ include: { image: true } }> | null;
@@ -95,7 +94,6 @@ export default function ProfileView({ data }: ProfileViewProps) {
backgroundColor: 'rgba(255, 255, 255, 0.95)',
}}
>
<Tooltip label="Jabatan Resmi" withArrow>
<Text
fz={{ base: 'xs', sm: 'sm' }}
c="dimmed"
@@ -103,7 +101,6 @@ export default function ProfileView({ data }: ProfileViewProps) {
>
{data.position || 'Tidak ada jabatan'}
</Text>
</Tooltip>
<Text
c={colors['blue-button']}
fw={700}

View File

@@ -0,0 +1,92 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import colors from '@/con/colors';
import { Center, Loader, Paper, Stack, Text, Title } from '@mantine/core';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
// Ganti ini jika tidak pakai next-auth
async function fetchUser() {
const res = await fetch('/api/auth/me');
if (!res.ok) throw new Error('Unauthorized');
return res.json();
}
export default function WaitingRoom() {
const router = useRouter();
const [user, setUser] = useState<any>(null);
// const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let isMounted = true;
const interval = setInterval(async () => {
try {
const data = await fetchUser();
if (!isMounted) return;
setUser(data.user);
// Jika sudah aktif, redirect ke dashboard admin
if (data.user.isActive) {
clearInterval(interval);
router.push('/admin'); // atau /dashboard
}
} catch (err: any) {
if (!isMounted) return;
setError(err.message || 'Gagal memuat status');
clearInterval(interval);
// Redirect ke login jika unauthorized
if (err.message === 'Unauthorized') {
router.push('/login');
}
}
}, 2000); // Cek setiap 2 detik
// Cleanup
return () => {
isMounted = false;
clearInterval(interval);
};
}, [router]);
if (error) {
return (
<Center h="100vh">
<Paper p="xl" radius="md" bg={colors['white-trans-1']} w={400}>
<Stack align="center" gap="md">
<Title order={3} c="red">Error</Title>
<Text>{error}</Text>
</Stack>
</Paper>
</Center>
);
}
return (
<Center h="100vh" bg={colors.Bg}>
<Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '90%', sm: 400 }}>
<Stack align="center" gap="lg">
<Title order={2} c={colors['blue-button']} ta="center">
Menunggu Persetujuan
</Title>
<Text ta="center" c="dimmed">
Akun Anda sedang dalam proses verifikasi oleh Superadmin.
</Text>
<Text ta="center" size="sm" c="dimmed">
Nomor: {user?.nomor || '...'}
</Text>
<Loader size="sm" color={colors['blue-button']} />
<Text ta="center" size="xs" c="dimmed">
Jangan tutup halaman ini. Anda akan dialihkan otomatis setelah disetujui.
</Text>
</Stack>
</Paper>
</Center>
);
}

17
src/store/authStore.ts Normal file
View File

@@ -0,0 +1,17 @@
import { proxy } from 'valtio';
export type User = {
id: string;
name: string;
roleId: number; // 0, 1, 2, 3
};
export const authStore = proxy<{
user: User | null;
setUser: (user: User | null) => void;
}>({
user: null,
setUser(user) {
authStore.user = user;
},
});