Compare commits

..

10 Commits

Author SHA1 Message Date
fb010bd05a Merge pull request 'Fix Route APBdes' (#12) from nico/18-nov-25 into staging
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/12
2025-11-18 14:30:00 +08:00
cfe60ed8fe Merge pull request 'Fix SDGs Desa Barchart sudah responsive, tabel dan bar progress di menu apbdes sudah sesuai dengan data' (#11) from nico/18-nov-25 into staging
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/11
2025-11-18 11:57:24 +08:00
dc56a329dc Merge pull request 'nico/12-nov-25' (#10) from nico/12-nov-25 into staging
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/10
2025-11-12 17:43:31 +08:00
a9195d30bd Merge pull request 'Fix Text to Speech Menu Landing Page && Add barchart Landing Page APBDes' (#9) from nico/6-nov-25 into staging
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/9
2025-11-06 11:36:00 +08:00
569b0d408b Merge pull request 'nico/5-nov-25' (#8) from nico/5-nov-25 into staging
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/8
2025-11-05 14:33:33 +08:00
40985f961a Merge pull request 'nico/4-nov-25' (#7) from nico/4-nov-25 into staging
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/7
2025-11-04 15:10:07 +08:00
22424ef53e Merge pull request 'Fix QC Keano FrontEnd' (#6) from nico/3-nov-25 into staging
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/6
2025-11-03 17:36:47 +08:00
14b49334ac Merge pull request 'nico/3-nov-25' (#5) from nico/3-nov-25 into staging
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/5
2025-11-03 10:29:31 +08:00
4a6829c502 Merge pull request 'nico/28-okt-25' (#4) from nico/28-okt-25 into staging
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/4
2025-10-28 23:05:26 +08:00
60b035749d Merge pull request 'nico/27-okt-25' (#3) from nico/27-okt-25 into staging
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/3
2025-10-27 22:19:56 +08:00
50 changed files with 810 additions and 4109 deletions

View File

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

View File

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

View File

@@ -183,41 +183,41 @@ model SdgsDesa {
//========================================= APBDes ========================================= //
model APBDes {
id String @id @default(cuid())
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? // opsional, tidak perlu default now()
isActive Boolean @default(true)
id String @id @default(cuid())
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? // 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)
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])
@@ -2164,7 +2164,7 @@ enum StatusPeminjaman {
model User {
id String @id @default(cuid())
username String @unique
username String
nomor String @unique
role Role @relation(fields: [roleId], references: [id])
roleId String @default("1")
@@ -2181,6 +2181,7 @@ 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
@@ -2199,6 +2200,17 @@ 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,13 +54,14 @@ 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 () => {
// =========== ROLE ===========
// =========== USER & ROLE ===========
// In your seed.ts
// =========== ROLES ===========
console.log("🔄 Seeding roles...");
@@ -68,12 +69,35 @@ 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

@@ -7,14 +7,14 @@ import { z } from "zod";
// --- Zod Schema ---
const ApbdesItemSchema = z.object({
kode: z.string().min(1, "Kode wajib diisi"),
uraian: z.string().min(1, "Uraian wajib diisi"),
kode: z.string().min(1),
uraian: z.string().min(1),
anggaran: z.number().min(0),
realisasi: z.number().min(0),
selisih: z.number(),
persentase: z.number(),
persentase: z.number().min(0).max(1000), // allow >100% if overbudget
level: z.number().int().min(1).max(3),
tipe: z.enum(['pendapatan', 'belanja', 'pembiayaan']).nullable().optional(),
tipe: z.string().min(1), // "pendapatan" | "belanja"
});
const ApbdesFormSchema = z.object({
@@ -37,9 +37,6 @@ const defaultApbdesForm = {
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
@@ -53,12 +50,64 @@ function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer
selisih,
persentase,
level: item.level || 1,
tipe: item.tipe, // biarkan null jika memang null
tipe: item.tipe || "pendapatan",
};
}
// --- State Utama ---
const apbdes = proxy({
// create: {
// form: { ...defaultApbdesForm },
// loading: false,
// addItem(item: Partial<z.infer<typeof ApbdesItemSchema>>) {
// const normalized = normalizeItem(item);
// this.form.items.push(normalized);
// },
// removeItem(index: number) {
// this.form.items.splice(index, 1);
// },
// updateItem(index: number, updates: Partial<z.infer<typeof ApbdesItemSchema>>) {
// const current = this.form.items[index];
// if (current) {
// const updated = normalizeItem({ ...current, ...updates });
// this.form.items[index] = updated;
// }
// },
// reset() {
// this.form = { ...defaultApbdesForm };
// },
// async create() {
// 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();
// this.reset();
// } else {
// toast.error(res.data?.message || "Gagal membuat APBDes");
// }
// } catch (error: any) {
// console.error("Create APBDes error:", error);
// toast.error(error?.message || "Terjadi kesalahan saat membuat APBDes");
// } finally {
// this.loading = false;
// }
// },
// },
create: {
form: { ...defaultApbdesForm },
loading: false,
@@ -256,7 +305,7 @@ const apbdes = proxy({
selisih: item.selisih,
persentase: item.persentase,
level: item.level,
tipe: item.tipe || 'pendapatan',
tipe: item.tipe,
})),
};
return data;

View File

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

View File

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

View File

@@ -1,127 +1,113 @@
// app/registrasi/page.tsx
'use client';
import { apiFetchRegister } from '@/app/api/auth/_lib/api_fetch_auth';
/* eslint-disable @typescript-eslint/no-unused-expressions */
'use client'
import { apiFetchRegister } from '@/app/admin/auth/_lib/api_fetch_auth';
import BackButton from '@/app/darmasaba/(pages)/desa/layanan/_com/BackButto';
import colors from '@/con/colors';
import {
Box, Button, Center, Checkbox, Image, Paper, Stack, Text, TextInput, Title,
} from '@mantine/core';
import { Box, Button, Center, Checkbox, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { PhoneInput } from 'react-international-phone';
import 'react-international-phone/style.css';
import { useState } from 'react';
import { PhoneInput } from "react-international-phone";
import "react-international-phone/style.css";
import { toast } from 'react-toastify';
export default function Registrasi() {
const router = useRouter();
const [username, setUsername] = useState('');
function Registrasi() {
const [phone, setPhone] = useState("")
const router = useRouter()
const [value, setValue] = useState("")
const [isValue, setIsValue] = useState(false);
const [loading, setLoading] = useState(false);
const [phone, setPhone] = useState(''); // ✅ tambahkan state untuk phone
// Ambil data dari localStorage (dari login)
useEffect(() => {
const storedNomor = localStorage.getItem('auth_nomor');
if (!storedNomor) {
toast.error('Akses tidak valid');
router.push('/login');
async function onRegistarsi() {
if (value.length < 5) {
toast.error("Username minimal 5 karakter!");
return;
}
setPhone(storedNomor);
}, [router]);
const handleRegister = async () => {
if (!username || username.trim().length < 5) {
toast.error('Username minimal 5 karakter!');
if (value.includes(" ")) {
toast.error("Username tidak boleh ada spasi!");
return;
}
if (username.includes(' ')) {
toast.error('Username tidak boleh ada spasi!');
if (!phone) {
toast.error("Nomor telepon wajib diisi!");
return;
}
const cleanPhone = phone.replace(/\D/g, '');
if (cleanPhone.length < 10) {
toast.error('Nomor tidak valid!');
return;
}
try {
setLoading(true);
// ✅ Hanya kirim username & nomor → dapat kodeId
const response = await apiFetchRegister({ username, nomor: cleanPhone });
const respone = await apiFetchRegister({ nomor: phone, username: value });
if (response.success) {
// Simpan sementara
localStorage.setItem('auth_kodeId', response.kodeId);
localStorage.setItem('auth_username', username); // simpan username
if (respone.success) {
router.push("/login", { scroll: false });
toast.success(respone.message);
toast.success('Kode verifikasi dikirim!');
router.push('/validasi'); // ✅ ke halaman validasi
} else {
setLoading(false);
toast.error(respone.message);
}
} 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']} w={{ base: '100%', sm: 400 }}>
<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']}>
<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 w="100%">
<TextInput
label="Username"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.currentTarget.value)}
error={
username.length > 0 && username.length < 5
? 'Minimal 5 karakter!'
: username.includes(' ')
? 'Tidak boleh ada spasi'
: ''
}
required
/>
<Box>
<TextInput placeholder='Username'
label='Username'
maxLength={50}
<Box pt="md">
<Text fz="sm">Nomor Telepon</Text>
error={
value.length > 0 && value.length < 5
? "Minimal 5 karakter !"
: value.includes(" ")
? "Tidak boleh ada spasi"
: isValue
? "Masukan username anda"
: ""
}
onChange={(val) => {
val.currentTarget.value.length > 0 ? setIsValue(false) : "";
setValue(val.currentTarget.value);
}}
required
/>
<Box py={10}>
<Text fz={"sm"} >Nomor Telepon</Text>
<PhoneInput
countrySelectorStyleProps={{
buttonStyle: {
backgroundColor: colors['blue-button'],
},
}}
inputStyle={{ width: "100%" }}
defaultCountry="id"
value={phone}
disabled
onChange={(val) => {
setPhone(val);
}}
/>
</Box>
<Box pt="md">
<Checkbox label="Saya menyetujui syarat dan ketentuan" defaultChecked />
<Box pb={10}>
<Checkbox
label="Saya menyetujui syarat dan ketentuan yang berlaku"
/>
</Box>
<Box pt="xl">
<Button
fullWidth
bg={colors['blue-button']}
radius="xl"
onClick={handleRegister}
loading={loading}
disabled={username.length < 5}
>
Kirim Kode Verifikasi
</Button>
<Box pb={20} >
<Button fullWidth bg={colors['blue-button']} radius={'xl'} onClick={onRegistarsi} loading={loading ? true : false}>Daftar</Button>
</Box>
</Box>
</Stack>
@@ -130,4 +116,6 @@ export default function Registrasi() {
</Box>
</Stack>
);
}
}
export default Registrasi;

View File

@@ -1,258 +1,31 @@
//
'use client';
import { apiFetchOtpData, apiFetchVerifyOtp } from '@/app/api/auth/_lib/api_fetch_auth';
'use client'
import colors from '@/con/colors';
import { Box, Button, Loader, Paper, PinInput, Stack, Text, Title } from '@mantine/core';
import { Box, Button, 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);
// Inisialisasi data OTP
useEffect(() => {
const storedKodeId = localStorage.getItem('auth_kodeId');
if (!storedKodeId) {
toast.error('Akses tidak valid');
router.replace('/login');
return;
}
setKodeId(storedKodeId);
const loadOtpData = async () => {
try {
const result = await apiFetchOtpData({ kodeId: storedKodeId });
if (result.success && result.data?.nomor) {
setNomor(result.data.nomor);
} else {
throw new Error('Data OTP tidak valid');
}
} catch (error) {
console.error('Gagal memuat data OTP:', error);
toast.error('Kode verifikasi tidak valid');
router.replace('/login');
} finally {
setIsLoading(false);
}
};
loadOtpData();
}, [router]);
// Verifikasi OTP
const handleVerify = async () => {
if (!kodeId || !nomor || otp.length < 4) return;
setLoading(true);
try {
const verifyResult = await apiFetchVerifyOtp({ nomor, otp, kodeId });
if (!verifyResult.success) {
// Registrasi baru?
if (
verifyResult.status === 404 &&
verifyResult.message?.includes('Akun tidak ditemukan')
) {
await handleNewRegistration();
return;
}
// Error lain
toast.error(verifyResult.message || 'Verifikasi gagal');
return;
}
// ✅ Verifikasi sukses → simpan user ke store
const user = verifyResult.user;
console.log('=== DEBUG USER ===');
console.log('Full user object:', user);
if (!user || !user.id) {
toast.error('Data pengguna tidak lengkap');
return;
}
const roleId = Number(user.roleId);
authStore.setUser({
id: user.id,
name: user.name || user.username || 'User',
roleId: roleId,
});
cleanupStorage();
const isUserActive = user.isActive ?? user.is_active ?? true;
// Redirect berdasarkan status approval
if (!isUserActive) {
router.replace('/waiting-room');
return;
}
// ✅ Switch statement lebih clean
let redirectPath: string;
switch (roleId) {
case 0:
case 1:
redirectPath = '/admin/landing-page/profil/program-inovasi';
break;
case 2:
redirectPath = '/admin/kesehatan/posyandu';
break;
case 3:
redirectPath = '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
break;
default:
redirectPath = '/admin';
console.warn('Unknown roleId:', roleId);
}
console.log('Redirecting to:', redirectPath);
router.replace(redirectPath);
} catch (error) {
console.error('Error saat verifikasi:', error);
toast.error('Terjadi kesalahan sistem');
} finally {
setLoading(false);
}
};
// Registrasi baru
const handleNewRegistration = async () => {
const username = localStorage.getItem('auth_username');
if (!username) {
toast.error('Data registrasi tidak ditemukan');
return;
}
try {
const res = await fetch('/api/auth/finalize-registration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor, username, otp, kodeId }),
});
const data = await res.json();
if (data.success) {
// Set user sementara (tanpa roleId, akan diisi saat approve)
authStore.setUser({
id: 'pending',
name: username,
});
cleanupStorage();
router.replace('/waiting-room');
} else {
toast.error(data.message || 'Registrasi gagal');
}
} catch (error) {
console.error('Error registrasi:', error);
toast.error('Gagal menyelesaikan registrasi');
}
};
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');
} else {
toast.error(data.message || 'Gagal mengirim ulang OTP');
}
} catch {
toast.error('Gagal menghubungi server');
}
};
// Loading
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']} w={{ base: '100%', sm: 400 }}>
<Stack align="center" gap="lg">
<Stack align='center' justify='center' h={"100vh"}>
<Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}>
<Stack align='center' gap={"lg"}>
<Box>
<Title ta="center" order={2} fw="bold" c={colors['blue-button']}>
<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 w="100%">
<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 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>
<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
<Box py={20} >
<Button onClick={() => router.push("/admin/landing-page/profile/program-inovasi")}>
Page
</Button>
</Text>
</Box>
</Box>
</Stack>
</Paper>
@@ -260,4 +33,6 @@ export default function Validasi() {
</Box>
</Stack>
);
}
}
export default Validasi;

View File

@@ -116,7 +116,6 @@ function EditAPBDes() {
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;
@@ -128,10 +127,9 @@ function EditAPBDes() {
selisih,
persentase,
level,
tipe: finalTipe, // ✅ Tidak akan undefined
tipe,
});
setNewItem({
kode: '',
uraian: '',
@@ -376,7 +374,6 @@ function EditAPBDes() {
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' })}
@@ -450,13 +447,9 @@ function EditAPBDes() {
</Badge>
</td>
<td>
{item.tipe ? (
<Badge color={item.tipe === 'pendapatan' ? 'teal' : 'red'} size="sm">
{item.tipe}
</Badge>
) : (
'-'
)}
<Badge size="sm" color={item.tipe === 'pendapatan' ? 'teal' : 'red'}>
{item.tipe}
</Badge>
</td>
<td>
<ActionIcon color="red" onClick={() => handleRemoveItem(idx)}>

View File

@@ -123,7 +123,6 @@ function CreateAPBDes() {
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;
@@ -135,7 +134,7 @@ function CreateAPBDes() {
selisih,
persentase,
level,
tipe: finalTipe,
tipe,
});
// Reset form input
@@ -362,9 +361,8 @@ function CreateAPBDes() {
{ value: 'pendapatan', label: 'Pendapatan' },
{ value: 'belanja', label: 'Belanja' },
]}
value={newItem.level === 1 ? null : newItem.tipe}
value={newItem.tipe}
onChange={(val) => setNewItem({ ...newItem, tipe: val as any })}
disabled={newItem.level === 1}
/>
</Group>
<TextInput

View File

@@ -1,8 +1,7 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import colors from '@/con/colors';
import { Box, Button, Group, Loader, Paper, Stack, Text, TextInput, Title } from '@mantine/core';
import { Box, Button, Loader, Group, MultiSelect, 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';
@@ -10,7 +9,6 @@ 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();
@@ -19,37 +17,46 @@ 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) => {
const data = await stateRole.update.load(id);
if (data) {
setFormData({
name: data.name ?? '',
});
setOriginalData({
name: data.name ?? '',
});
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');
}
}, []);
}, [stateRole.update]);
useEffect(() => {
stateRole.findMany.load(); // load permission
if (params?.id) loadRole(params.id as string);
}, [params?.id]);
stateRole.findMany.load(); // Load permissions/options
const id = params?.id as string;
if (id) loadRole(id);
}, [params?.id, loadRole, stateRole.findMany]);
const handleResetForm = () => {
setFormData({
name: originalData.name,
permissions: originalData.permissions,
});
toast.info("Form dikembalikan ke data awal");
};
@@ -59,6 +66,10 @@ function EditRole() {
toast.error('Nama role tidak boleh kosong');
return;
}
if (!formData.permissions.length) {
toast.error('Pilih minimal satu permission');
return;
}
try {
setIsSubmitting(true);
@@ -66,6 +77,7 @@ function EditRole() {
stateRole.update.form = {
...stateRole.update.form,
name: formData.name,
permissions: formData.permissions,
};
await stateRole.update.update();
toast.success('Role berhasil diperbarui!');
@@ -104,7 +116,24 @@ function EditRole() {
label={<Text fw="bold" fz="sm">Nama Role</Text>}
placeholder="Masukkan nama role"
/>
<Group justify="right">
<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">
{/* Tombol Batal */}
<Button
variant="outline"

View File

@@ -5,8 +5,9 @@ import {
Box,
Button,
Group,
Loader,
MultiSelect,
Paper,
Loader,
Stack,
TextInput,
Title
@@ -14,9 +15,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() {
@@ -30,7 +31,8 @@ export default function CreateRole() {
const resetForm = () => {
stateRole.create.form = {
name: ''
name: '',
permissions: [],
};
};
@@ -78,6 +80,28 @@ 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,14 +1,15 @@
'use client'
import colors from '@/con/colors';
import { Box, Button, Center, Group, Pagination, Paper, Select, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title, Tooltip } from '@mantine/core';
import { Box, Button, Center, Group, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title, Tooltip } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconCheck, IconSearch, IconTrash, IconX } from '@tabler/icons-react';
import { IconCheck, IconSearch, IconX } from '@tabler/icons-react';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../_com/header';
import { ModalKonfirmasiHapus } from '../../_com/modalKonfirmasiHapus';
import user from '../../_state/user/user-state';
function User() {
const [search, setSearch] = useState("");
return (
@@ -26,10 +27,9 @@ 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);
const stateUser = useProxy(user.userState)
const [modalHapus, setModalHapus] = useState(false)
const [selectedId, setSelectedId] = useState<string | null>(null)
const {
data,
@@ -41,80 +41,20 @@ function ListUser({ search }: { search: string }) {
const handleDelete = () => {
if (selectedId) {
stateUser.deleteUser.delete(selectedId);
setModalHapus(false);
setSelectedId(null);
stateUser.findMany.load();
stateUser.delete.submit(selectedId)
setModalHapus(false)
setSelectedId(null)
stateUser.findMany.load()
}
};
}
useShallowEffect(() => {
stateRole.findMany.load();
load(page, 10, search);
}, [page, search]);
load(page, 10, search)
}, [page, search])
// ✅ Helper function untuk nama role
const getRoleName = (roleId: string) => {
// Cari dari data role yang sudah diload
const role = stateRole.findMany.data.find((r) => r.id === roleId);
return role?.name || "Unknown Role";
};
// ✅ Handler untuk perubahan role dengan konfirmasi
const handleRoleChange = async (
userId: string,
username: string,
oldRoleId: string,
newRoleId: string
) => {
// Skip jika sama
if (oldRoleId === newRoleId) {
return true;
}
// ✅ Konfirmasi perubahan role
const confirmed = window.confirm(
`⚠️ PERINGATAN\n\n` +
`Mengubah role untuk "${username}" akan:\n` +
`• Logout user otomatis dari semua device\n` +
`• Mengubah akses menu sesuai role baru\n\n` +
`Role: ${getRoleName(oldRoleId)}${getRoleName(newRoleId)}\n\n` +
`Lanjutkan?`
);
if (!confirmed) {
// Reload data untuk reset dropdown ke nilai lama
stateUser.findMany.load(page, 10, search);
return false;
}
// ✅ Submit update
const success = await stateUser.update.submit({
id: userId,
roleId: newRoleId,
});
if (success) {
// Reload data setelah berhasil update
stateUser.findMany.load(page, 10, search);
}
return success;
};
// ✅ Handler untuk toggle isActive
const handleToggleActive = async (userId: string, currentStatus: boolean) => {
const success = await stateUser.update.submit({
id: userId,
isActive: !currentStatus,
});
if (success) {
stateUser.findMany.load(page, 10, search);
}
};
const filteredData = data || [];
const filteredData = data || []
if (loading || !data) {
return (
@@ -138,49 +78,25 @@ function ListUser({ search }: { search: string }) {
<TableTh style={{ width: '20%' }}>Nomor</TableTh>
<TableTh style={{ width: '20%' }}>Role</TableTh>
<TableTh style={{ width: '15%' }}>Aktif / Nonaktif</TableTh>
<TableTh style={{ width: '15%' }}>Hapus</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd style={{ width: '25%' }}>
<Text fw={500} truncate="end" lineClamp={1}>
{item.username}
</Text>
<TableTd style={{ width: '25%', }}>
<Text fw={500} truncate="end" lineClamp={1}>{item.username}</Text>
</TableTd>
<TableTd style={{ width: '20%' }}>
<TableTd style={{ width: '20%', }}>
<Text truncate fz="sm" c="dimmed">
{item.nomor}
</Text>
</TableTd>
<TableTd style={{ width: '20%' }}>
<Select
placeholder="Pilih role"
data={stateRole.findMany.data.map((r) => ({
label: r.name,
value: r.id,
}))}
value={item.roleId}
onChange={(val) => {
if (!val) return;
// ✅ Panggil handleRoleChange dengan konfirmasi
handleRoleChange(
item.id,
item.username,
item.roleId,
val
);
}}
searchable
clearable={false}
nothingFoundMessage="Role tidak ditemukan"
disabled={stateUser.update.loading}
/>
<TableTd style={{ width: '20%', }}>
<Text truncate fz="sm" c="dimmed">
{item.role.name}
</Text>
</TableTd>
<TableTd style={{ width: '15%' }}>
<Tooltip
label={item.isActive ? "Nonaktifkan user" : "Aktifkan user"}
@@ -189,34 +105,22 @@ function ListUser({ search }: { search: string }) {
<Button
variant="light"
color={item.isActive ? "green" : "red"}
onClick={() => handleToggleActive(item.id, item.isActive)}
disabled={stateUser.update.loading}
onClick={async () => {
await stateUser.updateActive.submit(item.id, !item.isActive)
stateUser.findMany.load(page, 10, search)
}}
>
{item.isActive ? <IconCheck size={20} /> : <IconX size={20} />}
</Button>
</Tooltip>
</TableTd>
<TableTd style={{ width: '15%' }}>
<Button
variant="light"
color='red'
disabled={stateUser.deleteUser.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
>
<IconTrash size={20} />
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={5}>
<TableTd colSpan={4}>
<Center py={20}>
<Text c="dimmed">Tidak ada data user yang cocok</Text>
<Text color="dimmed">Tidak ada data user yang cocok</Text>
</Center>
</TableTd>
</TableTr>
@@ -225,7 +129,6 @@ function ListUser({ search }: { search: string }) {
</Table>
</Box>
</Paper>
<Center>
<Pagination
value={page}
@@ -240,7 +143,6 @@ function ListUser({ search }: { search: string }) {
radius="md"
/>
</Center>
{/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus
opened={modalHapus}
@@ -252,4 +154,4 @@ function ListUser({ search }: { search: string }) {
);
}
export default User;
export default User;

View File

@@ -84,6 +84,7 @@ export const navBar = [
]
},
{
id: "Desa",
name: "Desa",
@@ -335,415 +336,7 @@ 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

@@ -1,9 +0,0 @@
// 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

@@ -0,0 +1,41 @@
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,267 +1,6 @@
// /* eslint-disable react-hooks/exhaustive-deps */
// '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,
@@ -269,11 +8,9 @@ import {
AppShellMain,
AppShellNavbar,
Burger,
Center,
Flex,
Group,
Image,
Loader,
NavLink,
ScrollArea,
Text,
@@ -289,165 +26,14 @@ import {
import _ from "lodash";
import Link from "next/link";
import { useRouter, useSelectedLayoutSegments } from "next/navigation";
import { useEffect, useState } from "react";
import { navigationByRole } from "./_com/navigationByRole";
import { useSnapshot } from "valtio";
import { toast } from "react-toastify";
import { navBar } from "./_com/list_PageAdmin";
export default function Layout({ children }: { children: React.ReactNode }) {
const [opened, { toggle }] = useDisclosure();
const [loading, setLoading] = useState(true);
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
const router = useRouter();
const segments = useSelectedLayoutSegments().map((s) => _.lowerCase(s));
const { user } = useSnapshot(authStore);
console.log("Current user in store:", user);
// ✅ Fetch user dari backend jika belum ada di store
useEffect(() => {
if (user) {
setLoading(false);
return;
} // Sudah ada → jangan fetch
const fetchUser = async () => {
try {
const res = await fetch('/api/auth/me');
const data = await res.json();
if (data.user) {
authStore.setUser({
id: data.user.id,
name: data.user.name,
roleId: Number(data.user.roleId),
});
} else {
authStore.setUser(null);
router.replace('/login');
}
} catch (error) {
console.error('Gagal memuat data pengguna:', error);
authStore.setUser(null);
router.replace('/login');
} finally {
setLoading(false);
}
};
fetchUser();
}, [user, router]); // ✅ Sekarang 'user' terdefinisi
// ✅ Polling untuk cek perubahan role setiap 30 detik
// Di layout.tsx - useEffect polling
useEffect(() => {
if (!user?.id) return;
const checkRoleUpdate = async () => {
try {
const res = await fetch('/api/auth/me');
const data = await res.json();
// ✅ Session tidak valid (sudah dihapus karena role berubah)
if (!data.success || !data.user) {
console.log('⚠️ Session tidak valid, logout...');
authStore.setUser(null);
// Clear cookie manual (backup)
document.cookie = `${process.env.NEXT_PUBLIC_SESSION_KEY}=; Max-Age=0; path=/;`;
toast.info('Role Anda telah diubah. Silakan login kembali.', {
autoClose: 5000,
});
router.push('/login');
return;
}
// Cek perubahan roleId (seharusnya tidak sampai sini jika session dihapus)
const currentRoleId = Number(data.user.roleId);
if (currentRoleId !== user.roleId) {
console.log('🔄 Role berubah! Dari', user.roleId, 'ke', currentRoleId);
// Update store
authStore.setUser({
id: data.user.id,
name: data.user.name || data.user.username,
roleId: currentRoleId,
});
// Redirect ke halaman default role baru
let redirectPath = '/admin';
switch (currentRoleId) {
case 0:
case 1:
redirectPath = '/admin/landing-page/profil/program-inovasi';
break;
case 2:
redirectPath = '/admin/kesehatan/posyandu';
break;
case 3:
redirectPath = '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
break;
}
toast.info('Role Anda telah diubah. Mengalihkan halaman...', {
autoClose: 5000,
});
router.push(redirectPath);
// Reload untuk clear semua state
setTimeout(() => {
window.location.reload();
}, 500);
}
} catch (error) {
console.error('❌ Error checking role update:', error);
}
};
// ✅ Polling setiap 10 detik (lebih responsif dari 30 detik)
const interval = setInterval(checkRoleUpdate, 10000);
// Juga cek saat window focus (user kembali ke tab)
const handleFocus = () => {
checkRoleUpdate();
};
window.addEventListener('focus', handleFocus);
return () => {
clearInterval(interval);
window.removeEventListener('focus', handleFocus);
};
}, [user, router]);
if (loading) {
return (
<AppShell>
<AppShellMain>
<Center h="100vh">
<Loader />
</Center>
</AppShellMain>
</AppShell>
);
}
// ✅ Ambil menu berdasarkan roleId
const currentNav = user?.roleId !== undefined
? (navigationByRole[user.roleId as keyof typeof navigationByRole] || [])
: [];
const handleLogout = () => {
authStore.setUser(null);
document.cookie = `${process.env.BASE_SESSION_KEY}=; Max-Age=0; path=/;`;
router.push('/login');
};
return (
<AppShell
suppressHydrationWarning
@@ -529,13 +115,13 @@ useEffect(() => {
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"
<Image
src="/assets/images/darmasaba-icon.png"
alt="Logo Darmasaba"
w={20}
h={20}
radius="md"
loading="lazy"
style={{
minWidth: '20px',
height: 'auto',
@@ -545,7 +131,9 @@ useEffect(() => {
</Tooltip>
<Tooltip label="Keluar" position="bottom" withArrow>
<ActionIcon
onClick={handleLogout}
onClick={() => {
router.push("/darmasaba");
}}
color={colors["blue-button"]}
radius="xl"
size="lg"
@@ -568,7 +156,7 @@ useEffect(() => {
p={{ base: 'xs', sm: 'sm' }}
>
<AppShell.Section p="sm">
{currentNav.map((v, k) => {
{navBar.map((v, k) => {
const isParentActive = segments.includes(_.lowerCase(v.name));
return (
@@ -667,4 +255,3 @@ useEffect(() => {
</AppShell>
);
}

View File

@@ -1,4 +1,5 @@
import prisma from "@/lib/prisma";
import { NextResponse } from "next/server";
import { randomOTP } from "../_lib/randomOTP";
@@ -11,70 +12,52 @@ export async function POST(req: Request) {
}
try {
const codeOtp = randomOTP();
const body = await req.json();
const { nomor } = body;
if (!nomor || typeof nomor !== "string") {
return NextResponse.json(
{ success: false, message: "Nomor tidak valid" },
{ status: 400 }
);
}
// Cek apakah user sudah terdaftar
const existingUser = await prisma.user.findUnique({
where: { nomor },
select: { id: true }, // cukup cek ada/tidak
});
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 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 waRes.json();
const sendWa = await res.json();
if (sendWa.status !== "success") {
if (sendWa.status !== "success")
return NextResponse.json(
{ success: false, message: "Nomor WhatsApp tidak aktif" },
{ success: false, message: "Nomor Whatsapp Tidak Aktif" },
{ status: 400 }
);
}
// Simpan OTP ke database
const otpRecord = await prisma.kodeOtp.create({
const createOtpId = await prisma.kodeOtp.create({
data: {
nomor: nomor,
otp: codeOtp, // Pastikan tipe ini number (Int di Prisma = number di TS)
otp: codeOtp,
},
});
if (!createOtpId)
return NextResponse.json(
{ success: false, message: "Gagal mengirim kode OTP" },
{ status: 400 }
);
return NextResponse.json(
{
success: true,
message: "Kode verifikasi terkirim",
kodeId: otpRecord.id,
isRegistered, // 🔑 Ini kunci untuk frontend tahu harus ke register atau verifikasi
kodeId: createOtpId.id,
},
{ status: 200 }
);
} catch (error) {
console.error("Error Login:", error);
console.log("Error Login", error);
return NextResponse.json(
{
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,
},
{ success: false, message: "Terjadi masalah saat login" , reason: error as Error },
{ status: 500 }
);
} finally {
await prisma.$disconnect();
}
}
}

View File

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

@@ -1,104 +0,0 @@
// 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

@@ -12,7 +12,7 @@ type APBDesItemInput = {
selisih: number;
persentase: number;
level: number;
tipe?: string | null;
tipe: string;
};
type FormCreate = {
@@ -66,8 +66,8 @@ export default async function apbdesCreate(context: Context) {
selisih: item.selisih,
persentase: item.persentase,
level: item.level,
tipe: item.tipe, // ✅ sertakan, biar null
apbdesId: apbdes.id,
// Note: tipe field is not included as it doesn't exist in the model
};
return prisma.aPBDesItem.create({

View File

@@ -15,7 +15,7 @@ const ApbdesItemSchema = t.Object({
selisih: t.Number(),
persentase: t.Number(),
level: t.Number(),
tipe: t.Optional(t.Union([t.String(), t.Null()])) // misal: "pendapatan" atau "belanja"
tipe: t.String(), // misal: "pendapatan" atau "belanja"
});
const APBDes = new Elysia({

View File

@@ -10,7 +10,7 @@ type APBDesItemInput = {
selisih: number;
persentase: number;
level: number;
tipe?: string | null;
tipe: string;
};
type FormUpdateBody = {
@@ -54,7 +54,7 @@ export default async function apbdesUpdate(context: Context) {
selisih: item.anggaran - item.realisasi,
persentase: item.anggaran > 0 ? (item.realisasi / item.anggaran) * 100 : 0,
level: item.level,
tipe: item.tipe || null,
tipe: item.tipe,
isActive: true,
})),
});

View File

@@ -1,38 +0,0 @@
// /api/user/delete.ts
import prisma from '@/lib/prisma';
import { Context } from 'elysia';
export default async function userDelete(context: Context) {
const { id } = context.params as { id: string };
try {
// Cek user dulu
const existingUser = await prisma.user.findUnique({
where: { id },
});
if (!existingUser) {
return {
success: false,
message: 'User tidak ditemukan',
};
}
// Hard delete (hapus permanen)
const deletedUser = await prisma.user.delete({
where: { id },
});
return {
success: true,
message: 'User berhasil dihapus permanen',
data: deletedUser,
};
} catch (error) {
console.error('Error delete user:', error);
return {
success: false,
message: 'Terjadi kesalahan saat menghapus user',
};
}
}

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ const Role = new Elysia({
.post("/create", roleCreate, {
body: t.Object({
name: t.String(),
permissions: t.Array(t.String()),
}),
})
@@ -26,6 +27,7 @@ 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,6 +3,7 @@ import { Context } from "elysia";
type FormUpdate = {
name: string;
permissions: string[];
}
export default async function roleUpdate(context: Context) {
@@ -14,6 +15,7 @@ export default async function roleUpdate(context: Context) {
where: { id },
data: {
name: body.name,
permissions: body.permissions,
},
});
return {

View File

@@ -4,11 +4,7 @@ import { Context } from "elysia";
export default async function userUpdate(context: Context) {
try {
const { id, isActive, roleId } = await context.body as {
id: string,
isActive?: boolean,
roleId?: string
};
const { id, isActive } = await context.body as { id: string, isActive: boolean };
if (!id) {
return {
@@ -17,100 +13,28 @@ 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",
};
}
}
// ✅ CEK: Apakah roleId berubah?
let isRoleChanged = false;
let oldRoleId: string | null = null;
if (roleId) {
const currentUser = await prisma.user.findUnique({
where: { id },
select: {
roleId: true,
username: true,
}
});
if (currentUser && currentUser.roleId !== roleId) {
isRoleChanged = true;
oldRoleId = currentUser.roleId;
console.log(`🔄 Role berubah untuk ${currentUser.username}: ${oldRoleId}${roleId}`);
}
}
// Update user
const updatedUser = await prisma.user.update({
where: { id },
data: {
...(isActive !== undefined && { isActive }),
...(roleId && { roleId }),
},
data: { isActive },
select: {
id: true,
username: true,
nomor: true,
isActive: true,
roleId: true,
updatedAt: true,
role: {
select: {
id: true,
name: true,
}
}
}
});
// ✅ FORCE LOGOUT: Hapus UserSession jika role berubah
if (isRoleChanged) {
try {
const deletedSessions = await prisma.userSession.deleteMany({
where: { userId: id }
});
console.log(`🔒 Force logout user ${updatedUser.username} (${id})`);
console.log(` Deleted ${deletedSessions.count} session(s)`);
console.log(` Role: ${oldRoleId}${roleId}`);
} catch (sessionError: any) {
// Jika UserSession tidak ditemukan (user belum pernah login), skip error
if (sessionError.code !== 'P2025') {
console.error("⚠️ Error menghapus session:", sessionError);
} else {
console.log(` User ${updatedUser.username} belum pernah login`);
}
}
}
// ✅ Response dengan info tambahan
return {
success: true,
message: isRoleChanged
? `User berhasil diupdate. ${updatedUser.username} akan logout otomatis.`
: "User berhasil diupdate",
message: `User berhasil ${isActive ? "diaktifkan" : "dinonaktifkan"}`,
data: updatedUser,
roleChanged: isRoleChanged, // Info untuk frontend
oldRoleId: oldRoleId,
newRoleId: roleId,
};
} catch (e: any) {
console.error("Error update user:", e);
console.error("Error update user:", e);
return {
success: false,
message: "Gagal mengupdate user: " + (e.message || "Unknown error"),
message: "Gagal mengupdate status user",
};
}
}
}

View File

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

View File

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

View File

@@ -1,91 +1,36 @@
// app/api/auth/_lib/session_create.ts
/* eslint-disable @typescript-eslint/no-explicit-any */
import { cookies } from "next/headers";
import { encrypt } from "./encrypt";
import prisma from "@/lib/prisma";
export async function sessionCreate({
sessionKey,
exp = "30 day",
jwtSecret,
exp = "7 year",
encodedKey,
user,
}: {
sessionKey: string;
exp?: string;
jwtSecret: string;
encodedKey: 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,
jwtSecret,
encodedKey,
user,
});
if (token === null) {
throw new Error("Token generation failed");
}
// ✅ HYBRID: Simpan token ke database UserSession
const userId = user.id as string;
if (userId) {
try {
// Hapus session lama user ini (logout device lain)
await prisma.userSession.deleteMany({
where: { userId },
});
// Parse expiration
const expiresDate = new Date();
const expMatch = exp.match(/(\d+)\s*(day|year)/);
if (expMatch) {
const [, num, unit] = expMatch;
const amount = parseInt(num);
if (unit === 'year') {
expiresDate.setFullYear(expiresDate.getFullYear() + amount);
} else if (unit === 'day') {
expiresDate.setDate(expiresDate.getDate() + amount);
}
} else {
// Default 30 hari
expiresDate.setDate(expiresDate.getDate() + 30);
}
// Buat session baru di database
await prisma.userSession.create({
data: {
userId,
token, // JWT token disimpan
expires: expiresDate,
active: true,
},
});
console.log(`✅ Session created for user ${userId}`);
} catch (dbError) {
console.error("⚠️ Error menyimpan session ke database:", dbError);
// Tetap lanjut meski gagal simpan ke DB (fallback ke JWT only)
}
}
// Set cookie
const cookieStore = await cookies();
cookieStore.set(sessionKey, token, {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: process.env.NODE_ENV === "production",
maxAge: 30 * 24 * 60 * 60, // 30 hari dalam detik
});
const cookie: any = {
key: sessionKey,
value: token,
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
},
};
(await cookies()).set(cookie.key, cookie.value, { ...cookie.options });
return token;
}
}
// wibu:0.2.82

View File

@@ -1,42 +0,0 @@
// app/api/auth/_lib/session_delete.ts
import { cookies } from "next/headers";
import prisma from "@/lib/prisma";
/**
* Hapus session dari database dan cookie
*/
export async function sessionDelete({
sessionKey,
userId,
}: {
sessionKey: string;
userId?: string;
}): Promise<boolean> {
try {
const cookieStore = await cookies();
const token = cookieStore.get(sessionKey)?.value;
// Hapus dari database
if (token) {
const deleted = await prisma.userSession.deleteMany({
where: { token },
});
console.log(`🗑️ Deleted ${deleted.count} session(s) by token`);
} else if (userId) {
// Fallback: hapus berdasarkan userId
const deleted = await prisma.userSession.deleteMany({
where: { userId },
});
console.log(`🗑️ Deleted ${deleted.count} session(s) for user ${userId}`);
}
// Hapus cookie
cookieStore.delete(sessionKey);
console.log('✅ Session deleted successfully');
return true;
} catch (error) {
console.error("❌ Error deleting session:", error);
return false;
}
}

View File

@@ -1,90 +0,0 @@
// app/api/auth/_lib/session_verify.ts
import { cookies } from 'next/headers';
import { decrypt } from './decrypt';
import prisma from '@/lib/prisma';
/**
* Verifikasi session hybrid:
* 1. Decrypt JWT token
* 2. Cek apakah token masih ada di database (untuk force logout)
* 3. Return data user terbaru dari database
*/
export async function verifySession(): Promise<Record<string, unknown> | null> {
try {
const sessionKey = process.env.BASE_SESSION_KEY;
if (!sessionKey) {
throw new Error('BASE_SESSION_KEY tidak ditemukan di environment');
}
const jwtSecret = process.env.BASE_TOKEN_KEY;
if (!jwtSecret) {
throw new Error('BASE_TOKEN_KEY tidak ditemukan di environment');
}
const cookieStore = await cookies();
const token = cookieStore.get(sessionKey)?.value;
if (!token) {
return null;
}
// Step 1: Decrypt JWT
const jwtUser = await decrypt({ token, jwtSecret });
if (!jwtUser || !jwtUser.id) {
console.log('⚠️ JWT decrypt failed atau tidak ada user ID');
return null;
}
// Step 2: Cek database UserSession (untuk force logout)
try {
const dbSession = await prisma.userSession.findFirst({
where: {
userId: jwtUser.id as string,
token: token,
active: true,
OR: [
{ expires: null },
{ expires: { gte: new Date() } },
],
},
include: {
User: {
select: {
id: true,
username: true,
nomor: true,
roleId: true,
isActive: true,
},
},
},
});
// Token tidak ditemukan di database = sudah dihapus (force logout)
if (!dbSession) {
console.log('⚠️ Token valid tapi sudah dihapus dari database (force logout)');
return null;
}
// Step 3: Return data user terbaru dari database
// Ini penting agar roleId selalu update
return {
id: dbSession.User.id,
username: dbSession.User.username,
nomor: dbSession.User.nomor,
roleId: dbSession.User.roleId,
isActive: dbSession.User.isActive,
};
} catch (dbError) {
console.error("⚠️ Error cek database session:", dbError);
// Fallback: jika database error, tetap pakai JWT
return jwtUser;
}
} catch (error) {
console.warn('❌ Session verification failed:', error);
return null;
}
}

View File

@@ -1,100 +0,0 @@
// app/api/auth/finalize-registration/route.ts
import prisma from "@/lib/prisma";
import { NextResponse } from "next/server";
import { sessionCreate } from "../_lib/session_create";
export async function POST(req: Request) {
try {
const { nomor, username, kodeId, roleId } = await req.json();
// Validasi input
if (!nomor || !username || !kodeId) {
return NextResponse.json(
{ success: false, message: "Data tidak lengkap" },
{ status: 400 }
);
}
// Verifikasi 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 }
);
}
// Cek apakah username sudah dipakai
const existingUser = await prisma.user.findUnique({
where: { username },
});
if (existingUser) {
return NextResponse.json(
{ success: false, message: "Username sudah digunakan" },
{ status: 400 }
);
}
// Buat user baru
const newUser = await prisma.user.create({
data: {
username,
nomor,
roleId: roleId || "1", // Default role
isActive: false, // Menunggu approval
},
});
// Nonaktifkan OTP
await prisma.kodeOtp.update({
where: { id: kodeId },
data: { isActive: false },
});
// ✅ CREATE SESSION (JWT + Database)
try {
await sessionCreate({
sessionKey: process.env.BASE_SESSION_KEY!,
jwtSecret: process.env.BASE_TOKEN_KEY!,
exp: "30 day",
user: {
id: newUser.id,
nomor: newUser.nomor,
username: newUser.username,
roleId: newUser.roleId,
isActive: false, // User baru belum aktif
},
});
} catch (sessionError) {
console.error("❌ Error creating session:", sessionError);
return NextResponse.json(
{ success: false, message: "Gagal membuat session" },
{ status: 500 }
);
}
return NextResponse.json({
success: true,
message: "Registrasi berhasil. Menunggu persetujuan admin.",
user: {
id: newUser.id,
name: newUser.username,
roleId: newUser.roleId,
isActive: false,
},
});
} 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,66 +12,52 @@ export async function POST(req: Request) {
}
try {
const { nomor } = await req.json();
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}.
`
);
if (!nomor || typeof nomor !== "string") {
const sendWa = await res.json();
if (sendWa.status !== "success")
return NextResponse.json(
{ success: false, message: "Nomor tidak valid" },
{ success: false, message: "Nomor Whatsapp Tidak Aktif" },
{ status: 400 }
);
}
const existingUser = await prisma.user.findUnique({
where: { nomor },
select: { id: true, isActive: true },
const createOtpId = await prisma.kodeOtp.create({
data: {
nomor: nomor,
otp: codeOtp,
},
});
const isRegistered = !!existingUser;
if (!createOtpId)
return NextResponse.json(
{ success: false, message: "Gagal mengirim kode OTP" },
{ status: 400 }
);
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 dikirim",
kodeId: createOtpId.id,
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.error("Error Login:", error);
return NextResponse.json(
{ success: false, message: "Terjadi kesalahan saat login" },
{
success: true,
message: "Kode verifikasi terkirim",
kodeId: createOtpId.id,
},
{ status: 200 }
);
} catch (error) {
console.log("Error Login", error);
return NextResponse.json(
{ success: false, message: "Terjadi masalah saat login" , reason: error as Error },
{ status: 500 }
);
} finally {
await prisma.$disconnect();
}
}
}

View File

@@ -1,35 +0,0 @@
// app/api/auth/logout/route.ts
import { NextResponse } from "next/server";
import { sessionDelete } from "../_lib/session_delete";
export async function POST() {
try {
const deleted = await sessionDelete({
sessionKey: process.env.BASE_SESSION_KEY!,
});
if (deleted) {
return NextResponse.json({
success: true,
message: "Logout berhasil",
});
} else {
return NextResponse.json(
{
success: false,
message: "Gagal logout",
},
{ status: 500 }
);
}
} catch (error) {
console.error("❌ Logout Error:", error);
return NextResponse.json(
{
success: false,
message: "Terjadi kesalahan saat logout",
},
{ status: 500 }
);
}
}

View File

@@ -1,45 +0,0 @@
// app/api/auth/me/route.ts
import { NextResponse } from 'next/server';
import { verifySession } from '../_lib/session_verify';
export async function GET() {
try {
// ✅ Verify session (hybrid: JWT + Database)
const user = await verifySession();
if (!user) {
return NextResponse.json(
{
success: false,
message: "Session tidak valid",
user: null
},
{ status: 401 }
);
}
// Data user sudah fresh dari database (via verifySession)
return NextResponse.json({
success: true,
user: {
id: user.id,
name: user.username,
username: user.username,
nomor: user.nomor,
roleId: user.roleId,
isActive: user.isActive,
},
});
} catch (error) {
console.error("❌ Error in /api/auth/me:", error);
return NextResponse.json(
{
success: false,
message: "Terjadi kesalahan",
user: null
},
{ status: 500 }
);
}
}

View File

@@ -1,41 +0,0 @@
// 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,51 +1,62 @@
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import { randomOTP } from '../_lib/randomOTP'; // pastikan ada
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 } = await req.json();
const { data } = 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 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 }
const cekUsername = await prisma.user.findUnique({
where: {
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 (cekUsername)
return NextResponse.json({
success: false,
message: "Username sudah digunakan",
});
const createUser = await prisma.user.create({
data: {
username: data.username,
nomor: data.nomor,
},
});
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('Register OTP Error:', error);
return NextResponse.json({ success: false, message: 'Gagal mengirim OTP' }, { status: 500 });
console.error("Error registrasi:", error);
return NextResponse.json(
{
success: false,
message: "Maaf, Terjadi Keselahan",
reason: (error as Error).message,
},
{ status: 500 }
);
} finally {
await prisma.$disconnect();
}
}
}

View File

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

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

@@ -1,78 +0,0 @@
/* 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

@@ -1,126 +0,0 @@
// 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) {
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 }
);
}
// Validasi OTP
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 }
);
}
// ✅ CREATE SESSION (JWT + Database)
try {
await sessionCreate({
sessionKey: process.env.BASE_SESSION_KEY!,
jwtSecret: process.env.BASE_TOKEN_KEY!,
exp: "30 day",
user: {
id: user.id,
nomor: user.nomor,
username: user.username,
roleId: user.roleId,
isActive: user.isActive,
},
});
} catch (sessionError) {
console.error("❌ Error creating session:", sessionError);
return NextResponse.json(
{ success: false, message: "Gagal membuat session" },
{ status: 500 }
);
}
// Nonaktifkan OTP
await prisma.kodeOtp.update({
where: { id: kodeId },
data: { isActive: false },
});
// Update lastLogin
await prisma.user.update({
where: { id: user.id },
data: { lastLogin: new Date() },
});
return NextResponse.json({
success: true,
message: user.isActive ? "Berhasil login" : "Menunggu persetujuan",
user: {
id: user.id,
name: user.username,
roleId: user.roleId,
isActive: user.isActive,
},
});
} 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,15 +1,25 @@
/* 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 { ActionIcon, BackgroundImage, Box, Button, Center, Flex, Group, Loader, SimpleGrid, Stack, Text } from '@mantine/core'
import { BarChart } from '@mantine/charts'
import { ActionIcon, BackgroundImage, Box, Button, Center, Flex, Group, Loader, Paper, SimpleGrid, Stack, Text, Title } 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)
@@ -18,9 +28,22 @@ 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) {
@@ -47,23 +70,56 @@ function Apbdes() {
</Stack>
</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>
{/* Chart */}
<APBDesProgress />
<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>
<SimpleGrid mx={{ base: 'md', md: 100 }} cols={{ base: 1, sm: 3 }} spacing="lg" pb={"xl"}>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="lg">
{loading ? (
<Center mih={200}>
<Loader size="lg" color="blue" />
@@ -129,7 +185,18 @@ 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,97 +0,0 @@
/* 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;
const currentUser = data.user;
setUser(currentUser);
// ✅ Sekarang isActive tersedia!
if (currentUser?.isActive) {
clearInterval(interval);
// Redirect ke halaman admin sesuai role
if (currentUser.roleId === 0) {
router.push('/admin/landing-page/profil/program-inovasi');
} else {
router.push('/admin'); // atau halaman default role
}
}
} catch (err: any) {
if (!isMounted) return;
setError(err.message || 'Gagal memuat status');
clearInterval(interval);
if (err.message === 'Unauthorized') {
router.push('/login');
}
}
}, 2000);
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>
);
}

View File

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