Compare commits
18 Commits
nico/25-no
...
nico/1-des
| Author | SHA1 | Date | |
|---|---|---|---|
| dcf195f54f | |||
| c03a6b3aed | |||
| 1bb9f239db | |||
| a213ff7d37 | |||
| 0018bdc251 | |||
| 83fb39a957 | |||
| 7238692dd0 | |||
| 8b50139d79 | |||
| 066180fc0e | |||
| 67f29aabef | |||
| dbf7c34228 | |||
| 036fc86fed | |||
| 2cecec733e | |||
| c64a2e5457 | |||
| 757911d7dd | |||
| 54232e4465 | |||
| 29a9a59bca | |||
| 2fb3666e57 |
10
prisma/data/user/users.json
Normal file
10
prisma/data/user/users.json
Normal file
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"id": "cmie1o0zh0002vn132vtzg7hh",
|
||||
"username": "SuperAdmin-Nico",
|
||||
"nomor": "6289647037426",
|
||||
"roleId": 0,
|
||||
"isActive": true,
|
||||
"sessionInvalid": false
|
||||
}
|
||||
]
|
||||
@@ -2163,20 +2163,20 @@ enum StatusPeminjaman {
|
||||
// ========================================= USER ========================================= //
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
username String
|
||||
nomor String @unique
|
||||
roleId String @default("2")
|
||||
isActive Boolean @default(false)
|
||||
sessionInvalid Boolean @default(false)
|
||||
nomor String @unique
|
||||
roleId String @default("2")
|
||||
isActive Boolean @default(false)
|
||||
sessionInvalid Boolean @default(false)
|
||||
lastLogin DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
sessions UserSession[] // ✅ Relasi one-to-many
|
||||
role Role @relation(fields: [roleId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
permissions Json?
|
||||
sessions UserSession[] // ✅ Relasi one-to-many
|
||||
role Role @relation(fields: [roleId], references: [id])
|
||||
menuAccesses UserMenuAccess[]
|
||||
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
@@ -2184,6 +2184,7 @@ model Role {
|
||||
id String @id @default(cuid())
|
||||
name String @unique // ADMIN_DESA, ADMIN_KESEHATAN, ADMIN_SEKOLAH
|
||||
description String?
|
||||
permissions Json?
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -2203,18 +2204,18 @@ model KodeOtp {
|
||||
}
|
||||
|
||||
model UserSession {
|
||||
id String @id @default(cuid())
|
||||
token String @db.Text // ✅ JWT bisa panjang
|
||||
expiresAt DateTime // ✅ Ubah jadi expiresAt (konsisten)
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId String // ✅ HAPUS @unique - user bisa punya multiple sessions
|
||||
|
||||
@@index([userId]) // ✅ Index untuk query cepat
|
||||
@@index([token]) // ✅ Index untuk verify cepat
|
||||
id String @id @default(cuid())
|
||||
token String @db.Text // ✅ JWT bisa panjang
|
||||
expiresAt DateTime // ✅ Ubah jadi expiresAt (konsisten)
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId String // ✅ HAPUS @unique - user bisa punya multiple sessions
|
||||
|
||||
@@index([userId]) // ✅ Index untuk query cepat
|
||||
@@index([token]) // ✅ Index untuk verify cepat
|
||||
@@map("user_sessions")
|
||||
}
|
||||
|
||||
|
||||
168
prisma/seed.ts
168
prisma/seed.ts
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import prisma from "@/lib/prisma";
|
||||
import profilePejabatDesa from "./data/landing-page/profile/profile.json";
|
||||
@@ -57,46 +58,107 @@ import roles from "./data/user/roles.json";
|
||||
import fileStorage from "./data/file-storage.json";
|
||||
import jenjangPendidikan from "./data/pendidikan/info-sekolah/jenjang-pendidikan.json";
|
||||
import seedAssets from "./seed_assets";
|
||||
import users from "./data/user/users.json";
|
||||
import { safeSeedUnique } from "./safeseedUnique";
|
||||
|
||||
(async () => {
|
||||
// =========== ROLE ===========
|
||||
// In your seed.ts
|
||||
// =========== ROLES ===========
|
||||
console.log("🔄 Seeding roles...");
|
||||
for (const r of roles) {
|
||||
await safeSeedUnique("role", { id: r.id }, {
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
isActive: r.isActive,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("✅ Roles seeded");
|
||||
for (const r of roles) {
|
||||
try {
|
||||
// ✅ Destructure to remove permissions if exists
|
||||
const { permissions, ...roleData } = r as any;
|
||||
|
||||
await safeSeedUnique(
|
||||
"role",
|
||||
{ name: roleData.name },
|
||||
{
|
||||
id: roleData.id,
|
||||
name: roleData.name,
|
||||
description: roleData.description,
|
||||
permissions: roleData.permissions || {}, // ✅ Include permissions
|
||||
isActive: roleData.isActive,
|
||||
}
|
||||
);
|
||||
console.log(`✅ Seeded role -> ${roleData.name}`);
|
||||
} catch (error: any) {
|
||||
if (error.code === "P2002") {
|
||||
console.warn(`⚠️ Role already exists (skipping): ${r.name}`);
|
||||
} else {
|
||||
console.error(`❌ Failed to seed role ${r.name}:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log("✅ Roles seeding completed");
|
||||
|
||||
// =========== USER ===========
|
||||
console.log("🔄 Seeding users...");
|
||||
for (const u of users) {
|
||||
try {
|
||||
// Verify role exists first
|
||||
const roleExists = await prisma.role.findUnique({
|
||||
where: { id: u.roleId.toString() },
|
||||
select: { id: true }, // Only select id to minimize query
|
||||
});
|
||||
|
||||
if (!roleExists) {
|
||||
console.error(
|
||||
`❌ Role with id ${u.roleId} not found for user ${u.username}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
await safeSeedUnique(
|
||||
"user",
|
||||
{ id: u.id },
|
||||
{
|
||||
username: u.username,
|
||||
nomor: u.nomor,
|
||||
roleId: u.roleId.toString(),
|
||||
isActive: u.isActive,
|
||||
sessionInvalid: false,
|
||||
}
|
||||
);
|
||||
console.log(`✅ Seeded user -> ${u.username}`);
|
||||
} catch (error: any) {
|
||||
if (error.code === "P2003") {
|
||||
console.error(
|
||||
`❌ Foreign key constraint failed for user ${u.username}: Role ${u.roleId} does not exist`
|
||||
);
|
||||
} else {
|
||||
console.error(`❌ Failed to seed user ${u.username}:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log("✅ Users seeding completed");
|
||||
|
||||
// =========== FILE STORAGE ===========
|
||||
console.log("🔄 Seeding file storage...");
|
||||
for (const f of fileStorage) {
|
||||
await prisma.fileStorage.upsert({
|
||||
where: { id: f.id },
|
||||
update: {
|
||||
name: f.name,
|
||||
realName: f.realName,
|
||||
path: f.path,
|
||||
mimeType: f.mimeType,
|
||||
link: f.link,
|
||||
category: f.category,
|
||||
},
|
||||
create: {
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
realName: f.realName,
|
||||
path: f.path,
|
||||
mimeType: f.mimeType,
|
||||
link: f.link,
|
||||
category: f.category,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await prisma.fileStorage.upsert({
|
||||
where: { id: f.id },
|
||||
update: {
|
||||
name: f.name,
|
||||
realName: f.realName,
|
||||
path: f.path,
|
||||
mimeType: f.mimeType,
|
||||
link: f.link,
|
||||
category: f.category,
|
||||
},
|
||||
create: {
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
realName: f.realName,
|
||||
path: f.path,
|
||||
mimeType: f.mimeType,
|
||||
link: f.link,
|
||||
category: f.category,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error(`❌ Failed to seed file storage ${f.name}:`, error.message);
|
||||
}
|
||||
}
|
||||
console.log("✅ File storage seeded");
|
||||
// =========== LANDING PAGE ===========
|
||||
@@ -515,15 +577,40 @@ import { safeSeedUnique } from "./safeseedUnique";
|
||||
console.log("posisi organisasi berhasil");
|
||||
|
||||
// =========== PEGAWAI PPID ===========
|
||||
console.log("🔄 Seeding pegawai PPID...");
|
||||
const flattenedPegawai = pegawaiPPID.flat();
|
||||
|
||||
// Check for duplicate emails
|
||||
const emails = new Set();
|
||||
for (const p of flattenedPegawai) {
|
||||
await prisma.pegawaiPPID.upsert({
|
||||
where: { id: p.id },
|
||||
update: p,
|
||||
create: p,
|
||||
});
|
||||
if (emails.has(p.email)) {
|
||||
console.warn(`⚠️ Duplicate email found in pegawaiPPID: ${p.email}`);
|
||||
}
|
||||
emails.add(p.email);
|
||||
}
|
||||
console.log("pegawai berhasil");
|
||||
|
||||
for (const p of flattenedPegawai) {
|
||||
try {
|
||||
await prisma.pegawaiPPID.upsert({
|
||||
where: { id: p.id },
|
||||
update: p,
|
||||
create: p,
|
||||
});
|
||||
console.log(`✅ Seeded pegawai PPID -> ${p.namaLengkap}`);
|
||||
} catch (error: any) {
|
||||
if (error.code === "P2002") {
|
||||
console.warn(
|
||||
`⚠️ Pegawai PPID with duplicate email (skipping): ${p.email}`
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
`❌ Failed to seed pegawai PPID ${p.namaLengkap}:`,
|
||||
error.message
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log("✅ pegawai PPID seeding completed");
|
||||
|
||||
// =========== SUBMENU VISI MISI PPID ===========
|
||||
|
||||
@@ -787,7 +874,9 @@ import { safeSeedUnique } from "./safeseedUnique";
|
||||
const flattenedPosisiBumdes = posisiOrganisasi.flat();
|
||||
|
||||
// ✅ Urutkan berdasarkan hierarki
|
||||
const sortedPosisiBumdes = flattenedPosisiBumdes.sort((a, b) => a.hierarki - b.hierarki);
|
||||
const sortedPosisiBumdes = flattenedPosisiBumdes.sort(
|
||||
(a, b) => a.hierarki - b.hierarki
|
||||
);
|
||||
|
||||
for (const p of sortedPosisiBumdes) {
|
||||
console.log(`Seeding: ${p.nama} (id: ${p.id}, parent: ${p.parentId})`);
|
||||
@@ -867,7 +956,7 @@ import { safeSeedUnique } from "./safeseedUnique";
|
||||
// Add IDs to the kategoriKegiatan data
|
||||
const kategoriKegiatan = kategoriKegiatanData.map((k, index) => ({
|
||||
...k,
|
||||
id: `kategori-${index + 1}`
|
||||
id: `kategori-${index + 1}`,
|
||||
}));
|
||||
|
||||
for (const k of kategoriKegiatan) {
|
||||
@@ -1159,7 +1248,6 @@ import { safeSeedUnique } from "./safeseedUnique";
|
||||
|
||||
// seed assets
|
||||
await seedAssets();
|
||||
|
||||
})()
|
||||
.then(() => prisma.$disconnect())
|
||||
.catch((e) => {
|
||||
|
||||
@@ -15,7 +15,9 @@ function Login() {
|
||||
|
||||
// Login.tsx
|
||||
async function onLogin() {
|
||||
|
||||
const cleanPhone = phone.replace(/\D/g, '');
|
||||
console.log(cleanPhone);
|
||||
if (cleanPhone.length < 10) {
|
||||
toast.error('Nomor telepon tidak valid');
|
||||
return;
|
||||
@@ -25,6 +27,8 @@ function Login() {
|
||||
setLoading(true);
|
||||
const response = await apiFetchLogin({ nomor: cleanPhone });
|
||||
|
||||
console.log(response);
|
||||
|
||||
if (!response.success) {
|
||||
toast.error(response.message || 'Gagal memproses login');
|
||||
return;
|
||||
@@ -32,11 +36,12 @@ function Login() {
|
||||
|
||||
// Simpan nomor untuk register
|
||||
localStorage.setItem('auth_nomor', cleanPhone);
|
||||
|
||||
if (response.isRegistered) {
|
||||
// ✅ User lama: simpan kodeId & ke validasi
|
||||
// ✅ User lama: simpan kodeId
|
||||
localStorage.setItem('auth_kodeId', response.kodeId);
|
||||
router.push('/validasi');
|
||||
|
||||
// ✅ Cookie sudah di-set oleh API, langsung redirect
|
||||
router.push('/validasi'); // Clean URL
|
||||
} else {
|
||||
// ❌ User baru: langsung ke registrasi (tanpa kodeId)
|
||||
router.push('/registrasi');
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function Registrasi() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [phone, setPhone] = useState(''); // ✅ tambahkan state untuk phone
|
||||
const [agree, setAgree] = useState(false)
|
||||
|
||||
// Ambil data dari localStorage (dari login)
|
||||
useEffect(() => {
|
||||
@@ -46,6 +47,11 @@ export default function Registrasi() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!agree) {
|
||||
toast.error("Anda harus menyetujui syarat dan ketentuan!");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
// ✅ Hanya kirim username & nomor → dapat kodeId
|
||||
@@ -92,8 +98,8 @@ export default function Registrasi() {
|
||||
username.length > 0 && username.length < 5
|
||||
? 'Minimal 5 karakter!'
|
||||
: username.includes(' ')
|
||||
? 'Tidak boleh ada spasi'
|
||||
: ''
|
||||
? 'Tidak boleh ada spasi'
|
||||
: ''
|
||||
}
|
||||
required
|
||||
/>
|
||||
@@ -108,9 +114,29 @@ export default function Registrasi() {
|
||||
</Box>
|
||||
|
||||
<Box pt="md">
|
||||
<Checkbox label="Saya menyetujui syarat dan ketentuan" defaultChecked />
|
||||
<Checkbox
|
||||
checked={agree}
|
||||
onChange={(e) => setAgree(e.currentTarget.checked)}
|
||||
label={
|
||||
<Text fz="sm">
|
||||
Saya menyetujui{" "}
|
||||
<a
|
||||
href="/terms-of-service"
|
||||
target="_blank"
|
||||
style={{
|
||||
color: colors["blue-button"],
|
||||
textDecoration: "underline",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
syarat dan ketentuan
|
||||
</a>
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
<Box pt="xl">
|
||||
<Button
|
||||
fullWidth
|
||||
|
||||
@@ -19,17 +19,34 @@ import { authStore } from '@/store/authStore';
|
||||
|
||||
export default function Validasi() {
|
||||
const router = useRouter();
|
||||
|
||||
const [nomor, setNomor] = useState<string | null>(null);
|
||||
const [otp, setOtp] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [kodeId, setKodeId] = useState<string | null>(null);
|
||||
const [isRegistrationFlow, setIsRegistrationFlow] = useState(false); // Tambahkan flag
|
||||
const [isRegistrationFlow, setIsRegistrationFlow] = useState(false);
|
||||
|
||||
// Cek apakah ini alur registrasi
|
||||
// ✅ Deteksi flow dari cookie via API
|
||||
useEffect(() => {
|
||||
const storedUsername = localStorage.getItem('auth_username');
|
||||
setIsRegistrationFlow(!!storedUsername);
|
||||
const checkFlow = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/get-flow', {
|
||||
credentials: 'include'
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
setIsRegistrationFlow(data.flow === 'register');
|
||||
console.log('🔍 Flow detected from cookie:', data.flow);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error getting flow:', error);
|
||||
setIsRegistrationFlow(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkFlow();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -68,10 +85,8 @@ export default function Validasi() {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (isRegistrationFlow) {
|
||||
// 🔑 Alur REGISTRASI
|
||||
await handleRegistrationVerification();
|
||||
} else {
|
||||
// 🔑 Alur LOGIN
|
||||
await handleLoginVerification();
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -82,71 +97,63 @@ export default function Validasi() {
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ Verifikasi OTP untuk REGISTRASI
|
||||
const handleRegistrationVerification = async () => {
|
||||
const username = localStorage.getItem('auth_username');
|
||||
if (!username) {
|
||||
toast.error('Data registrasi tidak ditemukan. Silakan ulangi dari awal.');
|
||||
toast.error('Data registrasi tidak ditemukan.');
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ Validasi format
|
||||
const cleanNomor = nomor?.replace(/\D/g, '') ?? '';
|
||||
if (cleanNomor.length < 10) {
|
||||
toast.error('Nomor tidak valid');
|
||||
if (cleanNomor.length < 10 || username.trim().length < 5) {
|
||||
toast.error('Data tidak valid');
|
||||
return;
|
||||
}
|
||||
|
||||
if (username.trim().length < 5) {
|
||||
toast.error('Username minimal 5 karakter');
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Verifikasi OTP via endpoint register
|
||||
// ✅ Verify OTP
|
||||
const verifyRes = await fetch('/api/auth/verify-otp-register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ nomor: cleanNomor, otp, kodeId }),
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
const verifyData = await verifyRes.json();
|
||||
|
||||
if (!verifyRes.ok) {
|
||||
toast.error(verifyData.message || 'Verifikasi OTP gagal');
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Finalisasi registrasi
|
||||
// ✅ Finalize registration
|
||||
const finalizeRes = await fetch('/api/auth/finalize-registration', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ nomor, username, kodeId }), // 🔴 Tidak perlu kirim `otp` ke sini
|
||||
body: JSON.stringify({ nomor: cleanNomor, username, kodeId }),
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
const finalizeData = await finalizeRes.json();
|
||||
const data = await finalizeRes.json();
|
||||
|
||||
if (!finalizeRes.ok) {
|
||||
toast.error(finalizeData.message || 'Registrasi gagal');
|
||||
return;
|
||||
// ✅ Check JSON response (bukan redirect)
|
||||
if (data.success) {
|
||||
toast.success('Registrasi berhasil! Menunggu persetujuan admin.');
|
||||
await cleanupStorage();
|
||||
|
||||
// ✅ Client-side redirect
|
||||
setTimeout(() => {
|
||||
window.location.href = '/waiting-room';
|
||||
}, 1000);
|
||||
} else {
|
||||
toast.error(data.message || 'Registrasi gagal');
|
||||
}
|
||||
|
||||
// 3. Set user & redirect
|
||||
authStore.setUser({
|
||||
id: finalizeData.user.id,
|
||||
name: finalizeData.user.name,
|
||||
roleId: Number(finalizeData.user.roleId),
|
||||
});
|
||||
|
||||
cleanupStorage();
|
||||
window.location.href = '/waiting-room';
|
||||
};
|
||||
|
||||
// ✅ Verifikasi OTP untuk LOGIN
|
||||
const handleLoginVerification = async () => {
|
||||
const loginRes = await fetch('/api/auth/verify-otp-login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ nomor, otp, kodeId }),
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
const loginData = await loginRes.json();
|
||||
@@ -164,7 +171,8 @@ export default function Validasi() {
|
||||
roleId: Number(roleId),
|
||||
});
|
||||
|
||||
cleanupStorage();
|
||||
// ✅ Cleanup setelah login sukses
|
||||
await cleanupStorage();
|
||||
|
||||
if (!isActive) {
|
||||
window.location.href = '/waiting-room';
|
||||
@@ -177,23 +185,35 @@ export default function Validasi() {
|
||||
|
||||
const getRedirectPath = (roleId: number): string => {
|
||||
switch (roleId) {
|
||||
case 0: // DEVELOPER
|
||||
case 1: // SUPERADMIN
|
||||
case 2: // ADMIN_DESA
|
||||
case 0:
|
||||
case 1:
|
||||
case 2:
|
||||
return '/admin/landing-page/profil/program-inovasi';
|
||||
case 3: // ADMIN_KESEHATAN
|
||||
case 3:
|
||||
return '/admin/kesehatan/posyandu';
|
||||
case 4: // ADMIN_PENDIDIKAN
|
||||
case 4:
|
||||
return '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
|
||||
default:
|
||||
return '/admin';
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupStorage = () => {
|
||||
// ✅ CLEANUP FUNCTION - Hapus localStorage + Cookie
|
||||
const cleanupStorage = async () => {
|
||||
// Clear localStorage
|
||||
localStorage.removeItem('auth_kodeId');
|
||||
localStorage.removeItem('auth_nomor');
|
||||
localStorage.removeItem('auth_username');
|
||||
|
||||
// Clear cookie
|
||||
try {
|
||||
await fetch('/api/auth/clear-flow', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error clearing flow cookie:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResend = async () => {
|
||||
|
||||
@@ -361,6 +361,7 @@ function CreateAPBDes() {
|
||||
data={[
|
||||
{ value: 'pendapatan', label: 'Pendapatan' },
|
||||
{ value: 'belanja', label: 'Belanja' },
|
||||
{ value: 'pembiayaan', label: 'Pembiayaan' },
|
||||
]}
|
||||
value={newItem.level === 1 ? null : newItem.tipe}
|
||||
onChange={(val) => setNewItem({ ...newItem, tipe: val as any })}
|
||||
|
||||
@@ -93,6 +93,7 @@ function ListUser({ search }: { search: string }) {
|
||||
const success = await stateUser.update.submit({
|
||||
id: userId,
|
||||
roleId: newRoleId,
|
||||
|
||||
});
|
||||
|
||||
if (success) {
|
||||
@@ -136,9 +137,10 @@ function ListUser({ search }: { search: string }) {
|
||||
}
|
||||
};
|
||||
|
||||
const filteredData = (data || []).filter(
|
||||
(item) => item.roleId !== "0" // asumsikan id role SUPERADMIN = "0"
|
||||
);
|
||||
const filteredData = (data || []).filter((item) => {
|
||||
return item.roleId !== "0" && item.roleId !== "1";
|
||||
});
|
||||
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
@@ -183,7 +185,7 @@ function ListUser({ search }: { search: string }) {
|
||||
<Select
|
||||
placeholder="Pilih role"
|
||||
data={stateRole.findMany.data
|
||||
.filter(r => r.id !== "0") // ❌ Sembunyikan SUPERADMIN
|
||||
.filter(r => r.id !== "0" && r.id !== "1") // ❌ Sembunyikan SUPERADMIN dan DEVELOPER
|
||||
.map(r => ({
|
||||
label: r.name,
|
||||
value: r.id,
|
||||
|
||||
@@ -1,3 +1,399 @@
|
||||
// 'use client'
|
||||
|
||||
// import colors from "@/con/colors";
|
||||
// import { authStore } from "@/store/authStore";
|
||||
// import {
|
||||
// ActionIcon,
|
||||
// AppShell,
|
||||
// AppShellHeader,
|
||||
// AppShellMain,
|
||||
// AppShellNavbar,
|
||||
// Burger,
|
||||
// Center,
|
||||
// Flex,
|
||||
// Group,
|
||||
// Image,
|
||||
// Loader,
|
||||
// NavLink,
|
||||
// ScrollArea,
|
||||
// Text,
|
||||
// Tooltip,
|
||||
// rem
|
||||
// } from "@mantine/core";
|
||||
// import { useDisclosure } from "@mantine/hooks";
|
||||
// import {
|
||||
// IconChevronLeft,
|
||||
// IconChevronRight,
|
||||
// IconLogout2
|
||||
// } from "@tabler/icons-react";
|
||||
// import _ from "lodash";
|
||||
// import Link from "next/link";
|
||||
// import { useRouter, useSelectedLayoutSegments } from "next/navigation";
|
||||
// import { useEffect, useState } from "react";
|
||||
// // import { useSnapshot } from "valtio";
|
||||
// import { getNavbar } from "./(dashboard)/user&role/_com/dynamicNavbar";
|
||||
|
||||
// export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
// const [opened, { toggle }] = useDisclosure();
|
||||
// const [loading, setLoading] = useState(true);
|
||||
// const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
// const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
|
||||
// const router = useRouter();
|
||||
// const segments = useSelectedLayoutSegments().map((s) => _.lowerCase(s));
|
||||
|
||||
// // const { user } = useSnapshot(authStore);
|
||||
|
||||
// // console.log("Current user in store:", user);
|
||||
|
||||
// // ✅ FIX: Selalu fetch user data setiap kali komponen mount
|
||||
// useEffect(() => {
|
||||
// const fetchUser = async () => {
|
||||
// try {
|
||||
// const res = await fetch('/api/auth/me');
|
||||
// const data = await res.json();
|
||||
|
||||
// if (data.user) {
|
||||
// // ✅ Check if user is NOT active → redirect to waiting room
|
||||
// if (!data.user.isActive) {
|
||||
// authStore.setUser(null);
|
||||
// router.replace('/waiting-room');
|
||||
// return;
|
||||
// }
|
||||
|
||||
// // ✅ Fetch menuIds
|
||||
// const menuRes = await fetch(`/api/admin/user-menu-access?userId=${data.user.id}`);
|
||||
// const menuData = await menuRes.json();
|
||||
|
||||
// const menuIds = menuData.success && Array.isArray(menuData.menuIds)
|
||||
// ? [...menuData.menuIds]
|
||||
// : null;
|
||||
|
||||
// // ✅ Set user dengan menuIds yang fresh
|
||||
// authStore.setUser({
|
||||
// id: data.user.id,
|
||||
// name: data.user.name,
|
||||
// roleId: Number(data.user.roleId),
|
||||
// menuIds,
|
||||
// isActive: data.user.isActive
|
||||
// });
|
||||
|
||||
// // ✅ TAMBAHKAN INI: Redirect ke dashboard sesuai roleId
|
||||
// const currentPath = window.location.pathname;
|
||||
// const expectedPath = getRedirectPath(Number(data.user.roleId));
|
||||
|
||||
// // Jika user di halaman /admin tapi bukan di path yang sesuai roleId
|
||||
// if (currentPath === '/admin' || !currentPath.startsWith(expectedPath)) {
|
||||
// router.replace(expectedPath);
|
||||
// }
|
||||
|
||||
// } else {
|
||||
// authStore.setUser(null);
|
||||
// router.replace('/login');
|
||||
// }
|
||||
// } catch (error) {
|
||||
// console.error('Gagal memuat data pengguna:', error);
|
||||
// authStore.setUser(null);
|
||||
// router.replace('/login');
|
||||
// } finally {
|
||||
// setLoading(false);
|
||||
// }
|
||||
// };
|
||||
|
||||
// fetchUser();
|
||||
// }, [router]);
|
||||
|
||||
// // ✅ Fungsi helper untuk get redirect path
|
||||
// const getRedirectPath = (roleId: number): string => {
|
||||
// switch (roleId) {
|
||||
// case 0: // DEVELOPER
|
||||
// case 1: // SUPERADMIN
|
||||
// case 2: // ADMIN_DESA
|
||||
// return '/admin/landing-page/profil/program-inovasi';
|
||||
// case 3: // ADMIN_KESEHATAN
|
||||
// return '/admin/kesehatan/posyandu';
|
||||
// case 4: // ADMIN_PENDIDIKAN
|
||||
// return '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
|
||||
// default:
|
||||
// return '/admin';
|
||||
// }
|
||||
// };
|
||||
|
||||
// if (loading) {
|
||||
// return (
|
||||
// <AppShell>
|
||||
// <AppShellMain>
|
||||
// <Center h="100vh">
|
||||
// <Loader />
|
||||
// </Center>
|
||||
// </AppShellMain>
|
||||
// </AppShell>
|
||||
// );
|
||||
// }
|
||||
|
||||
// // ✅ Ambil menu berdasarkan roleId dan menuIds
|
||||
// const currentNav = authStore.user
|
||||
// ? getNavbar({ roleId: authStore.user.roleId, menuIds: authStore.user.menuIds })
|
||||
// : [];
|
||||
|
||||
// const handleLogout = async () => {
|
||||
// try {
|
||||
// setIsLoggingOut(true);
|
||||
|
||||
// // ✅ Panggil API logout untuk clear session di server
|
||||
// const response = await fetch('/api/auth/logout', { method: 'POST' });
|
||||
// const result = await response.json();
|
||||
|
||||
// if (result.success) {
|
||||
// // Clear user data dari store
|
||||
// authStore.setUser(null);
|
||||
|
||||
// // Clear localStorage
|
||||
// localStorage.removeItem('auth_nomor');
|
||||
// localStorage.removeItem('auth_kodeId');
|
||||
|
||||
// // Force reload untuk reset semua state
|
||||
// window.location.href = '/login';
|
||||
// } else {
|
||||
// console.error('Logout failed:', result.message);
|
||||
// // Tetap redirect meskipun gagal
|
||||
// authStore.setUser(null);
|
||||
// window.location.href = '/login';
|
||||
// }
|
||||
// } catch (error) {
|
||||
// console.error('Error during logout:', error);
|
||||
// // Tetap clear store dan redirect jika error
|
||||
// authStore.setUser(null);
|
||||
// window.location.href = '/login';
|
||||
// } finally {
|
||||
// setIsLoggingOut(false);
|
||||
// }
|
||||
// };
|
||||
|
||||
// return (
|
||||
// <AppShell
|
||||
// suppressHydrationWarning
|
||||
// header={{ height: 64 }}
|
||||
// navbar={{
|
||||
// width: { base: 260, sm: 280, lg: 300 },
|
||||
// breakpoint: 'sm',
|
||||
// collapsed: {
|
||||
// mobile: !opened,
|
||||
// desktop: !desktopOpened,
|
||||
// },
|
||||
// }}
|
||||
// padding="md"
|
||||
// >
|
||||
// <AppShellHeader
|
||||
// style={{
|
||||
// background: "linear-gradient(90deg, #ffffff, #f9fbff)",
|
||||
// borderBottom: `1px solid ${colors["blue-button"]}20`,
|
||||
// padding: '0 16px',
|
||||
// }}
|
||||
// px={{ base: 'sm', sm: 'md' }}
|
||||
// py={{ base: 'xs', sm: 'sm' }}
|
||||
// >
|
||||
// <Group w="100%" h="100%" justify="space-between" wrap="nowrap">
|
||||
// <Flex align="center" gap="sm">
|
||||
// <Image
|
||||
// src="/assets/images/darmasaba-icon.png"
|
||||
// alt="Logo Darmasaba"
|
||||
// w={{ base: 32, sm: 40 }}
|
||||
// h={{ base: 32, sm: 40 }}
|
||||
// radius="md"
|
||||
// loading="lazy"
|
||||
// style={{
|
||||
// minWidth: '32px',
|
||||
// height: 'auto',
|
||||
// }}
|
||||
// />
|
||||
// <Text
|
||||
// fw={700}
|
||||
// c={colors["blue-button"]}
|
||||
// fz={{ base: 'md', sm: 'xl' }}
|
||||
// >
|
||||
// Admin Darmasaba
|
||||
// </Text>
|
||||
// </Flex>
|
||||
|
||||
// <Group gap="xs">
|
||||
// {!desktopOpened && (
|
||||
// <Tooltip label="Buka Navigasi" position="bottom" withArrow>
|
||||
// <ActionIcon
|
||||
// variant="light"
|
||||
// radius="xl"
|
||||
// size="lg"
|
||||
// onClick={toggleDesktop}
|
||||
// color={colors["blue-button"]}
|
||||
// >
|
||||
// <IconChevronRight />
|
||||
// </ActionIcon>
|
||||
// </Tooltip>
|
||||
// )}
|
||||
|
||||
// <Burger
|
||||
// opened={opened}
|
||||
// onClick={toggle}
|
||||
// hiddenFrom="sm"
|
||||
// size="md"
|
||||
// color={colors["blue-button"]}
|
||||
// mr="xs"
|
||||
// />
|
||||
|
||||
// <Tooltip label="Kembali ke Website Desa" position="bottom" withArrow>
|
||||
// <ActionIcon
|
||||
// onClick={() => {
|
||||
// router.push("/darmasaba");
|
||||
// }}
|
||||
// color={colors["blue-button"]}
|
||||
// radius="xl"
|
||||
// size="lg"
|
||||
// variant="gradient"
|
||||
// gradient={{ from: colors["blue-button"], to: "#228be6" }}
|
||||
// >
|
||||
// <Image
|
||||
// src="/assets/images/darmasaba-icon.png"
|
||||
// alt="Logo Darmasaba"
|
||||
// w={20}
|
||||
// h={20}
|
||||
// radius="md"
|
||||
// loading="lazy"
|
||||
// style={{
|
||||
// minWidth: '20px',
|
||||
// height: 'auto',
|
||||
// }}
|
||||
// />
|
||||
// </ActionIcon>
|
||||
// </Tooltip>
|
||||
// <Tooltip label="Keluar" position="bottom" withArrow>
|
||||
// <ActionIcon
|
||||
// onClick={handleLogout}
|
||||
// color={colors["blue-button"]}
|
||||
// radius="xl"
|
||||
// size="lg"
|
||||
// variant="gradient"
|
||||
// gradient={{ from: colors["blue-button"], to: "#228be6" }}
|
||||
// loading={isLoggingOut}
|
||||
// disabled={isLoggingOut}
|
||||
// >
|
||||
// <IconLogout2 size={22} />
|
||||
// </ActionIcon>
|
||||
// </Tooltip>
|
||||
// </Group>
|
||||
// </Group>
|
||||
// </AppShellHeader>
|
||||
|
||||
// <AppShellNavbar
|
||||
// component={ScrollArea}
|
||||
// style={{
|
||||
// background: "#ffffff",
|
||||
// borderRight: `1px solid ${colors["blue-button"]}20`,
|
||||
// }}
|
||||
// p={{ base: 'xs', sm: 'sm' }}
|
||||
// >
|
||||
// <AppShell.Section p="sm">
|
||||
// {currentNav.map((v, k) => {
|
||||
// const isParentActive = segments.includes(_.lowerCase(v.name));
|
||||
|
||||
// return (
|
||||
// <NavLink
|
||||
// key={k}
|
||||
// defaultOpened={isParentActive}
|
||||
// c={isParentActive ? colors["blue-button"] : "gray"}
|
||||
// label={
|
||||
// <Text fw={isParentActive ? 600 : 400} fz="sm">
|
||||
// {v.name}
|
||||
// </Text>
|
||||
// }
|
||||
// style={{
|
||||
// borderRadius: rem(10),
|
||||
// marginBottom: rem(4),
|
||||
// transition: "background 150ms ease",
|
||||
// }}
|
||||
// styles={{
|
||||
// root: {
|
||||
// '&:hover': {
|
||||
// backgroundColor: 'rgba(25, 113, 194, 0.05)',
|
||||
// },
|
||||
// },
|
||||
// }}
|
||||
// variant="light"
|
||||
// active={isParentActive}
|
||||
// >
|
||||
// {v.children.map((child, key) => {
|
||||
// const isChildActive = segments.includes(
|
||||
// _.lowerCase(child.name)
|
||||
// );
|
||||
|
||||
// return (
|
||||
// <NavLink
|
||||
// key={key}
|
||||
// href={child.path}
|
||||
// c={isChildActive ? colors["blue-button"] : "gray"}
|
||||
// label={
|
||||
// <Text fw={isChildActive ? 600 : 400} fz="sm">
|
||||
// {child.name}
|
||||
// </Text>
|
||||
// }
|
||||
// styles={{
|
||||
// root: {
|
||||
// borderRadius: rem(8),
|
||||
// marginBottom: rem(2),
|
||||
// transition: 'background 150ms ease',
|
||||
// padding: '6px 12px',
|
||||
// '&:hover': {
|
||||
// backgroundColor: isChildActive ? 'rgba(25, 113, 194, 0.15)' : 'rgba(25, 113, 194, 0.05)',
|
||||
// },
|
||||
// ...(isChildActive && {
|
||||
// backgroundColor: 'rgba(25, 113, 194, 0.1)',
|
||||
// }),
|
||||
// },
|
||||
// }}
|
||||
// active={isChildActive}
|
||||
// component={Link}
|
||||
// />
|
||||
// );
|
||||
// })}
|
||||
// </NavLink>
|
||||
// );
|
||||
// })}
|
||||
// </AppShell.Section>
|
||||
|
||||
// <AppShell.Section py="md">
|
||||
// <Group justify="end" pr="sm">
|
||||
// <Tooltip
|
||||
// label={desktopOpened ? "Tutup Navigasi" : "Buka Navigasi"}
|
||||
// position="top"
|
||||
// withArrow
|
||||
// >
|
||||
// <ActionIcon
|
||||
// variant="light"
|
||||
// radius="xl"
|
||||
// size="lg"
|
||||
// onClick={toggleDesktop}
|
||||
// color={colors["blue-button"]}
|
||||
// >
|
||||
// <IconChevronLeft />
|
||||
// </ActionIcon>
|
||||
// </Tooltip>
|
||||
// </Group>
|
||||
// </AppShell.Section>
|
||||
// </AppShellNavbar>
|
||||
|
||||
// <AppShellMain
|
||||
// style={{
|
||||
// background: "linear-gradient(180deg, #fdfdfd, #f6f9fc)",
|
||||
// minHeight: "100vh",
|
||||
// }}
|
||||
// >
|
||||
// {children}
|
||||
// </AppShellMain>
|
||||
// </AppShell>
|
||||
// );
|
||||
// }
|
||||
|
||||
|
||||
// app/admin/layout.tsx
|
||||
|
||||
'use client'
|
||||
|
||||
import colors from "@/con/colors";
|
||||
@@ -30,46 +426,62 @@ import _ from "lodash";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSelectedLayoutSegments } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSnapshot } from "valtio";
|
||||
import { getNavbar } from "./(dashboard)/user&role/_com/dynamicNavbar";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const [opened, { toggle }] = useDisclosure();
|
||||
const [opened, { toggle }] = useDisclosure();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
|
||||
const router = useRouter();
|
||||
const segments = useSelectedLayoutSegments().map((s) => _.lowerCase(s));
|
||||
|
||||
|
||||
const { user } = useSnapshot(authStore);
|
||||
|
||||
console.log("Current user in store:", user);
|
||||
|
||||
useEffect(() => {
|
||||
if (authStore.user) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/me');
|
||||
const res = await fetch('/api/auth/me', {
|
||||
credentials: 'include' // ✅ ADD credentials
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.user) {
|
||||
const menuRes = await fetch(`/api/admin/user-menu-access?userId=${data.user.id}`);
|
||||
// ✅ Check if user is NOT active → redirect to waiting room
|
||||
if (!data.user.isActive) {
|
||||
authStore.setUser(null);
|
||||
router.replace('/waiting-room');
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ Fetch menuIds
|
||||
const menuRes = await fetch(`/api/admin/user-menu-access?userId=${data.user.id}`, {
|
||||
credentials: 'include' // ✅ ADD credentials
|
||||
});
|
||||
const menuData = await menuRes.json();
|
||||
|
||||
// ✅ Clone ke array mutable
|
||||
const menuIds = menuData.success && Array.isArray(menuData.menuIds)
|
||||
? [...menuData.menuIds] // Converts readonly array to mutable
|
||||
? [...menuData.menuIds]
|
||||
: null;
|
||||
|
||||
// ✅ Set user dengan menuIds yang fresh
|
||||
authStore.setUser({
|
||||
id: data.user.id,
|
||||
name: data.user.name,
|
||||
roleId: Number(data.user.roleId),
|
||||
menuIds,
|
||||
isActive: data.user.isActive
|
||||
});
|
||||
|
||||
// ✅ IMPROVED: Redirect ONLY if di root /admin
|
||||
const currentPath = window.location.pathname;
|
||||
|
||||
if (currentPath === '/admin') {
|
||||
const expectedPath = getRedirectPath(Number(data.user.roleId));
|
||||
console.log('🔄 Redirecting from /admin to:', expectedPath);
|
||||
router.replace(expectedPath);
|
||||
}
|
||||
// ✅ Jangan redirect jika user sudah di path yang valid
|
||||
|
||||
} else {
|
||||
authStore.setUser(null);
|
||||
router.replace('/login');
|
||||
@@ -84,7 +496,22 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
};
|
||||
|
||||
fetchUser();
|
||||
}, [router]);
|
||||
}, [router]); // ✅ Only depend on router
|
||||
|
||||
const getRedirectPath = (roleId: number): string => {
|
||||
switch (roleId) {
|
||||
case 0: // DEVELOPER
|
||||
case 1: // SUPERADMIN
|
||||
case 2: // ADMIN_DESA
|
||||
return '/admin/landing-page/profil/program-inovasi';
|
||||
case 3: // ADMIN_KESEHATAN
|
||||
return '/admin/kesehatan/posyandu';
|
||||
case 4: // ADMIN_PENDIDIKAN
|
||||
return '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
|
||||
default:
|
||||
return '/admin';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -98,15 +525,38 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ✅ Ambil menu berdasarkan roleId
|
||||
const currentNav = authStore.user
|
||||
? getNavbar({ roleId: authStore.user.roleId, menuIds: authStore.user.menuIds })
|
||||
: [];
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.setUser(null);
|
||||
document.cookie = `${process.env.BASE_SESSION_KEY}=; Max-Age=0; path=/;`;
|
||||
router.push('/login');
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
setIsLoggingOut(true);
|
||||
|
||||
const response = await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include' // ✅ ADD credentials
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
authStore.setUser(null);
|
||||
localStorage.removeItem('auth_nomor');
|
||||
localStorage.removeItem('auth_kodeId');
|
||||
localStorage.removeItem('auth_username');
|
||||
window.location.href = '/login';
|
||||
} else {
|
||||
console.error('Logout failed:', result.message);
|
||||
authStore.setUser(null);
|
||||
window.location.href = '/login';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during logout:', error);
|
||||
authStore.setUser(null);
|
||||
window.location.href = '/login';
|
||||
} finally {
|
||||
setIsLoggingOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -123,6 +573,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
}}
|
||||
padding="md"
|
||||
>
|
||||
{/* ... rest of your JSX (Header, Navbar, Main) sama seperti sebelumnya ... */}
|
||||
<AppShellHeader
|
||||
style={{
|
||||
background: "linear-gradient(90deg, #ffffff, #f9fbff)",
|
||||
@@ -141,16 +592,9 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
h={{ base: 32, sm: 40 }}
|
||||
radius="md"
|
||||
loading="lazy"
|
||||
style={{
|
||||
minWidth: '32px',
|
||||
height: 'auto',
|
||||
}}
|
||||
style={{ minWidth: '32px', height: 'auto' }}
|
||||
/>
|
||||
<Text
|
||||
fw={700}
|
||||
c={colors["blue-button"]}
|
||||
fz={{ base: 'md', sm: 'xl' }}
|
||||
>
|
||||
<Text fw={700} c={colors["blue-button"]} fz={{ base: 'md', sm: 'xl' }}>
|
||||
Admin Darmasaba
|
||||
</Text>
|
||||
</Flex>
|
||||
@@ -158,61 +602,22 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
<Group gap="xs">
|
||||
{!desktopOpened && (
|
||||
<Tooltip label="Buka Navigasi" position="bottom" withArrow>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
radius="xl"
|
||||
size="lg"
|
||||
onClick={toggleDesktop}
|
||||
color={colors["blue-button"]}
|
||||
>
|
||||
<ActionIcon variant="light" radius="xl" size="lg" onClick={toggleDesktop} color={colors["blue-button"]}>
|
||||
<IconChevronRight />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Burger
|
||||
opened={opened}
|
||||
onClick={toggle}
|
||||
hiddenFrom="sm"
|
||||
size="md"
|
||||
color={colors["blue-button"]}
|
||||
mr="xs"
|
||||
/>
|
||||
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="md" color={colors["blue-button"]} mr="xs" />
|
||||
|
||||
<Tooltip label="Kembali ke Website Desa" position="bottom" withArrow>
|
||||
<ActionIcon
|
||||
onClick={() => {
|
||||
router.push("/darmasaba");
|
||||
}}
|
||||
color={colors["blue-button"]}
|
||||
radius="xl"
|
||||
size="lg"
|
||||
variant="gradient"
|
||||
gradient={{ from: colors["blue-button"], to: "#228be6" }}
|
||||
>
|
||||
<Image
|
||||
src="/assets/images/darmasaba-icon.png"
|
||||
alt="Logo Darmasaba"
|
||||
w={20}
|
||||
h={20}
|
||||
radius="md"
|
||||
loading="lazy"
|
||||
style={{
|
||||
minWidth: '20px',
|
||||
height: 'auto',
|
||||
}}
|
||||
/>
|
||||
<ActionIcon onClick={() => router.push("/darmasaba")} color={colors["blue-button"]} radius="xl" size="lg" variant="gradient" gradient={{ from: colors["blue-button"], to: "#228be6" }}>
|
||||
<Image src="/assets/images/darmasaba-icon.png" alt="Logo Darmasaba" w={20} h={20} radius="md" loading="lazy" style={{ minWidth: '20px', height: 'auto' }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Keluar" position="bottom" withArrow>
|
||||
<ActionIcon
|
||||
onClick={handleLogout}
|
||||
color={colors["blue-button"]}
|
||||
radius="xl"
|
||||
size="lg"
|
||||
variant="gradient"
|
||||
gradient={{ from: colors["blue-button"], to: "#228be6" }}
|
||||
>
|
||||
<ActionIcon onClick={handleLogout} color={colors["blue-button"]} radius="xl" size="lg" variant="gradient" gradient={{ from: colors["blue-button"], to: "#228be6" }} loading={isLoggingOut} disabled={isLoggingOut}>
|
||||
<IconLogout2 size={22} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
@@ -220,75 +625,17 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
</Group>
|
||||
</AppShellHeader>
|
||||
|
||||
<AppShellNavbar
|
||||
component={ScrollArea}
|
||||
style={{
|
||||
background: "#ffffff",
|
||||
borderRight: `1px solid ${colors["blue-button"]}20`,
|
||||
}}
|
||||
p={{ base: 'xs', sm: 'sm' }}
|
||||
>
|
||||
<AppShellNavbar component={ScrollArea} style={{ background: "#ffffff", borderRight: `1px solid ${colors["blue-button"]}20` }} p={{ base: 'xs', sm: 'sm' }}>
|
||||
{/* ... Navbar content sama seperti sebelumnya ... */}
|
||||
<AppShell.Section p="sm">
|
||||
{currentNav.map((v, k) => {
|
||||
const isParentActive = segments.includes(_.lowerCase(v.name));
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={k}
|
||||
defaultOpened={isParentActive}
|
||||
c={isParentActive ? colors["blue-button"] : "gray"}
|
||||
label={
|
||||
<Text fw={isParentActive ? 600 : 400} fz="sm">
|
||||
{v.name}
|
||||
</Text>
|
||||
}
|
||||
style={{
|
||||
borderRadius: rem(10),
|
||||
marginBottom: rem(4),
|
||||
transition: "background 150ms ease",
|
||||
}}
|
||||
styles={{
|
||||
root: {
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(25, 113, 194, 0.05)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
variant="light"
|
||||
active={isParentActive}
|
||||
>
|
||||
<NavLink key={k} defaultOpened={isParentActive} c={isParentActive ? colors["blue-button"] : "gray"} label={<Text fw={isParentActive ? 600 : 400} fz="sm">{v.name}</Text>} style={{ borderRadius: rem(10), marginBottom: rem(4), transition: "background 150ms ease" }} styles={{ root: { '&:hover': { backgroundColor: 'rgba(25, 113, 194, 0.05)' } } }} variant="light" active={isParentActive}>
|
||||
{v.children.map((child, key) => {
|
||||
const isChildActive = segments.includes(
|
||||
_.lowerCase(child.name)
|
||||
);
|
||||
|
||||
const isChildActive = segments.includes(_.lowerCase(child.name));
|
||||
return (
|
||||
<NavLink
|
||||
key={key}
|
||||
href={child.path}
|
||||
c={isChildActive ? colors["blue-button"] : "gray"}
|
||||
label={
|
||||
<Text fw={isChildActive ? 600 : 400} fz="sm">
|
||||
{child.name}
|
||||
</Text>
|
||||
}
|
||||
styles={{
|
||||
root: {
|
||||
borderRadius: rem(8),
|
||||
marginBottom: rem(2),
|
||||
transition: 'background 150ms ease',
|
||||
padding: '6px 12px',
|
||||
'&:hover': {
|
||||
backgroundColor: isChildActive ? 'rgba(25, 113, 194, 0.15)' : 'rgba(25, 113, 194, 0.05)',
|
||||
},
|
||||
...(isChildActive && {
|
||||
backgroundColor: 'rgba(25, 113, 194, 0.1)',
|
||||
}),
|
||||
},
|
||||
}}
|
||||
active={isChildActive}
|
||||
component={Link}
|
||||
/>
|
||||
<NavLink key={key} href={child.path} c={isChildActive ? colors["blue-button"] : "gray"} label={<Text fw={isChildActive ? 600 : 400} fz="sm">{child.name}</Text>} styles={{ root: { borderRadius: rem(8), marginBottom: rem(2), transition: 'background 150ms ease', padding: '6px 12px', '&:hover': { backgroundColor: isChildActive ? 'rgba(25, 113, 194, 0.15)' : 'rgba(25, 113, 194, 0.05)' }, ...(isChildActive && { backgroundColor: 'rgba(25, 113, 194, 0.1)' }) } }} active={isChildActive} component={Link} />
|
||||
);
|
||||
})}
|
||||
</NavLink>
|
||||
@@ -298,18 +645,8 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
<AppShell.Section py="md">
|
||||
<Group justify="end" pr="sm">
|
||||
<Tooltip
|
||||
label={desktopOpened ? "Tutup Navigasi" : "Buka Navigasi"}
|
||||
position="top"
|
||||
withArrow
|
||||
>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
radius="xl"
|
||||
size="lg"
|
||||
onClick={toggleDesktop}
|
||||
color={colors["blue-button"]}
|
||||
>
|
||||
<Tooltip label={desktopOpened ? "Tutup Navigasi" : "Buka Navigasi"} position="top" withArrow>
|
||||
<ActionIcon variant="light" radius="xl" size="lg" onClick={toggleDesktop} color={colors["blue-button"]}>
|
||||
<IconChevronLeft />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
@@ -317,14 +654,9 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
</AppShell.Section>
|
||||
</AppShellNavbar>
|
||||
|
||||
<AppShellMain
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #fdfdfd, #f6f9fc)",
|
||||
minHeight: "100vh",
|
||||
}}
|
||||
>
|
||||
<AppShellMain style={{ background: "linear-gradient(180deg, #fdfdfd, #f6f9fc)", minHeight: "100vh" }}>
|
||||
{children}
|
||||
</AppShellMain>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export default async function userUpdate(context: Context) {
|
||||
|
||||
const currentUser = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: { roleId: true, isActive: true }
|
||||
select: { roleId: true, isActive: true },
|
||||
});
|
||||
|
||||
if (!currentUser) {
|
||||
@@ -31,7 +31,15 @@ export default async function userUpdate(context: Context) {
|
||||
}
|
||||
|
||||
const isRoleChanged = roleId && currentUser.roleId !== roleId;
|
||||
const isActiveChanged = isActive !== undefined && currentUser.isActive !== isActive;
|
||||
const isActiveChanged =
|
||||
isActive !== undefined && currentUser.isActive !== isActive;
|
||||
|
||||
// ✅ Jika role berubah, hapus semua akses menu yang ada
|
||||
if (isRoleChanged) {
|
||||
await prisma.userMenuAccess.deleteMany({
|
||||
where: { userId: id }
|
||||
});
|
||||
}
|
||||
|
||||
// Update user
|
||||
const updatedUser = await prisma.user.update({
|
||||
@@ -48,10 +56,11 @@ export default async function userUpdate(context: Context) {
|
||||
nomor: true,
|
||||
isActive: true,
|
||||
roleId: true,
|
||||
role: { select: { name: true } }
|
||||
}
|
||||
role: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
// ✅ HAPUS SEMUA SESI USER DI DATABASE
|
||||
if (isRoleChanged) {
|
||||
await prisma.userSession.deleteMany({ where: { userId: id } });
|
||||
@@ -62,11 +71,13 @@ export default async function userUpdate(context: Context) {
|
||||
roleChanged: isRoleChanged,
|
||||
isActiveChanged,
|
||||
data: updatedUser,
|
||||
message: isRoleChanged
|
||||
message: isRoleChanged
|
||||
? `Role ${updatedUser.username} diubah. User akan logout otomatis.`
|
||||
: isActiveChanged
|
||||
? `${updatedUser.username} ${isActive ? 'diaktifkan' : 'dinonaktifkan'}.`
|
||||
: "User berhasil diupdate"
|
||||
? `${updatedUser.username} ${
|
||||
isActive ? "diaktifkan" : "dinonaktifkan"
|
||||
}.`
|
||||
: "User berhasil diupdate",
|
||||
};
|
||||
} catch (e: any) {
|
||||
console.error("❌ Error update user:", e);
|
||||
@@ -75,4 +86,4 @@ export default async function userUpdate(context: Context) {
|
||||
message: "Gagal mengupdate user: " + (e.message || "Unknown error"),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export const apiFetchLogin = async ({ nomor }: { nomor: string }) => {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ nomor: cleanPhone }),
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
// Pastikan respons bisa di-parse sebagai JSON
|
||||
@@ -58,6 +59,7 @@ export const apiFetchRegister = async ({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: username.trim(), nomor: cleanPhone }),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// app/api/auth/_lib/session_create.ts
|
||||
// src/app/api/auth/_lib/sessionCreate.ts
|
||||
import { cookies } from "next/headers";
|
||||
import { encrypt } from "./encrypt";
|
||||
import prisma from "@/lib/prisma";
|
||||
@@ -9,11 +9,13 @@ export async function sessionCreate({
|
||||
exp = "30 day",
|
||||
jwtSecret,
|
||||
user,
|
||||
invalidatePrevious = true, // 🔑 kontrol apakah sesi lama di-nonaktifkan
|
||||
}: {
|
||||
sessionKey: string;
|
||||
exp?: string;
|
||||
jwtSecret: string;
|
||||
user: Record<string, unknown> & { id: string };
|
||||
invalidatePrevious?: boolean; // default true untuk login, false untuk registrasi
|
||||
}) {
|
||||
// ✅ Validasi env vars
|
||||
if (!sessionKey || sessionKey.length === 0) {
|
||||
@@ -28,18 +30,19 @@ export async function sessionCreate({
|
||||
throw new Error("Token generation failed");
|
||||
}
|
||||
|
||||
// ✅ Hitung expiresAt sesuai exp
|
||||
// ✅ Hitung expiresAt
|
||||
let expiresAt = add(new Date(), { days: 30 });
|
||||
if (exp === "7 day") expiresAt = add(new Date(), { days: 7 });
|
||||
// tambahkan opsi lain jika perlu
|
||||
|
||||
// Sebelum create session baru, nonaktifkan session aktif sebelumnya
|
||||
await prisma.userSession.updateMany({
|
||||
where: { userId: user.id, active: true },
|
||||
data: { active: false },
|
||||
});
|
||||
// 🔐 Hanya nonaktifkan sesi aktif sebelumnya jika diminta (misal: saat login ulang)
|
||||
if (invalidatePrevious) {
|
||||
await prisma.userSession.updateMany({
|
||||
where: { userId: user.id, active: true },
|
||||
data: { active: false },
|
||||
});
|
||||
}
|
||||
|
||||
// ✅ Simpan ke database
|
||||
// ✅ Simpan sesi baru
|
||||
await prisma.userSession.create({
|
||||
data: {
|
||||
token,
|
||||
@@ -55,8 +58,8 @@ export async function sessionCreate({
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 30 * 24 * 60 * 60, // seconds
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 hari dalam detik
|
||||
});
|
||||
|
||||
return token;
|
||||
}
|
||||
}
|
||||
@@ -30,13 +30,7 @@ export async function verifySession() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ❌ Hanya tolak jika sessionInvalid = true
|
||||
if (dbSession.user.sessionInvalid) {
|
||||
console.log('⚠️ Session di-invalidate');
|
||||
return null;
|
||||
}
|
||||
|
||||
// ✅ Return user, meskipun isActive = false
|
||||
// Don't check isActive here, let the frontend handle it
|
||||
return dbSession.user;
|
||||
} catch (error) {
|
||||
console.warn('Session verification failed:', error);
|
||||
|
||||
19
src/app/api/auth/clear-flow/route.ts
Normal file
19
src/app/api/auth/clear-flow/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// app/api/auth/clear-flow/route.ts
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
// ✅ Next.js 15 syntax
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete('auth_flow');
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('❌ Error clearing flow cookie:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,26 @@
|
||||
// src/app/api/auth/finalize-registration/route.ts
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { NextResponse } from "next/server";
|
||||
import { sessionCreate } from "../_lib/session_create";
|
||||
|
||||
// ✅ Gunakan STRING untuk roleId
|
||||
const DEFAULT_MENUS_BY_ROLE: Record<string, string[]> = {
|
||||
"0": [
|
||||
"Landing Page", "PPID", "Desa", "Kesehatan", "Keamanan",
|
||||
"Ekonomi", "Inovasi", "Lingkungan", "Pendidikan", "User & Role"
|
||||
],
|
||||
"1": [
|
||||
"Landing Page", "PPID", "Desa", "Keamanan",
|
||||
"Ekonomi", "Inovasi", "Lingkungan", "User & Role"
|
||||
],
|
||||
"2": ["Landing Page", "Desa", "Ekonomi", "Inovasi", "Lingkungan"],
|
||||
"3": ["Kesehatan"],
|
||||
"4": ["Pendidikan"],
|
||||
};
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { nomor, username, kodeId } = await req.json();
|
||||
|
||||
const cleanNomor = nomor.replace(/\D/g, "");
|
||||
|
||||
if (!cleanNomor || !username || !kodeId) {
|
||||
@@ -17,13 +30,12 @@ export async function POST(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// Di awal fungsi POST
|
||||
console.log("📦 Received payload:", { nomor, username, kodeId });
|
||||
|
||||
// Validasi OTP
|
||||
const otpRecord = await prisma.kodeOtp.findUnique({
|
||||
// Verify OTP
|
||||
const otpRecord = await prisma.kodeOtp.findUnique({
|
||||
where: { id: kodeId },
|
||||
select: { nomor: true, isActive: true }
|
||||
});
|
||||
|
||||
if (!otpRecord?.isActive || otpRecord.nomor !== cleanNomor) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "OTP tidak valid" },
|
||||
@@ -31,7 +43,7 @@ export async function POST(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// Cek duplikat username
|
||||
// Check duplicate username
|
||||
if (await prisma.user.findFirst({ where: { username } })) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Username sudah digunakan" },
|
||||
@@ -39,36 +51,59 @@ export async function POST(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// ✅ Gunakan username dari input user
|
||||
const defaultRole = await prisma.role.findFirst({
|
||||
where: { name: "ADMIN DESA" },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!defaultRole) {
|
||||
// Check duplicate nomor
|
||||
if (await prisma.user.findUnique({ where: { nomor: cleanNomor } })) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Role default tidak ditemukan" },
|
||||
{ status: 500 }
|
||||
{ success: false, message: "Nomor sudah terdaftar" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// ✅ Buat user dengan username yang diinput
|
||||
// 🔥 Tentukan roleId sebagai STRING
|
||||
const targetRoleId = "2"; // ✅ Default ADMIN_DESA (roleId "2")
|
||||
|
||||
// Validasi role exists
|
||||
const roleExists = await prisma.role.findUnique({
|
||||
where: { id: targetRoleId },
|
||||
select: { id: true }
|
||||
});
|
||||
|
||||
if (!roleExists) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Role tidak valid" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// ✅ Create user (inactive, waiting approval)
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
username, // ✅ Ini yang benar
|
||||
nomor,
|
||||
roleId: defaultRole.id,
|
||||
isActive: false,
|
||||
username,
|
||||
nomor: cleanNomor,
|
||||
roleId: targetRoleId,
|
||||
isActive: false, // Waiting for admin approval
|
||||
},
|
||||
});
|
||||
|
||||
// Nonaktifkan OTP
|
||||
// ✅ Berikan akses menu default based on role
|
||||
const menuIds = DEFAULT_MENUS_BY_ROLE[targetRoleId] || [];
|
||||
if (menuIds.length > 0) {
|
||||
await prisma.userMenuAccess.createMany({
|
||||
data: menuIds.map(menuId => ({
|
||||
userId: newUser.id,
|
||||
menuId,
|
||||
})),
|
||||
skipDuplicates: true, // ✅ Avoid duplicate errors
|
||||
});
|
||||
}
|
||||
|
||||
// ✅ Mark OTP as used
|
||||
await prisma.kodeOtp.update({
|
||||
where: { id: kodeId },
|
||||
data: { isActive: false },
|
||||
});
|
||||
|
||||
// ✅ BUAT SESI untuk user baru (meski isActive = false)
|
||||
// ✅ Create session token
|
||||
const token = await sessionCreate({
|
||||
sessionKey: process.env.BASE_SESSION_KEY!,
|
||||
jwtSecret: process.env.BASE_TOKEN_KEY!,
|
||||
@@ -76,39 +111,39 @@ export async function POST(req: Request) {
|
||||
user: {
|
||||
id: newUser.id,
|
||||
nomor: newUser.nomor,
|
||||
username: newUser.username, // ✅ Pastikan sesuai
|
||||
username: newUser.username,
|
||||
roleId: newUser.roleId,
|
||||
isActive: false,
|
||||
isActive: false, // User belum aktif
|
||||
},
|
||||
invalidatePrevious: false,
|
||||
});
|
||||
|
||||
// Set cookie
|
||||
// ✅ PENTING: Return JSON response (bukan redirect)
|
||||
const response = NextResponse.json({
|
||||
success: true,
|
||||
message: "Registrasi berhasil. Menunggu persetujuan admin.",
|
||||
user: {
|
||||
id: newUser.id,
|
||||
name: newUser.username,
|
||||
roleId: newUser.roleId,
|
||||
isActive: false,
|
||||
},
|
||||
userId: newUser.id,
|
||||
});
|
||||
|
||||
response.cookies.set(process.env.BASE_SESSION_KEY!, token, {
|
||||
// ✅ Set session cookie
|
||||
const cookieName = process.env.BASE_SESSION_KEY || 'session';
|
||||
response.cookies.set(cookieName, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: 'lax',
|
||||
path: "/",
|
||||
maxAge: 30 * 24 * 60 * 60,
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
});
|
||||
|
||||
return response;
|
||||
|
||||
} catch (error) {
|
||||
console.error("❌ Finalize Registration Error:", error);
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Registrasi gagal" },
|
||||
{ success: false, message: "Registrasi gagal. Silakan coba lagi." },
|
||||
{ status: 500 }
|
||||
);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
22
src/app/api/auth/get-flow/route.ts
Normal file
22
src/app/api/auth/get-flow/route.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// app/api/auth/get-flow/route.ts
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// ✅ Next.js 15 syntax
|
||||
const cookieStore = await cookies();
|
||||
const flow = cookieStore.get('auth_flow')?.value || 'login';
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
flow
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Error getting flow cookie:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, flow: 'login' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomOTP } from "../_lib/randomOTP";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
if (req.method !== "POST") {
|
||||
@@ -29,19 +30,37 @@ export async function POST(req: Request) {
|
||||
const isRegistered = !!existingUser;
|
||||
|
||||
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)}`;
|
||||
// ✅ PERBAIKAN: Gunakan format pesan yang lebih sederhana
|
||||
// Hapus karakter khusus yang bisa bikin masalah
|
||||
const waMessage = `Website Desa Darmasaba\nKode verifikasi Anda ${codeOtp}`;
|
||||
|
||||
// // ✅ OPSI 1: Tanpa encoding (coba dulu ini)
|
||||
// const waUrl = `https://wa.wibudev.com/code?nom=${nomor}&text=${waMessage}`;
|
||||
|
||||
// ✅ OPSI 2: Dengan encoding (kalau opsi 1 gagal)
|
||||
const waUrl = `https://wa.wibudev.com/code?nom=${nomor}&text=${encodeURIComponent(waMessage)}`;
|
||||
|
||||
// ✅ OPSI 3: Encoding manual untuk URL-safe (alternatif terakhir)
|
||||
// const encodedMessage = waMessage.replace(/\n/g, '%0A').replace(/ /g, '%20');
|
||||
// const waUrl = `https://wa.wibudev.com/code?nom=${nomor}&text=${encodedMessage}`;
|
||||
|
||||
console.log("🔍 Debug WA URL:", waUrl); // Untuk debugging
|
||||
|
||||
const res = await fetch(waUrl);
|
||||
const sendWa = await res.json();
|
||||
|
||||
console.log("📱 WA Response:", sendWa); // Debug response
|
||||
|
||||
if (sendWa.status !== "success") {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Gagal mengirim OTP via WhatsApp" },
|
||||
{
|
||||
success: false,
|
||||
message: "Gagal mengirim OTP via WhatsApp",
|
||||
debug: sendWa // Tampilkan error detail
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
@@ -50,6 +69,15 @@ export async function POST(req: Request) {
|
||||
data: { nomor, otp: otpNumber, isActive: true },
|
||||
});
|
||||
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set('auth_flow', 'login', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 5, // 5 menit
|
||||
path: '/'
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Kode verifikasi dikirim",
|
||||
@@ -57,16 +85,14 @@ export async function POST(req: Request) {
|
||||
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);
|
||||
console.error("❌ Error Login:", error);
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Terjadi kesalahan saat login" },
|
||||
{ status: 500 }
|
||||
|
||||
@@ -12,6 +12,7 @@ export async function GET() {
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const [dbUser, menuAccess] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
|
||||
55
src/app/api/auth/refresh-session/route.ts
Normal file
55
src/app/api/auth/refresh-session/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { verifySession } from '../_lib/session_verify';
|
||||
import { sessionCreate } from '../_lib/session_create';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const sessionUser = await verifySession();
|
||||
if (!sessionUser) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Unauthorized" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get fresh user data
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: sessionUser.id },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
roleId: true,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "User not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create new session with updated data
|
||||
await sessionCreate({
|
||||
sessionKey: process.env.BASE_SESSION_KEY!,
|
||||
jwtSecret: process.env.BASE_TOKEN_KEY!,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
roleId: user.roleId,
|
||||
isActive: user.isActive,
|
||||
},
|
||||
invalidatePrevious: false, // Keep existing sessions
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error refreshing session:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { randomOTP } from '../_lib/randomOTP'; // pastikan ada
|
||||
import { randomOTP } from '../_lib/randomOTP';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
@@ -36,7 +37,17 @@ export async function POST(req: Request) {
|
||||
data: { nomor, otp: otpNumber, isActive: true }
|
||||
});
|
||||
|
||||
// ✅ Kembalikan kodeId (jangan buat user di sini!)
|
||||
// ✅ Set cookie flow=register (Next.js 15+ syntax)
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set('auth_flow', 'register', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 5, // 5 menit
|
||||
path: '/'
|
||||
});
|
||||
|
||||
// ✅ Kembalikan kodeId
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Kode verifikasi dikirim',
|
||||
|
||||
34
src/app/api/auth/set-flow/route.ts
Normal file
34
src/app/api/auth/set-flow/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// app/api/auth/set-flow/route.ts
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { flow } = await request.json();
|
||||
|
||||
if (!flow || !['login', 'register'].includes(flow)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Invalid flow parameter' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// ✅ Next.js 15 syntax
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set('auth_flow', flow, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 5,
|
||||
path: '/'
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('❌ Error setting flow cookie:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,7 @@ function Page() {
|
||||
variant="light"
|
||||
color="blue"
|
||||
leftSection={<IconDeviceImacCog size={16} />}
|
||||
onClick={() => router.push(`/darmasaba/ppid/daftar-informasi-publik-desa-darmasaba/${item.id}`)}
|
||||
onClick={() => router.push(`/darmasaba/ppid/daftar-informasi-publik/${item.id}`)}
|
||||
>
|
||||
Detail
|
||||
</Button>
|
||||
|
||||
@@ -41,7 +41,7 @@ const state = useProxy(indeksKepuasanState.responden);
|
||||
indeksKepuasanState.jenisKelaminResponden.findMany.load()
|
||||
indeksKepuasanState.pilihanRatingResponden.findMany.load()
|
||||
indeksKepuasanState.kelompokUmurResponden.findMany.load()
|
||||
})
|
||||
},[])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// src/app/admin/(dashboard)/landing-page/APBDes/APBDesProgress.tsx
|
||||
'use client';
|
||||
|
||||
import { Box, Paper, Progress, Stack, Text, Title } from '@mantine/core';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes';
|
||||
import colors from '@/con/colors';
|
||||
|
||||
|
||||
import { Box, Paper, Progress, Stack, Text, Title } from '@mantine/core';
|
||||
import { APBDesData } from './types';
|
||||
|
||||
function formatRupiah(value: number) {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
@@ -17,31 +12,33 @@ function formatRupiah(value: number) {
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function APBDesProgress() {
|
||||
const state = useProxy(apbdes);
|
||||
const data = state.findMany.data || [];
|
||||
interface APBDesProgressProps {
|
||||
apbdesData: APBDesData;
|
||||
}
|
||||
|
||||
// Ambil APBDes pertama (misalnya, jika hanya satu tahun ditampilkan)
|
||||
const apbdesItem = data[0]; // 👈 sesuaikan logika jika ada banyak APBDes
|
||||
|
||||
if (!apbdesItem) {
|
||||
return (
|
||||
<Box py="md" px={{ base: 'md', md: 100 }}>
|
||||
<Text c="dimmed">Belum ada data APBDes untuk ditampilkan.</Text>
|
||||
</Box>
|
||||
);
|
||||
function APBDesProgress({ apbdesData }: APBDesProgressProps) {
|
||||
// Return null if apbdesData is not available yet
|
||||
if (!apbdesData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const items = apbdesItem.items || [];
|
||||
const items = apbdesData.items || [];
|
||||
const sortedItems = [...items].sort((a, b) => a.kode.localeCompare(b.kode));
|
||||
|
||||
// Kelompokkan berdasarkan tipe
|
||||
const pendapatanItems = sortedItems.filter(item => item.tipe === 'pendapatan');
|
||||
const belanjaItems = sortedItems.filter(item => item.tipe === 'belanja');
|
||||
const pembiayaanItems = sortedItems.filter(item => item.tipe === 'pembiayaan'); // jika ada
|
||||
const pembiayaanItems = sortedItems.filter(item => item.tipe === 'pembiayaan');
|
||||
|
||||
// Items without a type (should be filtered out from calculations)
|
||||
const untypedItems = sortedItems.filter(item => !item.tipe);
|
||||
|
||||
if (untypedItems.length > 0) {
|
||||
console.warn(`Found ${untypedItems.length} items without a type. These will be excluded from calculations.`);
|
||||
}
|
||||
|
||||
// Hitung total per kategori
|
||||
const calcTotal = (items: any[]) => {
|
||||
const calcTotal = (items: { anggaran: number; realisasi: number }[]) => {
|
||||
const anggaran = items.reduce((sum, item) => sum + item.anggaran, 0);
|
||||
const realisasi = items.reduce((sum, item) => sum + item.realisasi, 0);
|
||||
const persen = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
|
||||
@@ -50,10 +47,10 @@ function APBDesProgress() {
|
||||
|
||||
const pendapatan = calcTotal(pendapatanItems);
|
||||
const belanja = calcTotal(belanjaItems);
|
||||
const pembiayaan = calcTotal(pembiayaanItems); // bisa kosong
|
||||
const pembiayaan = calcTotal(pembiayaanItems);
|
||||
|
||||
// Render satu progress bar
|
||||
const renderProgress = (label: string, dataset: any) => {
|
||||
const renderProgress = (label: string, dataset: { realisasi: number; anggaran: number; persen: number }) => {
|
||||
const isPembiayaan = label.includes('Pembiayaan');
|
||||
|
||||
return (
|
||||
@@ -71,8 +68,8 @@ function APBDesProgress() {
|
||||
root: { backgroundColor: '#d7e3f1' },
|
||||
section: {
|
||||
backgroundColor: isPembiayaan
|
||||
? 'green' // warna hijau untuk pembiayaan
|
||||
: colors['blue-button'], // biru untuk pendapatan/belanja
|
||||
? 'green'
|
||||
: colors['blue-button'],
|
||||
position: 'relative',
|
||||
'&::after': {
|
||||
content: `'${dataset.persen.toFixed(2)}%'`,
|
||||
@@ -102,7 +99,7 @@ function APBDesProgress() {
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<Title order={4} c={colors['blue-button']} ta="center">
|
||||
Grafik Pelaksanaan APBDes Tahun {apbdesItem.tahun}
|
||||
Grafik Pelaksanaan APBDes Tahun {apbdesData.tahun}
|
||||
</Title>
|
||||
|
||||
<Text ta="center" fw="bold" fz="sm" c="dimmed">
|
||||
@@ -112,97 +109,9 @@ function APBDesProgress() {
|
||||
{renderProgress('Pendapatan Desa', pendapatan)}
|
||||
{renderProgress('Belanja Desa', belanja)}
|
||||
{renderProgress('Pembiayaan Desa', pembiayaan)}
|
||||
{pembiayaanItems.length > 0 && renderProgress('Pembiayaan Desa', pembiayaan)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default APBDesProgress;
|
||||
|
||||
// /* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// 'use client';
|
||||
|
||||
// import { Box, Paper, Stack, Text, Title } from '@mantine/core';
|
||||
// import { BarChart } from '@mantine/charts';
|
||||
// import { useProxy } from 'valtio/utils';
|
||||
// import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes';
|
||||
// import colors from '@/con/colors';
|
||||
|
||||
// function APBDesProgress() {
|
||||
// const state = useProxy(apbdes);
|
||||
// const data = state.findMany.data || [];
|
||||
|
||||
// const apbdesItem = data[0];
|
||||
// if (!apbdesItem) {
|
||||
// return (
|
||||
// <Box py="md" px={{ base: 'md', md: 100 }}>
|
||||
// <Text c="dimmed">Belum ada data APBDes untuk ditampilkan.</Text>
|
||||
// </Box>
|
||||
// );
|
||||
// }
|
||||
|
||||
// const items = apbdesItem.items || [];
|
||||
// const sortedItems = [...items].sort((a, b) => a.kode.localeCompare(b.kode));
|
||||
|
||||
// const pendapatanItems = sortedItems.filter(i => i.tipe === 'pendapatan');
|
||||
// const belanjaItems = sortedItems.filter(i => i.tipe === 'belanja');
|
||||
// const pembiayaanItems = sortedItems.filter(i => i.tipe === 'pembiayaan');
|
||||
|
||||
// const total = (rows: any[]) => {
|
||||
// const anggaran = rows.reduce((s, i) => s + i.anggaran, 0);
|
||||
// const realisasi = rows.reduce((s, i) => s + i.realisasi, 0);
|
||||
// return anggaran === 0 ? 0 : (realisasi / anggaran) * 100;
|
||||
// };
|
||||
|
||||
// const chartData = [
|
||||
// { name: 'Pendapatan', persen: total(pendapatanItems) },
|
||||
// { name: 'Belanja', persen: total(belanjaItems) },
|
||||
// ];
|
||||
|
||||
// if (pembiayaanItems.length > 0) {
|
||||
// chartData.push({ name: 'Pembiayaan', persen: total(pembiayaanItems) });
|
||||
// }
|
||||
|
||||
// return (
|
||||
// <Paper
|
||||
// mx={{ base: 'md', md: 100 }}
|
||||
// p="xl"
|
||||
// radius="md"
|
||||
// shadow="sm"
|
||||
// withBorder
|
||||
// bg={colors['white-1']}
|
||||
// >
|
||||
// <Stack gap="lg">
|
||||
// <Title order={4} c={colors['blue-button']} ta="center">
|
||||
// Grafik Pelaksanaan APBDes Tahun {apbdesItem.tahun}
|
||||
// </Title>
|
||||
|
||||
// <Text ta="center" fw="bold" fz="sm" c="dimmed">
|
||||
// Persentase Realisasi (%) dari Anggaran
|
||||
// </Text>
|
||||
|
||||
// <BarChart
|
||||
// h={200}
|
||||
// data={chartData}
|
||||
// orientation="vertical"
|
||||
// dataKey="name"
|
||||
// barProps={{ radius: 6 }}
|
||||
// series={[
|
||||
// {
|
||||
// name: 'persen',
|
||||
// label: 'Persentase',
|
||||
// color: colors['blue-button'],
|
||||
// },
|
||||
// ]}
|
||||
// yAxisProps={{
|
||||
// domain: [0, 100],
|
||||
// }}
|
||||
// valueFormatter={(v) => `${v.toFixed(1)}%`}
|
||||
// />
|
||||
// </Stack>
|
||||
// </Paper>
|
||||
// );
|
||||
// }
|
||||
|
||||
// export default APBDesProgress;
|
||||
export default APBDesProgress;
|
||||
@@ -1,30 +1,8 @@
|
||||
// src/app/admin/(dashboard)/landing-page/APBDes/APBDesTable.tsx
|
||||
'use client';
|
||||
|
||||
import { Box, Paper, Table, Text, Title, Badge, Group } from '@mantine/core';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes';
|
||||
import colors from '@/con/colors';
|
||||
|
||||
interface APBDesItem {
|
||||
id: string;
|
||||
kode: string;
|
||||
uraian: string;
|
||||
anggaran: number;
|
||||
realisasi: number;
|
||||
selisih: number;
|
||||
persentase: number;
|
||||
level: number;
|
||||
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
|
||||
}
|
||||
|
||||
interface APBDesData {
|
||||
id: string;
|
||||
tahun: number;
|
||||
items: APBDesItem[];
|
||||
image?: { id: string; url: string } | null;
|
||||
file?: { id: string; url: string } | null;
|
||||
}
|
||||
import { APBDesData } from './types';
|
||||
|
||||
// Helper: Format Rupiah, tapi jika 0 → tampilkan '-'
|
||||
function formatRupiahOrEmpty(value: number): string {
|
||||
@@ -51,22 +29,12 @@ function getIndent(level: number) {
|
||||
};
|
||||
}
|
||||
|
||||
function APBDesTable() {
|
||||
const state = useProxy(apbdes);
|
||||
const data = state.findMany.data || [];
|
||||
interface APBDesTableProps {
|
||||
apbdesData: APBDesData;
|
||||
}
|
||||
|
||||
// Get the first APBDes item
|
||||
const apbdesItem = data[0] as unknown as APBDesData | undefined;
|
||||
|
||||
if (!apbdesItem) {
|
||||
return (
|
||||
<Box py="md" px={{ base: 'md', md: 100 }}>
|
||||
<Text c="dimmed">Belum ada data APBDes untuk ditampilkan.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const items = Array.isArray(apbdesItem.items) ? apbdesItem.items : [];
|
||||
function APBDesTable({ apbdesData }: APBDesTableProps) {
|
||||
const items = Array.isArray(apbdesData.items) ? apbdesData.items : [];
|
||||
const sortedItems = [...items].sort((a, b) => a.kode.localeCompare(b.kode));
|
||||
|
||||
// Calculate totals
|
||||
@@ -76,13 +44,13 @@ function APBDesTable() {
|
||||
const totalPersentase = totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0;
|
||||
|
||||
return (
|
||||
<Box py="md" px={{ base: 'md', md: 100 }}>
|
||||
<Box pt={"xs"} pb="md" px={{ base: 'md', md: 100 }}>
|
||||
<Title order={4} c={colors['blue-button']} mb="sm">
|
||||
Rincian APBDes Tahun {apbdesItem.tahun}
|
||||
Rincian APBDes Tahun {apbdesData.tahun}
|
||||
</Title>
|
||||
|
||||
<Paper withBorder radius="md" shadow="xs" p="md">
|
||||
<Box style={{overflowY: 'auto' }}>
|
||||
<Box style={{ overflowY: 'auto' }}>
|
||||
<Table withColumnBorders highlightOnHover>
|
||||
<Table.Thead bg="#2c5f78">
|
||||
<Table.Tr>
|
||||
@@ -109,9 +77,7 @@ function APBDesTable() {
|
||||
<Table.Td style={getIndent(item.level)}>
|
||||
<Group gap="xs" align="flex-start">
|
||||
<Text fw={item.level === 1 ? 'bold' : 'normal'}>{item.kode}</Text>
|
||||
<Text fz="sm" >
|
||||
{item.uraian}
|
||||
</Text>
|
||||
<Text fz="sm">{item.uraian}</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">{formatRupiahOrEmpty(item.anggaran)}</Table.Td>
|
||||
|
||||
42
src/app/darmasaba/(tambahan)/apbdes/lib/types.ts
Normal file
42
src/app/darmasaba/(tambahan)/apbdes/lib/types.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
export type APBDesTipe = 'pendapatan' | 'belanja' | 'pembiayaan';
|
||||
|
||||
export function isAPBDesTipe(tipe: string | null | undefined): tipe is APBDesTipe {
|
||||
return tipe === 'pendapatan' || tipe === 'belanja' || tipe === 'pembiayaan';
|
||||
}
|
||||
|
||||
export interface APBDesItem {
|
||||
id: string;
|
||||
kode: string;
|
||||
uraian: string;
|
||||
anggaran: number;
|
||||
realisasi: number;
|
||||
selisih: number;
|
||||
persentase: number;
|
||||
level: number;
|
||||
tipe?: APBDesTipe | null;
|
||||
// Additional fields from API
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
deletedAt?: Date | null;
|
||||
isActive?: boolean;
|
||||
apbdesId?: string;
|
||||
}
|
||||
|
||||
export interface APBDesData {
|
||||
id: string;
|
||||
tahun: number | null;
|
||||
items: APBDesItem[];
|
||||
image?: { id: string; url: string } | null;
|
||||
file?: { id: string; url: string } | null;
|
||||
}
|
||||
|
||||
export function transformAPBDesData(data: any): APBDesData {
|
||||
return {
|
||||
...data,
|
||||
items: data.items.map((item: any) => ({
|
||||
...item,
|
||||
tipe: isAPBDesTipe(item.tipe) ? item.tipe : null
|
||||
}))
|
||||
};
|
||||
}
|
||||
@@ -4,19 +4,21 @@
|
||||
import PendapatanAsliDesa from '@/app/admin/(dashboard)/_state/ekonomi/PADesa'
|
||||
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes'
|
||||
import colors from '@/con/colors'
|
||||
import { ActionIcon, BackgroundImage, Box, Center, Container, Group, Loader, SimpleGrid, Stack, Text, Title } from '@mantine/core'
|
||||
import { ActionIcon, BackgroundImage, Box, Center, Container, Group, Loader, Select, SimpleGrid, Stack, Text, Title } from '@mantine/core'
|
||||
import { IconDownload } from '@tabler/icons-react'
|
||||
import { Link } from 'next-view-transitions'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useProxy } from 'valtio/utils'
|
||||
import BackButton from '../../(pages)/desa/layanan/_com/BackButto'
|
||||
import APBDesProgress from './lib/apbDesaProgress'
|
||||
import APBDesTable from './lib/apbDesaTable'
|
||||
import APBDesProgress from './lib/apbDesaProgress'
|
||||
import { transformAPBDesData } from './lib/types'
|
||||
|
||||
function Page() {
|
||||
const state = useProxy(apbdes)
|
||||
const paDesaState = useProxy(PendapatanAsliDesa.ApbDesa)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectedYear, setSelectedYear] = useState<string | null>(null)
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
@@ -34,6 +36,23 @@ function Page() {
|
||||
|
||||
const dataAPBDes = state.findMany.data || []
|
||||
|
||||
// Buat daftar tahun unik dari data
|
||||
const years = Array.from(new Set(dataAPBDes.map((item: any) => item.tahun)))
|
||||
.sort((a, b) => b - a) // urutkan descending
|
||||
.map(year => ({ value: year.toString(), label: `Tahun ${year}` }))
|
||||
|
||||
// Pilih tahun pertama sebagai default jika belum ada yang dipilih
|
||||
useEffect(() => {
|
||||
if (years.length > 0 && !selectedYear) {
|
||||
setSelectedYear(years[0].value)
|
||||
}
|
||||
}, [years, selectedYear])
|
||||
|
||||
// Transform and filter data based on selected year
|
||||
const currentApbdes = dataAPBDes.length > 0
|
||||
? transformAPBDesData(dataAPBDes.find(item => item?.tahun?.toString() === selectedYear) || dataAPBDes[0])
|
||||
: null
|
||||
|
||||
return (
|
||||
<Stack pos="relative" bg={colors.Bg} py="xl" gap={32}>
|
||||
<Box px={{ base: 'md', md: 100 }}>
|
||||
@@ -94,8 +113,31 @@ function Page() {
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
<APBDesTable />
|
||||
<APBDesProgress />
|
||||
{/* 🔥 COMBOBOX UNTUK PILIH TAHUN */}
|
||||
<Box px={{ base: 'md', md: 100 }}>
|
||||
<Select
|
||||
label="Pilih Tahun APBDes"
|
||||
placeholder="Pilih tahun"
|
||||
value={selectedYear}
|
||||
onChange={setSelectedYear}
|
||||
data={years}
|
||||
w={{ base: '100%', sm: 200 }}
|
||||
searchable
|
||||
clearable
|
||||
nothingFoundMessage="Tidak ada tahun tersedia"
|
||||
/>
|
||||
</Box>
|
||||
{/* ❗ Pass currentApbdes ke komponen anak */}
|
||||
{currentApbdes ? (
|
||||
<>
|
||||
<APBDesTable apbdesData={currentApbdes} />
|
||||
<APBDesProgress apbdesData={currentApbdes} />
|
||||
</>
|
||||
) : (
|
||||
<Box px={{ base: 'md', md: 100 }} py="md">
|
||||
<Text c="dimmed">Tidak ada data APBDes untuk tahun yang dipilih.</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/* 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 { transformAPBDesData } from '@/app/darmasaba/(tambahan)/apbdes/lib/types'
|
||||
import colors from '@/con/colors'
|
||||
import { ActionIcon, BackgroundImage, Box, Button, Center, Flex, Group, Loader, SimpleGrid, Stack, Text } from '@mantine/core'
|
||||
import { ActionIcon, BackgroundImage, Box, Button, Center, Flex, Group, Loader, Select, SimpleGrid, Stack, Text } from '@mantine/core'
|
||||
import { IconDownload } from '@tabler/icons-react'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
@@ -12,6 +14,7 @@ import { useProxy } from 'valtio/utils'
|
||||
function Apbdes() {
|
||||
const state = useProxy(apbdes)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectedYear, setSelectedYear] = useState<string | null>(null)
|
||||
|
||||
const textHeading = {
|
||||
title: 'APBDes',
|
||||
@@ -32,6 +35,24 @@ function Apbdes() {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const dataAPBDes = state.findMany.data || []
|
||||
|
||||
const years = Array.from(new Set(dataAPBDes.map((item: any) => item.tahun)))
|
||||
.sort((a, b) => b - a) // urutkan descending
|
||||
.map(year => ({ value: year.toString(), label: `Tahun ${year}` }))
|
||||
|
||||
// Pilih tahun pertama sebagai default jika belum ada yang dipilih
|
||||
useEffect(() => {
|
||||
if (years.length > 0 && !selectedYear) {
|
||||
setSelectedYear(years[0].value)
|
||||
}
|
||||
}, [years, selectedYear])
|
||||
|
||||
// Transform and filter data based on selected year
|
||||
const currentApbdes = dataAPBDes.length > 0
|
||||
? transformAPBDesData(dataAPBDes.find(item => item?.tahun?.toString() === selectedYear) || dataAPBDes[0])
|
||||
: null
|
||||
|
||||
const data = (state.findMany.data || []).slice(0, 3)
|
||||
|
||||
return (
|
||||
@@ -60,8 +81,30 @@ function Apbdes() {
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Chart */}
|
||||
<APBDesProgress />
|
||||
{/* 🔥 COMBOBOX UNTUK PILIH TAHUN */}
|
||||
<Box px={{ base: 'md', md: 100 }}>
|
||||
<Select
|
||||
label="Pilih Tahun APBDes"
|
||||
placeholder="Pilih tahun"
|
||||
value={selectedYear}
|
||||
onChange={setSelectedYear}
|
||||
data={years}
|
||||
w={{ base: '100%', sm: 200 }}
|
||||
searchable
|
||||
clearable
|
||||
nothingFoundMessage="Tidak ada tahun tersedia"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{currentApbdes ? (
|
||||
<>
|
||||
<APBDesProgress apbdesData={currentApbdes} />
|
||||
</>
|
||||
) : (
|
||||
<Box px={{ base: 'md', md: 100 }} py="md">
|
||||
<Text c="dimmed">Tidak ada data APBDes untuk tahun yang dipilih.</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<SimpleGrid mx={{ base: 'md', md: 100 }} cols={{ base: 1, sm: 3 }} spacing="lg" pb={"xl"}>
|
||||
{loading ? (
|
||||
@@ -129,7 +172,7 @@ function Apbdes() {
|
||||
)}
|
||||
</SimpleGrid>
|
||||
|
||||
|
||||
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ function Kepuasan() {
|
||||
indeksKepuasanState.jenisKelaminResponden.findMany.load()
|
||||
indeksKepuasanState.pilihanRatingResponden.findMany.load()
|
||||
indeksKepuasanState.kelompokUmurResponden.findMany.load()
|
||||
})
|
||||
},[])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
|
||||
@@ -59,6 +59,35 @@ const getWorkStatus = (day: string, currentTime: string): { status: string; mess
|
||||
: { status: "Tutup", message: "08:00 - 17:00" };
|
||||
};
|
||||
|
||||
// Skeleton component untuk Social Media
|
||||
const SosmedSkeleton = () => (
|
||||
<Flex gap="md" justify="center" wrap="wrap">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} height={56} width={56} circle />
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
// Skeleton component untuk Profile
|
||||
const ProfileSkeleton = () => (
|
||||
<Card
|
||||
radius="xl"
|
||||
bg={colors.grey[1]}
|
||||
p="lg"
|
||||
shadow="xl"
|
||||
w={{ base: "100%", md: "35%" }}
|
||||
style={{ height: "fit-content" }}
|
||||
>
|
||||
<Stack gap="lg" align="center">
|
||||
<Skeleton height={300} width="100%" radius="lg" />
|
||||
<Stack gap="xs" w="100%" align="center">
|
||||
<Skeleton height={20} width="60%" />
|
||||
<Skeleton height={32} width="80%" />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
|
||||
function LandingPage() {
|
||||
const [socialMedia, setSocialMedia] = useState<
|
||||
Prisma.MediaSosialGetPayload<{ include: { image: true } }>[]
|
||||
@@ -66,9 +95,8 @@ function LandingPage() {
|
||||
const [profile, setProfile] = useState<
|
||||
Prisma.PejabatDesaGetPayload<{ include: { image: true } }> | null
|
||||
>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
|
||||
const [isLoadingSosmed, setIsLoadingSosmed] = useState(true);
|
||||
const [isLoadingProfile, setIsLoadingProfile] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSocialMedia = async () => {
|
||||
@@ -86,7 +114,7 @@ function LandingPage() {
|
||||
} catch {
|
||||
setSocialMedia([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsLoadingSosmed(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -98,6 +126,8 @@ function LandingPage() {
|
||||
setProfile(result.data || null);
|
||||
} catch {
|
||||
setProfile(null);
|
||||
} finally {
|
||||
setIsLoadingProfile(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -189,8 +219,8 @@ function LandingPage() {
|
||||
|
||||
<ModuleView />
|
||||
|
||||
{isLoading ? (
|
||||
<Skeleton height={32} width="100%" />
|
||||
{isLoadingSosmed ? (
|
||||
<SosmedSkeleton />
|
||||
) : socialMedia.length > 0 ? (
|
||||
<SosmedView data={socialMedia} />
|
||||
) : (
|
||||
@@ -207,19 +237,27 @@ function LandingPage() {
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
{isLoading ? (
|
||||
<Skeleton height={300} width="100%" radius="lg" />
|
||||
{isLoadingProfile ? (
|
||||
<ProfileSkeleton />
|
||||
) : profile ? (
|
||||
<ProfileView data={profile} />
|
||||
) : (
|
||||
<Center w="100%">
|
||||
<Text c="dimmed">Informasi profil belum tersedia</Text>
|
||||
</Center>
|
||||
<Card
|
||||
radius="xl"
|
||||
bg={colors.grey[1]}
|
||||
p="lg"
|
||||
shadow="xl"
|
||||
w={{ base: "100%", md: "35%" }}
|
||||
style={{ height: "fit-content" }}
|
||||
>
|
||||
<Center h={300}>
|
||||
<Text c="dimmed">Informasi profil belum tersedia</Text>
|
||||
</Center>
|
||||
</Card>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default LandingPage;
|
||||
export default LandingPage;
|
||||
173
src/app/darmasaba/_com/term-of-service.html
Normal file
173
src/app/darmasaba/_com/term-of-service.html
Normal file
@@ -0,0 +1,173 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Syarat & Ketentuan Penggunaan HIPMI Badung Connect</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #1e293b;
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 20px;
|
||||
background-color: white;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #1e3a5f;
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #1e3a5f;
|
||||
margin-top: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1rem;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin-left: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 0.5rem;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin-bottom: 2rem;
|
||||
padding: 1.25rem;
|
||||
background-color: #f1f5f9;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #1e3a5f;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 3rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.25rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
h1 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin-left: 1.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Syarat & Ketentuan Penggunaan HIPMI Badung Connect</h1>
|
||||
|
||||
<div class="intro">
|
||||
<p>Dengan menggunakan aplikasi <strong>HIPMI Badung Connect</strong> ("Aplikasi"), Anda setuju untuk mematuhi dan terikat oleh syarat dan ketentuan berikut. Jika Anda tidak setuju dengan ketentuan ini, harap jangan gunakan Aplikasi.</p>
|
||||
</div>
|
||||
|
||||
<h2>1. Definisi</h2>
|
||||
<p><strong>HIPMI Badung Connect</strong> adalah platform digital resmi untuk anggota Himpunan Pengusaha Muda Indonesia (HIPMI) Kabupaten Badung, yang bertujuan memfasilitasi jaringan, kolaborasi, dan pertumbuhan bisnis para pengusaha muda.</p>
|
||||
|
||||
<h2>2. Larangan Konten Tidak Pantas</h2>
|
||||
<p>Anda <strong>dilarang keras</strong> memposting, mengirim, membagikan, atau mengunggah konten apa pun yang mengandung:</p>
|
||||
<ul>
|
||||
<li>Ujaran kebencian, diskriminasi, atau konten SARA (Suku, Agama, Ras, Antar-golongan)</li>
|
||||
<li>Pornografi, konten seksual eksplisit, atau gambar tidak senonoh</li>
|
||||
<li>Ancaman, pelecehan, bullying, atau perilaku melecehkan</li>
|
||||
<li>Informasi palsu, hoaks, spam, atau konten menyesatkan</li>
|
||||
<li>Konten ilegal, melanggar hukum, atau melanggar hak kekayaan intelektual pihak lain</li>
|
||||
<li>Promosi narkoba, perjudian, atau aktivitas ilegal lainnya</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. Tanggung Jawab Pengguna</h2>
|
||||
<p>Anda bertanggung jawab penuh atas setiap konten yang Anda unggah atau bagikan melalui fitur-fitur berikut:</p>
|
||||
<ul>
|
||||
<li>Profil (bio, foto, portofolio)</li>
|
||||
<li>Forum diskusi</li>
|
||||
<li>Chat pribadi atau grup</li>
|
||||
<li>Lowongan kerja, investasi, dan donasi</li>
|
||||
</ul>
|
||||
<p>Konten yang melanggar ketentuan ini dapat dihapus kapan saja tanpa pemberitahuan.</p>
|
||||
|
||||
<h2>4. Tindakan terhadap Pelanggaran</h2>
|
||||
<p>Jika kami menerima laporan atau menemukan konten yang melanggar ketentuan ini, kami akan:</p>
|
||||
<ul>
|
||||
<li>Segera menghapus konten tersebut</li>
|
||||
<li>Memberikan peringatan atau memblokir akun pengguna</li>
|
||||
<li>Dalam kasus berat, melaporkan ke pihak berwajib sesuai hukum yang berlaku</li>
|
||||
</ul>
|
||||
<p>Tim kami berkomitmen untuk menanggapi laporan konten tidak pantas <strong>dalam waktu 24 jam</strong>.</p>
|
||||
|
||||
<h2>5. Mekanisme Pelaporan</h2>
|
||||
<p>Anda dapat melaporkan konten atau pengguna yang mencurigakan melalui:</p>
|
||||
<ul>
|
||||
<li>Tombol <strong>"Laporkan"</strong> di setiap posting forum atau pesan chat</li>
|
||||
<li>Tombol <strong>"Blokir Pengguna"</strong> di profil pengguna</li>
|
||||
</ul>
|
||||
<p>Setiap laporan akan ditangani secara rahasia dan segera.</p>
|
||||
|
||||
<h2>6. Perubahan Ketentuan</h2>
|
||||
<p>Kami berhak memperbarui Syarat & Ketentuan ini sewaktu-waktu. Versi terbaru akan dipublikasikan di halaman ini dengan tanggal revisi yang diperbarui.</p>
|
||||
|
||||
<h2>7. Kontak</h2>
|
||||
<p>Jika Anda memiliki pertanyaan tentang ketentuan ini, silakan hubungi kami di:<br>
|
||||
<strong>bip.baliinteraktifperkasa@gmail.com</strong></p>
|
||||
|
||||
<div class="footer">
|
||||
© 2025 Bali Interaktif Perkasa. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
'use client'
|
||||
import DesaAntiKorupsi from "@/app/darmasaba/_com/main-page/desaantikorupsi";
|
||||
import Kepuasan from "@/app/darmasaba/_com/main-page/kepuasan";
|
||||
@@ -13,32 +14,34 @@ import Apbdes from "./_com/main-page/apbdes";
|
||||
import Prestasi from "./_com/main-page/prestasi";
|
||||
import ScrollToTopButton from "./_com/scrollToTopButton";
|
||||
|
||||
import NewsReaderLanding from "./_com/NewsReaderalanding";
|
||||
import ModernNewsNotification from "./_com/ModernNeewsNotification";
|
||||
import { useMemo } from "react";
|
||||
import { useProxy } from "valtio/utils";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useSnapshot } from "valtio";
|
||||
import stateDashboardBerita from "../admin/(dashboard)/_state/desa/berita";
|
||||
import stateDesaPengumuman from "../admin/(dashboard)/_state/desa/pengumuman";
|
||||
import { useEffect } from "react";
|
||||
import ModernNewsNotification from "./_com/ModernNeewsNotification";
|
||||
import NewsReaderLanding from "./_com/NewsReaderalanding";
|
||||
|
||||
|
||||
export default function Page() {
|
||||
const featured = useProxy(stateDashboardBerita.berita.findFirst);
|
||||
const snap1 = useSnapshot(stateDashboardBerita.berita.findFirst);
|
||||
const snap2 = useSnapshot(stateDesaPengumuman.pengumuman.findFirst);
|
||||
|
||||
const featured = snap1;
|
||||
const pengumuman = snap2;
|
||||
const loadingFeatured = featured.loading;
|
||||
const pengumuman = useProxy(stateDesaPengumuman.pengumuman.findFirst);
|
||||
const loadingPengumuman = pengumuman.loading;
|
||||
|
||||
useEffect(() => {
|
||||
if (!featured.data && !loadingFeatured) {
|
||||
stateDashboardBerita.berita.findFirst.load();
|
||||
}
|
||||
}, [featured.data, loadingFeatured]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pengumuman.data && !loadingPengumuman) {
|
||||
stateDesaPengumuman.pengumuman.findFirst.load();
|
||||
}
|
||||
}, [pengumuman.data, loadingPengumuman]);
|
||||
}, []);
|
||||
|
||||
|
||||
const newsData = useMemo(() => {
|
||||
|
||||
@@ -1,31 +1,105 @@
|
||||
/* styles/globals.css */
|
||||
|
||||
/* ===================================
|
||||
1. IMPORT CSS LIBRARIES
|
||||
=================================== */
|
||||
@import "@mantine/carousel/styles.css";
|
||||
@import "@mantine/dropzone/styles.css";
|
||||
@import "@mantine/charts/styles.css";
|
||||
@import "@mantine/dates/styles.css";
|
||||
@import "@mantine/tiptap/styles.css";
|
||||
@import "animate.css";
|
||||
@import "react-simple-toasts/dist/style.css";
|
||||
@import "react-simple-toasts/dist/theme/dark.css";
|
||||
@import "primereact/resources/themes/lara-light-blue/theme.css";
|
||||
@import "primereact/resources/primereact.min.css";
|
||||
@import "primeicons/primeicons.css";
|
||||
|
||||
/* ===================================
|
||||
2. FONT FACE - OPTIMIZED
|
||||
=================================== */
|
||||
@font-face {
|
||||
font-family: 'San Francisco';
|
||||
src: url('/assets/fonts/font.otf') format('opentype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap; /* ✅ TAMBAHKAN INI - Penting untuk PageSpeed! */
|
||||
}
|
||||
|
||||
/* ===================================
|
||||
3. RESET & BASE STYLES
|
||||
=================================== */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%; /* Prevent font scaling in landscape */
|
||||
-moz-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'San Francisco', -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||
Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* ===================================
|
||||
4. GLASS EFFECTS - OPTIMIZED
|
||||
=================================== */
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
-webkit-backdrop-filter: blur(40px);
|
||||
backdrop-filter: blur(40px);
|
||||
-webkit-backdrop-filter: blur(40px);
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
will-change: transform; /* ✅ Hardware acceleration */
|
||||
}
|
||||
|
||||
.glass2 {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
-webkit-backdrop-filter: blur(40px);
|
||||
backdrop-filter: blur(40px);
|
||||
-webkit-backdrop-filter: blur(40px);
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
will-change: transform; /* ✅ Hardware acceleration */
|
||||
}
|
||||
|
||||
.glass3 {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
-webkit-backdrop-filter: blur(40px);
|
||||
backdrop-filter: blur(40px);
|
||||
-webkit-backdrop-filter: blur(40px);
|
||||
will-change: transform; /* ✅ Hardware acceleration */
|
||||
}
|
||||
|
||||
/* ===================================
|
||||
5. PERFORMANCE OPTIMIZATION
|
||||
=================================== */
|
||||
img,
|
||||
picture,
|
||||
video,
|
||||
canvas,
|
||||
svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Reduce motion for accessibility */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,5 @@
|
||||
// Import styles of packages that you've installed.
|
||||
// All packages except `@mantine/hooks` require styles imports
|
||||
import "@mantine/carousel/styles.css";
|
||||
import "@mantine/core/styles.css";
|
||||
import '@mantine/dropzone/styles.css';
|
||||
import "animate.css";
|
||||
import 'react-simple-toasts/dist/style.css';
|
||||
import 'react-simple-toasts/dist/theme/dark.css';
|
||||
import "./globals.css";
|
||||
import '@mantine/charts/styles.css';
|
||||
import '@mantine/dates/styles.css';
|
||||
import '@mantine/tiptap/styles.css';
|
||||
import "primereact/resources/themes/lara-light-blue/theme.css";
|
||||
import "primereact/resources/primereact.min.css";
|
||||
import "primeicons/primeicons.css";
|
||||
|
||||
|
||||
|
||||
import LoadDataFirstClient from "@/app/darmasaba/_com/LoadDataFirstClient";
|
||||
import {
|
||||
@@ -23,19 +8,83 @@ import {
|
||||
createTheme,
|
||||
mantineHtmlProps,
|
||||
} from "@mantine/core";
|
||||
import { Metadata, Viewport } from "next";
|
||||
import { ViewTransitions } from "next-view-transitions";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
|
||||
export const metadata = {
|
||||
title: "Desa Darmasaba",
|
||||
description: "Desa Darmasaba Kabupaten Badung",
|
||||
// ✅ Pisahkan viewport ke export tersendiri
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 5,
|
||||
};
|
||||
|
||||
|
||||
export const metadata: Metadata = {
|
||||
// ✅ Tambahkan metadataBase
|
||||
metadataBase: new URL("https://cld-dkr-staging-desa-darmasaba.wibudev.com"),
|
||||
|
||||
title: {
|
||||
default: "Desa Darmasaba",
|
||||
template: "%s | Desa Darmasaba",
|
||||
},
|
||||
description: "Website resmi Desa Darmasaba, Kabupaten Badung, Bali. Informasi layanan publik, berita, dan profil desa.",
|
||||
// ❌ HAPUS viewport dari sini
|
||||
keywords: [
|
||||
"desa darmasaba",
|
||||
"darmasaba",
|
||||
"badung",
|
||||
"bali",
|
||||
"desa",
|
||||
"pemerintah desa",
|
||||
"layanan publik",
|
||||
"abang batan desa",
|
||||
],
|
||||
authors: [{ name: "Pemerintah Desa Darmasaba" }],
|
||||
creator: "Desa Darmasaba",
|
||||
publisher: "Desa Darmasaba",
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-video-preview": -1,
|
||||
"max-image-preview": "large",
|
||||
"max-snippet": -1,
|
||||
},
|
||||
},
|
||||
icons: {
|
||||
icon: "/assets/images/darmasaba-icon.png",
|
||||
apple: "/assets/images/darmasaba-icon.png",
|
||||
},
|
||||
manifest: "/manifest.json",
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "id_ID",
|
||||
url: "https://cld-dkr-staging-desa-darmasaba.wibudev.com",
|
||||
siteName: "Desa Darmasaba",
|
||||
title: "Desa Darmasaba - Kabupaten Badung, Bali",
|
||||
description: "Website resmi Desa Darmasaba, Kabupaten Badung, Bali. Informasi layanan publik, berita, dan profil desa.",
|
||||
images: [
|
||||
{
|
||||
url: "/assets/images/darmasaba-icon.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Desa Darmasaba",
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "government",
|
||||
other: {
|
||||
"msapplication-TileColor": "#ffffff",
|
||||
"theme-color": "#ffffff",
|
||||
},
|
||||
};
|
||||
|
||||
const theme = createTheme({
|
||||
fontFamily:
|
||||
"San Francisco, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif",
|
||||
fontFamilyMonospace:
|
||||
"SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace",
|
||||
fontFamily: "San Francisco, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif",
|
||||
fontFamilyMonospace: "SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||
headings: { fontFamily: "San Francisco, sans-serif" },
|
||||
});
|
||||
|
||||
@@ -46,26 +95,23 @@ export default function RootLayout({
|
||||
}) {
|
||||
return (
|
||||
<ViewTransitions>
|
||||
<html lang="en" {...mantineHtmlProps}>
|
||||
<html lang="id" {...mantineHtmlProps}>
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<ColorSchemeScript />
|
||||
<link
|
||||
rel="icon"
|
||||
href="/assets/images/darmasaba-icon.png"
|
||||
sizes="any"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<MantineProvider theme={theme}>
|
||||
{children}
|
||||
|
||||
<LoadDataFirstClient />
|
||||
<ToastContainer
|
||||
position="bottom-center"
|
||||
hideProgressBar
|
||||
style={{ zIndex: 9999 }}
|
||||
/>
|
||||
</MantineProvider>
|
||||
<ToastContainer position="bottom-center" hideProgressBar style={{
|
||||
zIndex: 9999
|
||||
}} />
|
||||
</body>
|
||||
<LoadDataFirstClient />
|
||||
</html>
|
||||
</ViewTransitions>
|
||||
);
|
||||
}
|
||||
}
|
||||
102
src/app/terms-of-service/page.tsx
Normal file
102
src/app/terms-of-service/page.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Box, Container, Divider, List, ListItem, Paper, Stack, Text, Title } from '@mantine/core';
|
||||
import React from 'react';
|
||||
|
||||
function Page() {
|
||||
return (
|
||||
<Container size="md" py={40}>
|
||||
<Stack gap="xl">
|
||||
<Title order={1} size="h1" fw={700} c="blue.9">
|
||||
Syarat & Ketentuan Penggunaan Admin Desa Darmasaba
|
||||
</Title>
|
||||
|
||||
<Paper p="lg" radius="md" withBorder bg="gray.0" style={{ borderLeft: '4px solid #1e3a5f' }}>
|
||||
<Text c="gray.8">
|
||||
Dengan menggunakan website <Text component="span" fw={600}>Admin Desa Darmasaba</Text> ("Website"),
|
||||
Anda setuju untuk mematuhi dan terikat oleh syarat dan ketentuan berikut. Jika Anda tidak setuju
|
||||
dengan ketentuan ini, harap jangan gunakan Website.
|
||||
</Text>
|
||||
</Paper>
|
||||
|
||||
<Box>
|
||||
<Title order={2} size="h2" fw={700} c="blue.9" mb="md">
|
||||
1. Definisi
|
||||
</Title>
|
||||
<Text c="gray.7">
|
||||
<Text component="span" fw={600}>Admin Desa Darmasaba</Text> adalah website resmi untuk Admin Desa Darmasaba, yang bertujuan
|
||||
menambahkan, menghapus, dan mengedit konten desa ke dalam website.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Title order={2} size="h2" fw={700} c="blue.9" mb="md">
|
||||
2. Larangan Konten Tidak Pantas
|
||||
</Title>
|
||||
<Text c="gray.7" mb="md">
|
||||
Anda <Text component="span" fw={600}>dilarang keras</Text> menambahkan, menghapus, dan mengedit konten desa apa pun yang mengandung:
|
||||
</Text>
|
||||
<List spacing="xs" c="gray.7">
|
||||
<ListItem>Ujaran kebencian, diskriminasi, atau konten SARA (Suku, Agama, Ras, Antar-golongan)</ListItem>
|
||||
<ListItem>Pornografi, konten seksual eksplisit, atau gambar tidak senonoh</ListItem>
|
||||
<ListItem>Ancaman, pelecehan, bullying, atau perilaku melecehkan</ListItem>
|
||||
<ListItem>Informasi palsu, hoaks, spam, atau konten menyesatkan</ListItem>
|
||||
<ListItem>Konten ilegal, melanggar hukum, atau melanggar hak kekayaan intelektual pihak lain</ListItem>
|
||||
<ListItem>Promosi narkoba, perjudian, atau aktivitas ilegal lainnya</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Title order={2} size="h2" fw={700} c="blue.9" mb="md">
|
||||
3. Tanggung Jawab Pengguna
|
||||
</Title>
|
||||
<List spacing="xs" c="gray.7">
|
||||
<ListItem>Anda bertanggung jawab penuh atas setiap konten yang Anda unggah atau bagikan.</ListItem>
|
||||
<ListItem>Konten yang melanggar ketentuan ini dapat dihapus kapan saja tanpa pemberitahuan.</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Title order={2} size="h2" fw={700} c="blue.9" mb="md">
|
||||
4. Tindakan terhadap Pelanggaran
|
||||
</Title>
|
||||
<Text c="gray.7" mb="md">
|
||||
Jika kami menerima laporan atau menemukan konten yang melanggar ketentuan ini, kami akan:
|
||||
</Text>
|
||||
<List spacing="xs" c="gray.7">
|
||||
<ListItem>Segera menghapus konten tersebut</ListItem>
|
||||
<ListItem>Menghapus akun pengguna</ListItem>
|
||||
<ListItem>Dalam kasus berat, melaporkan ke pihak berwajib sesuai hukum yang berlaku</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
<Box>
|
||||
<Title order={2} size="h2" fw={700} c="blue.9" mb="md">
|
||||
5. Perubahan Ketentuan
|
||||
</Title>
|
||||
<Text c="gray.7">
|
||||
Kami berhak memperbarui Syarat & Ketentuan ini sewaktu-waktu. Versi terbaru akan dipublikasikan di
|
||||
halaman ini dengan tanggal revisi yang diperbarui.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Title order={2} size="h2" fw={700} c="blue.9" mb="md">
|
||||
6. Kontak
|
||||
</Title>
|
||||
<Text c="gray.7">
|
||||
Jika Anda memiliki pertanyaan tentang ketentuan ini, silakan hubungi kami di:
|
||||
</Text>
|
||||
<Text c="gray.7" fw={600} mt="xs">
|
||||
bip.baliinteraktifperkasa@gmail.com
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Divider my="xl" />
|
||||
|
||||
<Text ta="center" c="gray.6" size="sm">
|
||||
© 2025 Bali Interaktif Perkasa. All rights reserved.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default Page;
|
||||
@@ -2,14 +2,24 @@
|
||||
'use client';
|
||||
|
||||
import colors from '@/con/colors';
|
||||
import { Center, Loader, Paper, Stack, Text, Title } from '@mantine/core';
|
||||
import {
|
||||
Button,
|
||||
Center,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { authStore } from '@/store/authStore'; // ✅ integrasi authStore
|
||||
|
||||
async function fetchUser() {
|
||||
const res = await fetch('/api/auth/me');
|
||||
const res = await fetch('/api/auth/me', {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!res.ok) {
|
||||
// Jangan throw error — biarkan handle status code
|
||||
const text = await res.text();
|
||||
throw new Error(`HTTP ${res.status}: ${text}`);
|
||||
}
|
||||
@@ -21,10 +31,15 @@ export default function WaitingRoom() {
|
||||
const [user, setUser] = useState<any>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isRedirecting, setIsRedirecting] = useState(false);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const MAX_RETRIES = 2;
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
const interval = setInterval(async () => {
|
||||
let interval: ReturnType<typeof setInterval>;
|
||||
|
||||
const poll = async () => {
|
||||
if (isRedirecting || !isMounted) return;
|
||||
|
||||
try {
|
||||
@@ -34,63 +49,110 @@ export default function WaitingRoom() {
|
||||
const currentUser = data.user;
|
||||
setUser(currentUser);
|
||||
|
||||
// ✅ Periksa isActive dan redirect
|
||||
// ✅ Update authStore
|
||||
if (currentUser) {
|
||||
authStore.setUser({
|
||||
id: currentUser.id,
|
||||
name: currentUser.name,
|
||||
roleId: Number(currentUser.roleId),
|
||||
menuIds: currentUser.menuIds || null,
|
||||
});
|
||||
}
|
||||
|
||||
// In the poll function
|
||||
if (currentUser?.isActive === true) {
|
||||
setIsRedirecting(true);
|
||||
clearInterval(interval);
|
||||
|
||||
// ✅ roleId adalah STRING → gunakan string literal
|
||||
let redirectPath = '/admin';
|
||||
// Update authStore with the current user data
|
||||
authStore.setUser({
|
||||
id: currentUser.id,
|
||||
name: currentUser.name || 'User',
|
||||
roleId: Number(currentUser.roleId),
|
||||
menuIds: currentUser.menuIds || null,
|
||||
isActive: true
|
||||
});
|
||||
|
||||
switch (currentUser.roleId) {
|
||||
case "0": // DEVELOPER
|
||||
case "1": // SUPERADMIN
|
||||
case "2": // ADMIN_DESA
|
||||
redirectPath = '/admin/landing-page/profil/program-inovasi';
|
||||
break;
|
||||
case "3": // ADMIN_KESEHATAN
|
||||
redirectPath = '/admin/kesehatan/posyandu';
|
||||
break;
|
||||
case "4": // ADMIN_PENDIDIKAN
|
||||
redirectPath = '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
|
||||
break;
|
||||
// Clean up storage
|
||||
localStorage.removeItem('auth_kodeId');
|
||||
localStorage.removeItem('auth_nomor');
|
||||
localStorage.removeItem('auth_username');
|
||||
|
||||
// Force a session refresh
|
||||
try {
|
||||
const res = await fetch('/api/auth/refresh-session', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
// Redirect based on role
|
||||
let redirectPath = '/admin';
|
||||
switch (String(currentUser.roleId)) {
|
||||
case "0": case "1": case "2":
|
||||
redirectPath = '/admin/landing-page/profil/program-inovasi';
|
||||
break;
|
||||
case "3":
|
||||
redirectPath = '/admin/kesehatan/posyandu';
|
||||
break;
|
||||
case "4":
|
||||
redirectPath = '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
|
||||
break;
|
||||
}
|
||||
window.location.href = redirectPath; // Use window.location to force full page reload
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error refreshing session:', error);
|
||||
router.refresh(); // Fallback to client-side refresh
|
||||
}
|
||||
|
||||
setTimeout(() => router.push(redirectPath), 500);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!isMounted) return;
|
||||
|
||||
// ❌ Hanya redirect ke /login jika benar-benar tidak ada sesi
|
||||
if (err.message.includes('401')) {
|
||||
setError('Sesi tidak valid');
|
||||
clearInterval(interval);
|
||||
router.push('/login');
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
setRetryCount((prev) => prev + 1);
|
||||
setTimeout(() => {
|
||||
if (isMounted) interval = setInterval(poll, 3000);
|
||||
}, 800);
|
||||
} else {
|
||||
setError('Sesi tidak valid. Silakan login ulang.');
|
||||
clearInterval(interval);
|
||||
authStore.setUser(null); // ✅ clear sesi
|
||||
}
|
||||
} else {
|
||||
console.error('Error polling:', err);
|
||||
}
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
interval = setInterval(poll, 3000);
|
||||
return () => {
|
||||
isMounted = false;
|
||||
clearInterval(interval);
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}, [router, isRedirecting]);
|
||||
}, [router, isRedirecting, retryCount]);
|
||||
|
||||
// ✅ UI Error
|
||||
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>
|
||||
<Title order={3} c="red">
|
||||
Sesi Tidak Valid
|
||||
</Title>
|
||||
<Text>{error}</Text>
|
||||
<Button onClick={() => router.push('/login')}>
|
||||
Login Ulang
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
// ✅ UI Redirecting
|
||||
if (isRedirecting) {
|
||||
return (
|
||||
<Center h="100vh" bg={colors.Bg}>
|
||||
@@ -109,6 +171,7 @@ export default function WaitingRoom() {
|
||||
);
|
||||
}
|
||||
|
||||
// ✅ UI Default (MENUNGGU) — INI YANG KAMU HILANGKAN!
|
||||
return (
|
||||
<Center h="100vh" bg={colors.Bg}>
|
||||
<Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '90%', sm: 400 }}>
|
||||
|
||||
@@ -6,6 +6,7 @@ export type User = {
|
||||
name: string;
|
||||
roleId: number;
|
||||
menuIds?: string[] | null; // ✅ Pastikan pakai `string[]`
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
export const authStore = proxy<{
|
||||
|
||||
Reference in New Issue
Block a user