From 6d86e1ca2f9da0f5c4d419a2795ea02a83ed758b Mon Sep 17 00:00:00 2001 From: Randa Firman Putra Date: Mon, 14 Jul 2025 15:07:33 +0700 Subject: [PATCH] Change Alur Aplikasi --- app/api/auth/check/route.ts | 35 +- app/api/auth/login/route.ts | 197 +--- app/api/auth/logout/route.ts | 39 +- app/api/auth/user/route.ts | 26 + .../data-beasiswa-mahasiswa/route.ts | 322 ++++++ .../data-beasiswa-mahasiswa/upload/route.ts | 374 +++++++ .../data-kelompok-keahlian/route.ts | 146 +++ app/api/keloladata/data-mahasiswa/route.ts | 241 +++++ .../keloladata/data-mahasiswa/upload/route.ts | 360 +++++++ .../data-prestasi-mahasiswa/route.ts | 336 +++++++ .../data-prestasi-mahasiswa/upload/route.ts | 470 +++++++++ .../setting-jenis-pendaftaran/route.ts | 63 ++ app/api/keloladata/update-semester/route.ts | 94 ++ app/api/mahasiswa/status-kuliah/route.ts | 2 +- app/api/mahasiswa/status/route.ts | 2 +- app/api/mahasiswa/total/route.ts | 2 +- app/dashboard/mahasiswa/total/page.tsx | 1 - app/dashboard/page.tsx | 49 +- app/keloladata/beasiswa/page.tsx | 9 + app/keloladata/mahasiswa/page.tsx | 9 + app/keloladata/prestasi/page.tsx | 9 + app/layout.tsx | 8 +- app/page.tsx | 586 ++++------- app/visualisasi/beasiswa/page.tsx | 94 ++ app/visualisasi/berprestasi/page.tsx | 80 ++ app/visualisasi/status/page.tsx | 86 ++ app/visualisasi/tipekelulusan/page.tsx | 61 ++ app/visualisasi/total/page.tsx | 59 ++ components/ClientLayout.tsx | 31 +- components/FilterStatusKuliah.tsx | 2 +- components/charts/JenisPendaftaranChart.tsx | 4 +- components/charts/StatistikMahasiswaChart.tsx | 2 +- components/charts/StatusMahasiswaChart.tsx | 4 +- components/datatable/data-table-mahasiswa.tsx | 929 ++++++++++++++++++ .../datatable/edit-jenis-pendaftaran.tsx | 224 +++++ .../datatable/upload-excel-mahasiswa.tsx | 160 +++ components/ui/Navbar.tsx | 343 ++++++- components/ui/SidebarContent.tsx | 74 +- components/ui/button.tsx | 47 +- components/ui/dropdown-menu.tsx | 101 +- components/ui/form.tsx | 167 ++++ components/ui/input.tsx | 2 +- components/ui/login-dialog.tsx | 212 ++++ components/ui/pagination.tsx | 127 +++ components/ui/select.tsx | 248 +++-- components/ui/sheet.tsx | 17 +- components/ui/table.tsx | 116 +++ components/ui/{use-toast.ts => use-toast.tsx} | 0 create_env.js | 7 +- lib/utils.ts | 84 +- middleware.ts | 89 +- package-lock.json | 313 ++++-- package.json | 10 +- 53 files changed, 6109 insertions(+), 964 deletions(-) create mode 100644 app/api/auth/user/route.ts create mode 100644 app/api/keloladata/data-beasiswa-mahasiswa/route.ts create mode 100644 app/api/keloladata/data-beasiswa-mahasiswa/upload/route.ts create mode 100644 app/api/keloladata/data-kelompok-keahlian/route.ts create mode 100644 app/api/keloladata/data-mahasiswa/route.ts create mode 100644 app/api/keloladata/data-mahasiswa/upload/route.ts create mode 100644 app/api/keloladata/data-prestasi-mahasiswa/route.ts create mode 100644 app/api/keloladata/data-prestasi-mahasiswa/upload/route.ts create mode 100644 app/api/keloladata/setting-jenis-pendaftaran/route.ts create mode 100644 app/api/keloladata/update-semester/route.ts create mode 100644 app/keloladata/beasiswa/page.tsx create mode 100644 app/keloladata/mahasiswa/page.tsx create mode 100644 app/keloladata/prestasi/page.tsx create mode 100644 app/visualisasi/beasiswa/page.tsx create mode 100644 app/visualisasi/berprestasi/page.tsx create mode 100644 app/visualisasi/status/page.tsx create mode 100644 app/visualisasi/tipekelulusan/page.tsx create mode 100644 app/visualisasi/total/page.tsx create mode 100644 components/datatable/data-table-mahasiswa.tsx create mode 100644 components/datatable/edit-jenis-pendaftaran.tsx create mode 100644 components/datatable/upload-excel-mahasiswa.tsx create mode 100644 components/ui/form.tsx create mode 100644 components/ui/login-dialog.tsx create mode 100644 components/ui/pagination.tsx create mode 100644 components/ui/table.tsx rename components/ui/{use-toast.ts => use-toast.tsx} (100%) diff --git a/app/api/auth/check/route.ts b/app/api/auth/check/route.ts index e48666e..b8a11f4 100644 --- a/app/api/auth/check/route.ts +++ b/app/api/auth/check/route.ts @@ -10,7 +10,7 @@ export async function GET() { if (!token) { return NextResponse.json( - { error: 'Unauthorized' }, + { error: 'Unauthorized', isAuthenticated: false }, { status: 401 } ); } @@ -21,33 +21,58 @@ export async function GET() { new TextEncoder().encode(process.env.JWT_SECRET || 'your-secret-key') ); + // Check if token is expired + if (payload.exp && payload.exp * 1000 < Date.now()) { + return NextResponse.json( + { error: 'Token expired', isAuthenticated: false }, + { status: 401 } + ); + } + // Get user data from user_app table const { data: user, error } = await supabase .from('user_app') - .select('id_user, nim, username, role') + .select('id_user, nim, username, nip, role_user') .eq('id_user', payload.id) .single(); if (error || !user) { return NextResponse.json( - { error: 'User not found' }, + { error: 'User not found', isAuthenticated: false }, { status: 404 } ); } return NextResponse.json({ + isAuthenticated: true, user: { id: user.id_user, nim: user.nim, username: user.username, - role: user.role + nip: user.nip, + role: user.role_user + }, + session: { + expiresAt: payload.exp ? new Date(payload.exp * 1000).toISOString() : null, + issuedAt: payload.iat ? new Date(payload.iat * 1000).toISOString() : null } }); } catch (error) { console.error('Auth check error:', error); return NextResponse.json( - { error: 'Unauthorized' }, + { error: 'Unauthorized', isAuthenticated: false }, { status: 401 } ); } +} + +// Handle OPTIONS request for CORS +export async function OPTIONS() { + return NextResponse.json({}, { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + } + }); } \ No newline at end of file diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index bbae0b8..497c4c8 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -1,198 +1,105 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import supabase from '@/lib/db'; import bcrypt from 'bcryptjs'; -import { SignJWT } from 'jose'; -interface User { - id_user: number; - username?: string; - nip?: string; - password: string; - role_user: string; -} - -export async function POST(request: Request) { +export async function POST(request: NextRequest) { try { - - // Test database connection first - try { - const { data: testData, error: testError } = await supabase - .from('user_app') - .select('count') - .limit(1); - - if (testError) { - return NextResponse.json( - { error: 'Tidak dapat terhubung ke database' }, - { status: 500 } - ); - } - } catch (dbError) { - return NextResponse.json( - { error: 'Tidak dapat terhubung ke database' }, - { status: 500 } - ); - } - const body = await request.json(); - - const { username, nip, password, role } = body; + const { nip, username, password, role } = body; - // Validate input based on role - if (role === 'admin') { - if (!username || !password) { + // Validate required fields + if (!password || !role) { return NextResponse.json( - { error: 'Username dan password harus diisi' }, - { status: 400 } + { message: 'Password dan role diperlukan' }, + { status: 400 } ); } - } else if (role === 'dosen' || role === 'kajur') { - if (!nip || !password) { - return NextResponse.json( - { error: 'NIP dan password harus diisi' }, - { status: 400 } - ); - } - } else { + + // Validate role + if (!['ketuajurusan', 'admin'].includes(role)) { return NextResponse.json( - { error: 'Role tidak valid' }, + { message: 'Role tidak valid' }, { status: 400 } ); } - // Get user by username (admin) or NIP (dosen/kajur) - let users: User[]; - try { - let query = supabase.from('user_app').select('*'); - - if (role === 'admin') { - query = query.eq('username', username); - } else { - query = query.eq('nip', nip); - } + let query = supabase + .from('user_app') + .select('*') + .eq('role_user', role); - const { data, error } = await query; - - if (error) { + // Add specific field filter based on role + if (role === 'ketuajurusan') { + if (!nip) { return NextResponse.json( - { error: 'Terjadi kesalahan saat memeriksa data pengguna' }, - { status: 500 } + { message: 'NIP diperlukan untuk Ketua Jurusan' }, + { status: 400 } ); } + query = query.eq('nip', nip); + } else if (role === 'admin') { + if (!username) { + return NextResponse.json( + { message: 'Username diperlukan untuk Admin' }, + { status: 400 } + ); + } + query = query.eq('username', username); + } - users = data || []; - } catch (queryError) { + const { data: users, error } = await query; + + if (error) { + console.error('Database error:', error); return NextResponse.json( - { error: 'Terjadi kesalahan saat memeriksa data pengguna' }, + { message: 'Internal Server Error' }, { status: 500 } ); } - if (users.length === 0) { + if (!users || users.length === 0) { return NextResponse.json( - { error: role === 'admin' ? 'Username atau password salah' : 'NIP atau password salah' }, + { message: 'User tidak ditemukan' }, { status: 401 } ); } const user = users[0]; - // Check if user role matches - if (user.role_user !== role) { - return NextResponse.json( - { error: 'Role tidak sesuai' }, - { status: 401 } - ); - } - // Verify password - let isPasswordValid; - try { - // For admin, check if password is plain text (not hashed) - if (user.role_user === 'admin') { - // Check if stored password is plain text (not starting with $2a$ or $2b$) - if (!user.password.startsWith('$2a$') && !user.password.startsWith('$2b$')) { - // Plain text password - direct comparison - isPasswordValid = password === user.password; - } else { - // Hashed password - use bcrypt - isPasswordValid = await bcrypt.compare(password, user.password); - } - } else { - // For dosen/kajur, always use bcrypt (should be hashed) - isPasswordValid = await bcrypt.compare(password, user.password); - } - } catch (bcryptError) { - return NextResponse.json( - { error: 'Terjadi kesalahan saat memverifikasi password' }, - { status: 500 } - ); - } + const isPasswordValid = await bcrypt.compare(password, user.password); if (!isPasswordValid) { return NextResponse.json( - { error: role === 'admin' ? 'Username atau password salah' : 'NIP atau password salah' }, + { message: 'Password salah' }, { status: 401 } ); } - // Create JWT token - let token; - try { - const tokenPayload: any = { - id: user.id_user, - role: user.role_user - }; - - // Add username for admin, NIP for dosen/kajur - if (user.role_user === 'admin') { - tokenPayload.username = user.username; - } else { - tokenPayload.nip = user.nip; - } - - token = await new SignJWT(tokenPayload) - .setProtectedHeader({ alg: 'HS256' }) - .setExpirationTime('24h') - .sign(new TextEncoder().encode(process.env.JWT_SECRET || 'your-secret-key')); - } catch (jwtError) { - return NextResponse.json( - { error: 'Terjadi kesalahan saat membuat token' }, - { status: 500 } - ); - } + // Return user data (without password) + const { password: _, ...userWithoutPassword } = user; - // Set cookie - const userResponse: any = { - id: user.id_user, - role: user.role_user - }; - - // Add username for admin, NIP for dosen/kajur - if (user.role_user === 'admin') { - userResponse.username = user.username; - } else { - userResponse.nip = user.nip; - } - + // Create response with session cookie const response = NextResponse.json({ - user: userResponse + message: 'Login berhasil', + user: userWithoutPassword, }); - response.cookies.set('token', token, { + // Set secure session cookie + response.cookies.set('user_session', JSON.stringify(userWithoutPassword), { httpOnly: true, - secure: false, + secure: process.env.NODE_ENV === 'production', sameSite: 'lax', - maxAge: 60 * 60 * 24 // 24 hours + maxAge: 24 * 60 * 60, // 24 hours + path: '/', }); return response; + } catch (error) { - if (error instanceof Error) { - console.error('Error message:', error.message); - } + console.error('Login error:', error); return NextResponse.json( - { error: 'Terjadi kesalahan saat login' }, + { message: 'Internal Server Error' }, { status: 500 } ); } diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts index 679a9cf..4190509 100644 --- a/app/api/auth/logout/route.ts +++ b/app/api/auth/logout/route.ts @@ -1,28 +1,29 @@ import { NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; export async function POST() { - try { - const response = NextResponse.json( - { message: 'Logout berhasil' }, - { status: 200 } - ); + const response = NextResponse.json({ + message: 'Logout berhasil', + }); - // Clear the token cookie with additional security options - response.cookies.set('token', '', { - expires: new Date(0), - path: '/', + // Clear the session cookie + response.cookies.set('user_session', '', { httpOnly: true, - secure: false, - sameSite: 'lax' + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 0, // Expire immediately + path: '/', }); return response; - } catch (error) { - console.error('Logout error:', error); - return NextResponse.json( - { error: 'Terjadi kesalahan saat logout' }, - { status: 500 } - ); - } +} + +// Handle OPTIONS request for CORS +export async function OPTIONS() { + return NextResponse.json({}, { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + } + }); } diff --git a/app/api/auth/user/route.ts b/app/api/auth/user/route.ts new file mode 100644 index 0000000..d96ac0b --- /dev/null +++ b/app/api/auth/user/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(request: NextRequest) { + try { + const userSession = request.cookies.get('user_session'); + + if (!userSession) { + return NextResponse.json( + { message: 'Tidak ada session aktif' }, + { status: 401 } + ); + } + + const userData = JSON.parse(userSession.value); + + return NextResponse.json({ + user: userData, + }); + } catch (error) { + console.error('Get user error:', error); + return NextResponse.json( + { message: 'Internal Server Error' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/keloladata/data-beasiswa-mahasiswa/route.ts b/app/api/keloladata/data-beasiswa-mahasiswa/route.ts new file mode 100644 index 0000000..9f11517 --- /dev/null +++ b/app/api/keloladata/data-beasiswa-mahasiswa/route.ts @@ -0,0 +1,322 @@ +import { NextRequest, NextResponse } from 'next/server'; +import supabase from '@/lib/db'; + +// GET - Fetch all beasiswa mahasiswa or filter by criteria +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + const id_mahasiswa = searchParams.get('id_mahasiswa'); + const search = searchParams.get('search'); + const jenisBeasiswa = searchParams.get('jenis_beasiswa'); + + // If ID is provided, fetch specific beasiswa by ID with join + if (id) { + const { data, error } = await supabase + .from('beasiswa_mahasiswa') + .select(` + *, + mahasiswa!inner(nama, nim) + `) + .eq('id_beasiswa', id) + .single(); + + if (error || !data) { + return NextResponse.json({ message: 'Beasiswa mahasiswa not found' }, { status: 404 }); + } + + // Transform the data to flatten the nama and nim fields + const transformedData = { + ...data, + nama: data.mahasiswa.nama, + nim: data.mahasiswa.nim + }; + delete transformedData.mahasiswa; + + return NextResponse.json(transformedData); + } + + // If id_mahasiswa is provided, fetch beasiswa for specific student with join + if (id_mahasiswa) { + const { data, error } = await supabase + .from('beasiswa_mahasiswa') + .select(` + *, + mahasiswa!inner(nama, nim) + `) + .eq('id_mahasiswa', id_mahasiswa) + .order('created_at', { ascending: false }); + + if (error) { + console.error('Error fetching data:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } + + // Transform the data to flatten the nama and nim fields + const transformedData = data.map(item => ({ + ...item, + nama: item.mahasiswa.nama, + nim: item.mahasiswa.nim + })).map(({ mahasiswa, ...rest }) => rest); + + return NextResponse.json(transformedData); + } + + // Build the query based on filters with join + let query = supabase.from('beasiswa_mahasiswa').select(` + *, + mahasiswa!inner(nama, nim) + `); + + // Add search condition if provided + if (search) { + query = query.or(`mahasiswa.nama.ilike.%${search}%,mahasiswa.nim.ilike.%${search}%,nama_beasiswa.ilike.%${search}%,sumber_beasiswa.ilike.%${search}%`); + } + + // Add jenis_beasiswa filter if provided + if (jenisBeasiswa) { + query = query.eq('jenis_beasiswa', jenisBeasiswa); + } + + // Add order by + query = query.order('created_at', { ascending: false }); + + // Execute the query + const { data, error } = await query; + + if (error) { + console.error('Error fetching data:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } + + // Transform the data to flatten the nama and nim fields + const transformedData = data.map(item => ({ + ...item, + nama: item.mahasiswa.nama, + nim: item.mahasiswa.nim + })).map(({ mahasiswa, ...rest }) => rest); + + return NextResponse.json(transformedData); + } catch (error) { + console.error('Error fetching data:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } +} + +// POST - Create a new beasiswa mahasiswa +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { + nim, + nama_beasiswa, + sumber_beasiswa, + beasiswa_status, + jenis_beasiswa + } = body; + + // Validate required fields + if (!nim || !nama_beasiswa || !sumber_beasiswa || !beasiswa_status || !jenis_beasiswa) { + return NextResponse.json( + { message: 'Missing required fields: nim, nama_beasiswa, sumber_beasiswa, beasiswa_status, jenis_beasiswa' }, + { status: 400 } + ); + } + + // Check if mahasiswa exists by NIM and get id_mahasiswa + const { data: mahasiswaExists, error: checkError } = await supabase + .from('mahasiswa') + .select('id_mahasiswa, nama') + .eq('nim', nim) + .single(); + + if (checkError || !mahasiswaExists) { + return NextResponse.json( + { message: `Mahasiswa dengan NIM ${nim} tidak terdaftar dalam database` }, + { status: 404 } + ); + } + + // Validate enum values + const validStatus = ['Aktif', 'Selesai', 'Dibatalkan']; + const validJenisBeasiswa = ['Pemerintah', 'Non-Pemerintah']; + + if (!validStatus.includes(beasiswa_status)) { + return NextResponse.json( + { message: 'Invalid beasiswa_status value. Must be one of: Aktif, Selesai, Dibatalkan' }, + { status: 400 } + ); + } + + if (!validJenisBeasiswa.includes(jenis_beasiswa)) { + return NextResponse.json( + { message: 'Invalid jenis_beasiswa value. Must be one of: Pemerintah, Non-Pemerintah' }, + { status: 400 } + ); + } + + // Insert new beasiswa using id_mahasiswa + const { data, error } = await supabase + .from('beasiswa_mahasiswa') + .insert({ + id_mahasiswa: mahasiswaExists.id_mahasiswa, + nama_beasiswa, + sumber_beasiswa, + beasiswa_status, + jenis_beasiswa + }) + .select() + .single(); + + if (error) { + console.error('Error creating beasiswa mahasiswa:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } + + return NextResponse.json( + { + message: `Beasiswa berhasil ditambahkan`, + id: data.id_beasiswa + }, + { status: 201 } + ); + } catch (error) { + console.error('Error creating beasiswa mahasiswa:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } +} + +// PUT - Update an existing beasiswa mahasiswa +export async function PUT(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + + if (!id) { + return NextResponse.json({ message: 'ID is required' }, { status: 400 }); + } + + const body = await request.json(); + const { + nim, + nama_beasiswa, + sumber_beasiswa, + beasiswa_status, + jenis_beasiswa + } = body; + + // Validate required fields + if (!nim || !nama_beasiswa || !sumber_beasiswa || !beasiswa_status || !jenis_beasiswa) { + return NextResponse.json( + { message: 'Missing required fields: nim, nama_beasiswa, sumber_beasiswa, beasiswa_status, jenis_beasiswa' }, + { status: 400 } + ); + } + + // Check if beasiswa exists + const { data: existing, error: checkError } = await supabase + .from('beasiswa_mahasiswa') + .select('*') + .eq('id_beasiswa', id) + .single(); + + if (checkError || !existing) { + return NextResponse.json({ message: 'Beasiswa mahasiswa not found' }, { status: 404 }); + } + + // Check if mahasiswa exists by NIM and get id_mahasiswa + const { data: mahasiswaExists, error: mahasiswaCheckError } = await supabase + .from('mahasiswa') + .select('id_mahasiswa, nama') + .eq('nim', nim) + .single(); + + if (mahasiswaCheckError || !mahasiswaExists) { + return NextResponse.json( + { message: `Mahasiswa dengan NIM ${nim} tidak terdaftar` }, + { status: 404 } + ); + } + + // Validate enum values + const validStatus = ['Aktif', 'Selesai', 'Dibatalkan']; + const validJenisBeasiswa = ['Pemerintah', 'Non-Pemerintah']; + + if (!validStatus.includes(beasiswa_status)) { + return NextResponse.json( + { message: 'Invalid beasiswa_status value. Must be one of: Aktif, Selesai, Dibatalkan' }, + { status: 400 } + ); + } + + if (!validJenisBeasiswa.includes(jenis_beasiswa)) { + return NextResponse.json( + { message: 'Invalid jenis_beasiswa value. Must be one of: Pemerintah, Non-Pemerintah' }, + { status: 400 } + ); + } + + // Update beasiswa using id_mahasiswa + const { error } = await supabase + .from('beasiswa_mahasiswa') + .update({ + id_mahasiswa: mahasiswaExists.id_mahasiswa, + nama_beasiswa, + sumber_beasiswa, + beasiswa_status, + jenis_beasiswa + }) + .eq('id_beasiswa', id); + + if (error) { + console.error('Error updating beasiswa mahasiswa:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } + + return NextResponse.json({ + message: `Beasiswa berhasil diperbarui` + }); + } catch (error) { + console.error('Error updating beasiswa mahasiswa:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } +} + +// DELETE - Delete a beasiswa mahasiswa +export async function DELETE(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + + if (!id) { + return NextResponse.json({ message: 'ID is required' }, { status: 400 }); + } + + // Check if beasiswa exists + const { data: existing, error: checkError } = await supabase + .from('beasiswa_mahasiswa') + .select('id_beasiswa') + .eq('id_beasiswa', id) + .single(); + + if (checkError || !existing) { + return NextResponse.json({ message: 'Beasiswa mahasiswa not found' }, { status: 404 }); + } + + // Delete beasiswa + const { error } = await supabase + .from('beasiswa_mahasiswa') + .delete() + .eq('id_beasiswa', id); + + if (error) { + console.error('Error deleting beasiswa mahasiswa:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } + + return NextResponse.json({ message: 'Beasiswa mahasiswa deleted successfully' }); + } catch (error) { + console.error('Error deleting beasiswa mahasiswa:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/keloladata/data-beasiswa-mahasiswa/upload/route.ts b/app/api/keloladata/data-beasiswa-mahasiswa/upload/route.ts new file mode 100644 index 0000000..97dcd19 --- /dev/null +++ b/app/api/keloladata/data-beasiswa-mahasiswa/upload/route.ts @@ -0,0 +1,374 @@ +import { NextRequest, NextResponse } from 'next/server'; +import * as XLSX from 'xlsx'; +import supabase from '@/lib/db'; + +export async function POST(request: NextRequest) { + try { + // Get form data from request + const formData = await request.formData(); + const file = formData.get('file') as File; + + if (!file) { + return NextResponse.json({ message: 'File tidak ditemukan' }, { status: 400 }); + } + + // Process file data based on file type + let validData = []; + let errors: string[] = []; + + if (file.name.endsWith('.csv') || file.type === 'text/csv') { + // Process as CSV + const fileContent = await file.text(); + const result = await processCSVData(fileContent); + validData = result.validData; + errors = result.errors; + } else { + // Process as Excel + const fileBuffer = await file.arrayBuffer(); + const result = await processExcelData(fileBuffer); + validData = result.validData; + errors = result.errors; + } + + if (validData.length === 0) { + return NextResponse.json({ + message: 'Tidak ada data valid yang ditemukan dalam file', + errors + }, { status: 400 }); + } + + // Insert valid data into the database + const { imported, errorCount, errorMessages } = await insertDataToDatabase(validData); + + // Combine all error messages + const allErrors = [...errors, ...errorMessages]; + + return NextResponse.json({ + message: 'Upload berhasil', + imported, + errors: errorCount, + errorDetails: allErrors.length > 0 ? allErrors : undefined + }); + + } catch (error) { + console.error('Error uploading file:', error); + return NextResponse.json( + { message: `Terjadi kesalahan: ${(error as Error).message}` }, + { status: 500 } + ); + } +} + +// Function to process Excel data +async function processExcelData(fileBuffer: ArrayBuffer) { + try { + // Parse Excel file + const workbook = XLSX.read(fileBuffer, { type: 'array' }); + + // Get first sheet + const sheetName = workbook.SheetNames[0]; + const worksheet = workbook.Sheets[sheetName]; + + // Convert to JSON with proper typing + const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as any[][]; + + if (jsonData.length === 0) { + return { validData: [], errors: ['File Excel kosong'] }; + } + + // Convert Excel data to CSV-like format for processing + const headers = jsonData[0].map(h => String(h).toLowerCase()); + const rows = jsonData.slice(1); + + // Process the data using the common function + return processData(headers, rows); + } catch (error) { + console.error('Error processing Excel data:', error); + return { validData: [], errors: [(error as Error).message] }; + } +} + +// Function to process CSV data +async function processCSVData(fileContent: string) { + const lines = fileContent.split(/\r?\n/).filter(line => line.trim() !== ''); + + if (lines.length === 0) { + return { validData: [], errors: ['File CSV kosong'] }; + } + + // Get headers from first line + const headerLine = lines[0].toLowerCase(); + const headers = headerLine.split(',').map(h => h.trim()); + + // Process data rows + const rows = lines.slice(1).map(line => line.split(',').map(v => v.trim())); + + return processData(headers, rows); +} + +// Common function to process data regardless of source format +function processData(headers: string[], rows: any[][]) { + // Define expected headers and their possible variations + const expectedHeaderMap = { + nim: ['nim', 'nomor induk', 'nomor mahasiswa'], + nama_beasiswa: ['nama_beasiswa', 'nama beasiswa', 'namabeasiswa', 'beasiswa', 'nama'], + sumber_beasiswa: ['sumber_beasiswa', 'sumber beasiswa', 'sumberbeasiswa', 'sumber'], + beasiswa_status: ['beasiswa_status', 'status beasiswa', 'statusbeasiswa', 'status'], + jenis_beasiswa: ['jenis_beasiswa', 'jenis beasiswa', 'jenisbeasiswa', 'jenis'] + }; + + // Map actual headers to expected headers + const headerMap: { [key: string]: number } = {}; + for (const [expectedHeader, variations] of Object.entries(expectedHeaderMap)) { + const index = headers.findIndex(h => + variations.some(variation => h.includes(variation)) + ); + if (index !== -1) { + headerMap[expectedHeader] = index; + } + } + + // Check required headers + const requiredHeaders = ['nim', 'nama_beasiswa', 'sumber_beasiswa', 'beasiswa_status', 'jenis_beasiswa']; + const missingHeaders = requiredHeaders.filter(h => headerMap[h] === undefined); + + if (missingHeaders.length > 0) { + return { + validData: [], + errors: [`Kolom berikut tidak ditemukan: ${missingHeaders.join(', ')}. Pastikan file memiliki kolom: NIM, Nama Beasiswa, Sumber Beasiswa, Status Beasiswa, dan Jenis Beasiswa.`] + }; + } + + const validData = []; + const errors = []; + const validStatuses = ['Aktif', 'Selesai', 'Dibatalkan']; + const validJenis = ['Pemerintah', 'Non-Pemerintah']; + + // Process data rows + for (let i = 0; i < rows.length; i++) { + const values = rows[i]; + if (!values || values.length === 0) continue; + + try { + // Extract values using header map + const nim = String(values[headerMap.nim] || '').trim(); + const nama_beasiswa = String(values[headerMap.nama_beasiswa] || '').trim(); + const sumber_beasiswa = String(values[headerMap.sumber_beasiswa] || '').trim(); + let beasiswa_status = String(values[headerMap.beasiswa_status] || '').trim(); + let jenis_beasiswa = String(values[headerMap.jenis_beasiswa] || '').trim(); + + // Validate required fields + if (!nim || !nama_beasiswa || !sumber_beasiswa || !beasiswa_status || !jenis_beasiswa) { + errors.push(`Baris ${i+2}: Data tidak lengkap (NIM: ${nim || 'kosong'})`); + continue; + } + + // Normalize status beasiswa + beasiswa_status = normalizeBeasiswaStatus(beasiswa_status); + + // Validate status beasiswa + if (!validStatuses.includes(beasiswa_status)) { + errors.push(`Baris ${i+2}: Status beasiswa tidak valid "${beasiswa_status}" untuk NIM ${nim}. Harus salah satu dari: ${validStatuses.join(', ')}`); + continue; + } + + // Normalize jenis beasiswa + jenis_beasiswa = normalizeJenisBeasiswa(jenis_beasiswa); + + // Validate jenis beasiswa + if (!validJenis.includes(jenis_beasiswa)) { + errors.push(`Baris ${i+2}: Jenis beasiswa tidak valid "${jenis_beasiswa}" untuk NIM ${nim}. Harus salah satu dari: ${validJenis.join(', ')}`); + continue; + } + + // Add to valid data + validData.push({ + nim, + nama_beasiswa, + sumber_beasiswa, + beasiswa_status, + jenis_beasiswa + }); + + } catch (error) { + errors.push(`Baris ${i+2}: Error memproses data - ${(error as Error).message}`); + } + } + + return { validData, errors }; +} + +// Function to normalize beasiswa status values +function normalizeBeasiswaStatus(value: string): string { + const lowerValue = value.toLowerCase(); + + if (['aktif', 'active', 'a'].includes(lowerValue)) { + return 'Aktif'; + } + + if (['selesai', 'complete', 'completed', 's', 'finish', 'finished'].includes(lowerValue)) { + return 'Selesai'; + } + + if (['dibatalkan', 'cancel', 'cancelled', 'canceled', 'batal', 'd', 'c'].includes(lowerValue)) { + return 'Dibatalkan'; + } + + return value; // Return original if no match +} + +// Function to normalize jenis beasiswa values +function normalizeJenisBeasiswa(value: string): string { + const lowerValue = value.toLowerCase(); + + if (['pemerintah', 'government', 'p', 'gov'].includes(lowerValue)) { + return 'Pemerintah'; + } + + if (['non-pemerintah', 'non pemerintah', 'nonpemerintah', 'swasta', 'private', 'np', 'non'].includes(lowerValue)) { + return 'Non-Pemerintah'; + } + + return value; // Return original if no match +} + +// Function to insert data into database +async function insertDataToDatabase(data: any[]) { + let imported = 0; + let errorCount = 0; + const errorMessages: string[] = []; + + console.log('=== DEBUG: Starting beasiswa data insertion process ==='); + console.log(`Total data items to process: ${data.length}`); + console.log('Sample data items:', data.slice(0, 3)); + + // First, validate all NIMs exist before processing + const uniqueNims = [...new Set(data.map(item => item.nim))]; + console.log(`Unique NIMs found: ${uniqueNims.length}`); + console.log('Unique NIMs:', uniqueNims); + + const nimValidationMap = new Map(); + + // Batch check all NIMs for existence + console.log('=== DEBUG: Starting NIM validation ==='); + for (const nim of uniqueNims) { + try { + console.log(`Checking NIM: ${nim}`); + const { data: mahasiswaData, error: checkError } = await supabase + .from('mahasiswa') + .select('id_mahasiswa, nama') + .eq('nim', nim) + .single(); + + if (checkError || !mahasiswaData) { + console.log(`❌ NIM ${nim}: NOT FOUND in database`); + console.log(`Error details:`, checkError); + nimValidationMap.set(nim, { exists: false, error: 'Mahasiswa dengan NIM ini tidak ditemukan dalam database' }); + } else { + console.log(`✅ NIM ${nim}: FOUND - ID: ${mahasiswaData.id_mahasiswa}, Nama: ${mahasiswaData.nama}`); + nimValidationMap.set(nim, { exists: true, id_mahasiswa: mahasiswaData.id_mahasiswa, nama: mahasiswaData.nama }); + } + } catch (error) { + console.log(`❌ NIM ${nim}: ERROR during validation`); + console.log(`Error details:`, error); + nimValidationMap.set(nim, { exists: false, error: `Error checking NIM: ${(error as Error).message}` }); + } + } + + console.log('=== DEBUG: NIM validation results ==='); + console.log('Validation map:', Object.fromEntries(nimValidationMap)); + + // Process each data item + console.log('=== DEBUG: Starting beasiswa data processing ==='); + for (const item of data) { + try { + console.log(`\n--- Processing beasiswa item: NIM ${item.nim} ---`); + console.log('Item data:', item); + + const nimValidation = nimValidationMap.get(item.nim); + console.log('NIM validation result:', nimValidation); + + if (!nimValidation || !nimValidation.exists) { + errorCount++; + const errorMsg = nimValidation?.error || `NIM ${item.nim}: Mahasiswa dengan NIM ini tidak ditemukan dalam database`; + console.log(`❌ Skipping item - ${errorMsg}`); + errorMessages.push(errorMsg); + continue; + } + + console.log(`✅ NIM ${item.nim} is valid, proceeding with beasiswa check/insert`); + + // Check if beasiswa already exists for this mahasiswa and nama_beasiswa + console.log(`Checking existing beasiswa for mahasiswa ID: ${nimValidation.id_mahasiswa}, nama_beasiswa: ${item.nama_beasiswa}`); + const { data: existingBeasiswa, error: beasiswaCheckError } = await supabase + .from('beasiswa_mahasiswa') + .select('id_beasiswa') + .eq('id_mahasiswa', nimValidation.id_mahasiswa) + .eq('nama_beasiswa', item.nama_beasiswa) + .single(); + + if (beasiswaCheckError && beasiswaCheckError.code !== 'PGRST116') { + console.log(`❌ Error checking existing beasiswa:`, beasiswaCheckError); + } + + if (existingBeasiswa) { + console.log(`📝 Updating existing beasiswa (ID: ${existingBeasiswa.id_beasiswa})`); + // Update existing beasiswa + const { error: updateError } = await supabase + .from('beasiswa_mahasiswa') + .update({ + sumber_beasiswa: item.sumber_beasiswa, + beasiswa_status: item.beasiswa_status, + jenis_beasiswa: item.jenis_beasiswa + }) + .eq('id_beasiswa', existingBeasiswa.id_beasiswa); + + if (updateError) { + errorCount++; + const errorMsg = `NIM ${item.nim} (${nimValidation.nama}): Gagal memperbarui beasiswa: ${updateError.message}`; + console.log(`❌ Update failed: ${errorMsg}`); + errorMessages.push(errorMsg); + continue; + } else { + console.log(`✅ Beasiswa updated successfully`); + } + } else { + console.log(`📝 Inserting new beasiswa for mahasiswa ID: ${nimValidation.id_mahasiswa}`); + // Insert new beasiswa + const { error: insertError } = await supabase + .from('beasiswa_mahasiswa') + .insert({ + id_mahasiswa: nimValidation.id_mahasiswa, + nama_beasiswa: item.nama_beasiswa, + sumber_beasiswa: item.sumber_beasiswa, + beasiswa_status: item.beasiswa_status, + jenis_beasiswa: item.jenis_beasiswa + }); + + if (insertError) { + errorCount++; + const errorMsg = `NIM ${item.nim} (${nimValidation.nama}): Gagal menyimpan beasiswa: ${insertError.message}`; + console.log(`❌ Insert failed: ${errorMsg}`); + errorMessages.push(errorMsg); + continue; + } else { + console.log(`✅ Beasiswa inserted successfully`); + } + } + + imported++; + console.log(`✅ Item processed successfully. Imported count: ${imported}`); + } catch (error) { + console.error(`❌ Error processing record for NIM ${item.nim}:`, error); + errorCount++; + errorMessages.push(`NIM ${item.nim}: Terjadi kesalahan: ${(error as Error).message}`); + } + } + + console.log('=== DEBUG: Final results ==='); + console.log(`Total imported: ${imported}`); + console.log(`Total errors: ${errorCount}`); + console.log(`Error messages:`, errorMessages); + + return { imported, errorCount, errorMessages }; +} \ No newline at end of file diff --git a/app/api/keloladata/data-kelompok-keahlian/route.ts b/app/api/keloladata/data-kelompok-keahlian/route.ts new file mode 100644 index 0000000..c0c31fb --- /dev/null +++ b/app/api/keloladata/data-kelompok-keahlian/route.ts @@ -0,0 +1,146 @@ +import { NextRequest, NextResponse } from "next/server"; +import supabase from "@/lib/db"; + +// GET - Fetch all kelompok keahlian +export async function GET() { + try { + const { data, error } = await supabase + .from('kelompok_keahlian') + .select('id_kk, nama_kelompok') + .order('nama_kelompok'); + + if (error) { + throw error; + } + + return NextResponse.json(data); + } catch (error) { + console.error("Error fetching kelompok keahlian:", error); + return NextResponse.json( + { error: "Failed to fetch kelompok keahlian data" }, + { status: 500 } + ); + } +} + +// POST - Create new kelompok keahlian +export async function POST(request: NextRequest) { + try { + const { nama_kelompok } = await request.json(); + + // Validation + if (!nama_kelompok) { + return NextResponse.json( + { error: "nama_kelompok is required" }, + { status: 400 } + ); + } + + // Check if nama_kelompok already exists + const { data: existingKelompok, error: checkError } = await supabase + .from('kelompok_keahlian') + .select('id_kk') + .eq('nama_kelompok', nama_kelompok) + .single(); + + if (checkError && checkError.code !== 'PGRST116') { + throw checkError; + } + + if (existingKelompok) { + return NextResponse.json( + { error: "Nama kelompok keahlian already exists" }, + { status: 400 } + ); + } + + const { data, error } = await supabase + .from('kelompok_keahlian') + .insert([{ nama_kelompok }]) + .select('id_kk, nama_kelompok') + .single(); + + if (error) { + throw error; + } + + return NextResponse.json(data, { status: 201 }); + } catch (error) { + console.error("Error creating kelompok keahlian:", error); + return NextResponse.json( + { error: "Failed to create kelompok keahlian" }, + { status: 500 } + ); + } +} + +// PUT - Update kelompok keahlian +export async function PUT(request: NextRequest) { + try { + const { id_kk, nama_kelompok } = await request.json(); + + // Validation + if (!id_kk || !nama_kelompok) { + return NextResponse.json( + { error: "id_kk and nama_kelompok are required" }, + { status: 400 } + ); + } + + // Check if kelompok keahlian exists + const { data: existingKelompok, error: checkError } = await supabase + .from('kelompok_keahlian') + .select('id_kk') + .eq('id_kk', id_kk) + .single(); + + if (checkError && checkError.code !== 'PGRST116') { + throw checkError; + } + + if (!existingKelompok) { + return NextResponse.json( + { error: "Kelompok keahlian not found" }, + { status: 404 } + ); + } + + // Check if nama_kelompok already exists for another kelompok + const { data: duplicateNama, error: duplicateError } = await supabase + .from('kelompok_keahlian') + .select('id_kk') + .eq('nama_kelompok', nama_kelompok) + .neq('id_kk', id_kk) + .single(); + + if (duplicateError && duplicateError.code !== 'PGRST116') { + throw duplicateError; + } + + if (duplicateNama) { + return NextResponse.json( + { error: "Nama kelompok keahlian already exists for another kelompok" }, + { status: 400 } + ); + } + + const { data, error } = await supabase + .from('kelompok_keahlian') + .update({ nama_kelompok }) + .eq('id_kk', id_kk) + .select('id_kk, nama_kelompok') + .single(); + + if (error) { + throw error; + } + + return NextResponse.json(data); + } catch (error) { + console.error("Error updating kelompok keahlian:", error); + return NextResponse.json( + { error: "Failed to update kelompok keahlian" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/keloladata/data-mahasiswa/route.ts b/app/api/keloladata/data-mahasiswa/route.ts new file mode 100644 index 0000000..8789fd8 --- /dev/null +++ b/app/api/keloladata/data-mahasiswa/route.ts @@ -0,0 +1,241 @@ +import { NextRequest, NextResponse } from 'next/server'; +import supabase from '@/lib/db'; + +// GET - Fetch all mahasiswa or a specific one by NIM +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const nim = searchParams.get('nim'); + + if (nim) { + // Fetch specific mahasiswa by NIM with joins + const { data, error } = await supabase + .from('mahasiswa') + .select(` + *, + kelompok_keahlian!id_kelompok_keahlian(id_kk, nama_kelompok) + `) + .eq('nim', nim) + .single(); + + if (error || !data) { + return NextResponse.json({ message: 'Mahasiswa not found' }, { status: 404 }); + } + + // Transform the data to flatten the joined fields + const transformedData = { + ...data, + nama_kelompok_keahlian: data.kelompok_keahlian?.nama_kelompok || null + }; + delete transformedData.kelompok_keahlian; + + return NextResponse.json(transformedData); + } else { + // Fetch all mahasiswa with joins + const { data, error } = await supabase + .from('mahasiswa') + .select(` + *, + kelompok_keahlian!id_kelompok_keahlian(id_kk, nama_kelompok) + `) + .order('nim'); + + if (error) { + console.error('Error fetching data:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } + + // Transform the data to flatten the joined fields + const transformedData = data.map(item => ({ + ...item, + nama_kelompok_keahlian: item.kelompok_keahlian?.nama_kelompok || null + })).map(({ kelompok_keahlian, ...rest }) => rest); + + return NextResponse.json(transformedData); + } + } catch (error) { + console.error('Error fetching data:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } +} + +// POST - Create a new mahasiswa +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { + nim, + nama, + jk, + agama, + kabupaten, + provinsi, + jenis_pendaftaran, + tahun_angkatan, + ipk, + id_kelompok_keahlian, + status_kuliah, + semester + } = body; + + // Validate required fields + if (!nim || !nama || !jk || !tahun_angkatan) { + return NextResponse.json( + { message: 'Missing required fields: nim, nama, jk, tahun_angkatan' }, + { status: 400 } + ); + } + + // Check if mahasiswa already exists + const { data: existing, error: checkError } = await supabase + .from('mahasiswa') + .select('nim') + .eq('nim', nim) + .single(); + + if (existing) { + return NextResponse.json( + { message: 'Mahasiswa with this NIM already exists' }, + { status: 409 } + ); + } + + // Insert new mahasiswa + const { data, error } = await supabase + .from('mahasiswa') + .insert({ + nim, + nama, + jk, + agama: agama || null, + kabupaten: kabupaten || null, + provinsi: provinsi || null, + jenis_pendaftaran: jenis_pendaftaran || null, + tahun_angkatan, + ipk: ipk || null, + id_kelompok_keahlian: id_kelompok_keahlian || null, + status_kuliah: status_kuliah || "Aktif", + semester: semester || 1 + }) + .select() + .single(); + + if (error) { + console.error('Error creating mahasiswa:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } + + return NextResponse.json( + { message: 'Mahasiswa created successfully', nim }, + { status: 201 } + ); + } catch (error) { + console.error('Error creating mahasiswa:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } +} + +// PUT - Update an existing mahasiswa +export async function PUT(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const nim = searchParams.get('nim'); + + if (!nim) { + return NextResponse.json({ message: 'NIM is required' }, { status: 400 }); + } + + const body = await request.json(); + const { + nama, + jk, + agama, + kabupaten, + provinsi, + jenis_pendaftaran, + tahun_angkatan, + ipk, + id_kelompok_keahlian, + status_kuliah, + semester + } = body; + + // Check if mahasiswa exists + const { data: existing, error: checkError } = await supabase + .from('mahasiswa') + .select('*') + .eq('nim', nim) + .single(); + + if (checkError || !existing) { + return NextResponse.json({ message: 'Mahasiswa not found' }, { status: 404 }); + } + + // Update mahasiswa + const { error } = await supabase + .from('mahasiswa') + .update({ + nama: nama || existing.nama, + jk: jk || existing.jk, + agama: agama || existing.agama, + kabupaten: kabupaten || existing.kabupaten, + provinsi: provinsi || existing.provinsi, + jenis_pendaftaran: jenis_pendaftaran || existing.jenis_pendaftaran, + tahun_angkatan: tahun_angkatan || existing.tahun_angkatan, + ipk: ipk || existing.ipk, + id_kelompok_keahlian: id_kelompok_keahlian || existing.id_kelompok_keahlian, + status_kuliah: status_kuliah || existing.status_kuliah, + semester: semester || existing.semester + }) + .eq('nim', nim); + + if (error) { + console.error('Error updating mahasiswa:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } + + return NextResponse.json({ message: 'Mahasiswa updated successfully', nim }); + } catch (error) { + console.error('Error updating mahasiswa:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } +} + +// DELETE - Delete a mahasiswa +export async function DELETE(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const nim = searchParams.get('nim'); + + if (!nim) { + return NextResponse.json({ message: 'NIM is required' }, { status: 400 }); + } + + // Check if mahasiswa exists + const { data: existing, error: checkError } = await supabase + .from('mahasiswa') + .select('nim') + .eq('nim', nim) + .single(); + + if (checkError || !existing) { + return NextResponse.json({ message: 'Mahasiswa not found' }, { status: 404 }); + } + + // Delete mahasiswa + const { error } = await supabase + .from('mahasiswa') + .delete() + .eq('nim', nim); + + if (error) { + console.error('Error deleting mahasiswa:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } + + return NextResponse.json({ message: 'Mahasiswa deleted successfully' }); + } catch (error) { + console.error('Error deleting mahasiswa:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/keloladata/data-mahasiswa/upload/route.ts b/app/api/keloladata/data-mahasiswa/upload/route.ts new file mode 100644 index 0000000..88f2dc5 --- /dev/null +++ b/app/api/keloladata/data-mahasiswa/upload/route.ts @@ -0,0 +1,360 @@ +import { NextRequest, NextResponse } from 'next/server'; +import supabase from '@/lib/db'; +import * as XLSX from 'xlsx'; + +export async function POST(request: NextRequest) { + try { + // Get form data from request + const formData = await request.formData(); + const file = formData.get('file') as File; + + if (!file) { + return NextResponse.json({ message: 'No file uploaded' }, { status: 400 }); + } + + // Read file content as array buffer + const fileBuffer = await file.arrayBuffer(); + + // Process file data based on file type + let validData = []; + let errors: string[] = []; + + if (file.name.endsWith('.csv') || file.type === 'text/csv') { + // Process as CSV + const fileContent = await file.text(); + const result = await processCSVData(fileContent); + validData = result.validData; + errors = result.errors; + } else { + // Process as Excel + const result = await processExcelData(fileBuffer); + validData = result.validData; + errors = result.errors; + } + + if (validData.length === 0) { + return NextResponse.json({ + message: 'No valid data found in the file', + errors + }, { status: 400 }); + } + + // Insert valid data into the database + const { insertedCount, errorCount } = await insertDataToDatabase(validData); + + return NextResponse.json({ + message: 'File processed successfully', + insertedCount, + errorCount, + errors: errors.length > 0 ? errors : undefined + }); + + } catch (error) { + console.error('Error processing file upload:', error); + return NextResponse.json({ + message: 'Error processing file upload', + error: (error as Error).message + }, { status: 500 }); + } +} + +// Function to process Excel data +async function processExcelData(fileBuffer: ArrayBuffer) { + try { + // Parse Excel file + const workbook = XLSX.read(fileBuffer, { type: 'array' }); + + // Get first sheet + const sheetName = workbook.SheetNames[0]; + const worksheet = workbook.Sheets[sheetName]; + + // Convert to JSON with proper typing + const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as any[][]; + + if (jsonData.length === 0) { + return { validData: [], errors: ['Excel file is empty'] }; + } + + // Convert Excel data to CSV-like format for processing + const headers = jsonData[0].map(h => String(h).toLowerCase()); + const rows = jsonData.slice(1); + + // Process the data using the common function + return processData(headers, rows); + } catch (error) { + console.error('Error processing Excel data:', error); + return { validData: [], errors: [(error as Error).message] }; + } +} + +// Function to process CSV data +async function processCSVData(fileContent: string) { + const lines = fileContent.split(/\r?\n/).filter(line => line.trim() !== ''); + + if (lines.length === 0) { + return { validData: [], errors: ['CSV file is empty'] }; + } + + // Get headers from first line + const headerLine = lines[0].toLowerCase(); + const headers = headerLine.split(',').map(h => h.trim()); + + // Process data rows + const rows = lines.slice(1).map(line => line.split(',').map(v => v.trim())); + + return processData(headers, rows); +} + +// Common function to process data regardless of source format +function processData(headers: string[], rows: any[][]) { + // Define expected headers and their possible variations + const expectedHeaderMap = { + nim: ['nim'], + nama: ['nama', 'name'], + jenis_kelamin: ['jenis_kelamin', 'jk', 'gender'], + agama: ['agama', 'religion'], + kabupaten: ['kabupaten', 'kota', 'city'], + provinsi: ['provinsi', 'province'], + jenis_pendaftaran: ['jenis_pendaftaran', 'jalur_masuk', 'admission_type'], + tahun_angkatan: ['tahun_angkatan', 'angkatan', 'tahun', 'year'], + ipk: ['ipk', 'gpa'], + kelompok_keahlian: ['kelompok_keahlian', 'kk', 'keahlian', 'id_kk'], + status_kuliah: ['status_kuliah', 'status', 'status_mahasiswa'], + semester: ['semester', 'sem'] + }; + + // Map actual headers to expected headers + const headerMap: { [key: string]: number } = {}; + for (const [expectedHeader, variations] of Object.entries(expectedHeaderMap)) { + const index = headers.findIndex(h => variations.includes(h)); + if (index !== -1) { + headerMap[expectedHeader] = index; + } + } + + // Check required headers + const requiredHeaders = ['nim', 'nama', 'jenis_kelamin', 'tahun_angkatan']; + const missingHeaders = requiredHeaders.filter(h => headerMap[h] === undefined); + + if (missingHeaders.length > 0) { + return { + validData: [], + errors: [`Missing required headers: ${missingHeaders.join(', ')}`] + }; + } + + const validData = []; + const errors = []; + + // Process data rows + for (let i = 0; i < rows.length; i++) { + const values = rows[i]; + if (!values || values.length === 0) continue; + + try { + // Extract values using header map + const nim = String(values[headerMap.nim] || ''); + const nama = String(values[headerMap.nama] || ''); + const jenis_kelamin = mapGender(String(values[headerMap.jenis_kelamin] || '')); + const tahun_angkatan = String(values[headerMap.tahun_angkatan] || ''); + + // Optional fields + const agama = headerMap.agama !== undefined ? String(values[headerMap.agama] || '') || null : null; + const kabupaten = headerMap.kabupaten !== undefined ? String(values[headerMap.kabupaten] || '') || null : null; + const provinsi = headerMap.provinsi !== undefined ? String(values[headerMap.provinsi] || '') || null : null; + const jenis_pendaftaran = headerMap.jenis_pendaftaran !== undefined ? String(values[headerMap.jenis_pendaftaran] || '') || null : null; + + // Handle IPK (could be number or string) + let ipk = null; + if (headerMap.ipk !== undefined && values[headerMap.ipk] !== undefined && values[headerMap.ipk] !== null) { + const ipkValue = values[headerMap.ipk]; + const ipkStr = typeof ipkValue === 'number' ? ipkValue.toString() : String(ipkValue); + ipk = ipkStr ? parseFloat(ipkStr) : null; + } + + // Handle kelompok_keahlian (could be number or string) + let kelompok_keahlian_id = null; + if (headerMap.kelompok_keahlian !== undefined && values[headerMap.kelompok_keahlian] !== undefined) { + const kkValue = values[headerMap.kelompok_keahlian]; + if (kkValue !== null && kkValue !== '') { + kelompok_keahlian_id = typeof kkValue === 'number' ? kkValue : parseInt(String(kkValue)); + } + } + + // Handle status_kuliah + let status_kuliah = 'Aktif'; // Default value + if (headerMap.status_kuliah !== undefined && values[headerMap.status_kuliah] !== undefined) { + const statusValue = String(values[headerMap.status_kuliah] || '').trim(); + if (statusValue) { + const mappedStatus = mapStatus(statusValue); + if (mappedStatus) { + status_kuliah = mappedStatus; + } + } + } + + // Handle semester (could be number or string) + let semester = 1; // Default value + if (headerMap.semester !== undefined && values[headerMap.semester] !== undefined) { + const semesterValue = values[headerMap.semester]; + if (semesterValue !== null && semesterValue !== '') { + const semesterNum = typeof semesterValue === 'number' ? semesterValue : parseInt(String(semesterValue)); + if (!isNaN(semesterNum) && semesterNum >= 1 && semesterNum <= 14) { + semester = semesterNum; + } + } + } + + // Validate required fields + if (!nim || !nama || !jenis_kelamin || !tahun_angkatan) { + errors.push(`Row ${i+1}: Missing required fields`); + continue; + } + + // Validate NIM format (should be alphanumeric and proper length) + if (!/^[A-Za-z0-9]{8,12}$/.test(nim)) { + errors.push(`Row ${i+1}: Invalid NIM format - ${nim}`); + continue; + } + + // Validate tahun_angkatan format (should be 4 digits) + if (!/^\d{4}$/.test(tahun_angkatan)) { + errors.push(`Row ${i+1}: Invalid tahun_angkatan format - ${tahun_angkatan}`); + continue; + } + + // Validate IPK if provided + if (ipk !== null && (isNaN(ipk) || ipk < 0 || ipk > 4)) { + errors.push(`Row ${i+1}: Invalid IPK value - ${ipk}`); + continue; + } + + // Validate semester if provided + if (semester < 1 || semester > 14) { + errors.push(`Row ${i+1}: Invalid semester value - ${semester} (must be between 1-14)`); + continue; + } + + // Add to valid data + validData.push({ + nim, + nama, + jk: jenis_kelamin, + agama, + kabupaten, + provinsi, + jenis_pendaftaran, + tahun_angkatan, + ipk, + kelompok_keahlian_id, + status_kuliah, + semester + }); + + } catch (error) { + errors.push(`Row ${i+1}: Error processing row - ${(error as Error).message}`); + } + } + + return { validData, errors }; +} + +// Function to map gender values to standardized format +function mapGender(value: string): 'Pria' | 'Wanita' | null { + if (!value) return null; + + const lowerValue = value.toLowerCase(); + + if (['pria', 'laki-laki', 'laki', 'l', 'male', 'm', 'p'].includes(lowerValue)) { + return 'Pria'; + } + + if (['wanita', 'perempuan', 'w', 'female', 'f', 'woman', 'w'].includes(lowerValue)) { + return 'Wanita'; + } + + return null; +} + +// Function to map status values to standardized format +function mapStatus(value: string): 'Aktif' | 'Cuti' | 'Lulus' | 'Non-Aktif' | null { + if (!value) return null; + + const lowerValue = value.toLowerCase(); + + if (['aktif', 'active', 'a'].includes(lowerValue)) { + return 'Aktif'; + } + + if (['cuti', 'leave', 'c'].includes(lowerValue)) { + return 'Cuti'; + } + + if (['lulus', 'graduated', 'graduate', 'l'].includes(lowerValue)) { + return 'Lulus'; + } + + if (['non-aktif', 'non aktif', 'nonaktif', 'non-aktif', 'non aktif', 'nonaktif', 'n'].includes(lowerValue)) { + return 'Non-Aktif'; + } + + return null; +} + +// Function to insert data into database +async function insertDataToDatabase(data: any[]) { + let insertedCount = 0; + let errorCount = 0; + + for (const item of data) { + try { + // Check if mahasiswa already exists + const { data: existingData } = await supabase + .from('mahasiswa') + .select('nim') + .eq('nim', item.nim) + .single(); + + const mahasiswaData = { + nama: item.nama, + jk: item.jk, + agama: item.agama, + kabupaten: item.kabupaten, + provinsi: item.provinsi, + jenis_pendaftaran: item.jenis_pendaftaran, + tahun_angkatan: item.tahun_angkatan, + ipk: item.ipk, + id_kelompok_keahlian: item.kelompok_keahlian_id, + status_kuliah: item.status_kuliah, + semester: item.semester + }; + + if (existingData) { + // Update existing record + const { error } = await supabase + .from('mahasiswa') + .update(mahasiswaData) + .eq('nim', item.nim); + + if (error) throw error; + } else { + // Insert new record + const { error } = await supabase + .from('mahasiswa') + .insert({ + nim: item.nim, + ...mahasiswaData + }); + + if (error) throw error; + } + + insertedCount++; + } catch (error) { + console.error(`Error inserting/updating record for NIM ${item.nim}:`, error); + errorCount++; + } + } + + return { insertedCount, errorCount }; +} \ No newline at end of file diff --git a/app/api/keloladata/data-prestasi-mahasiswa/route.ts b/app/api/keloladata/data-prestasi-mahasiswa/route.ts new file mode 100644 index 0000000..9806244 --- /dev/null +++ b/app/api/keloladata/data-prestasi-mahasiswa/route.ts @@ -0,0 +1,336 @@ +import { NextRequest, NextResponse } from 'next/server'; +import supabase from '@/lib/db'; + +// GET - Fetch all prestasi mahasiswa or filter by criteria +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + const id_mahasiswa = searchParams.get('id_mahasiswa'); + const search = searchParams.get('search'); + const jenisPrestasi = searchParams.get('jenis_prestasi'); + const tingkat = searchParams.get('tingkat'); + + // If ID is provided, fetch specific prestasi by ID with join + if (id) { + const { data, error } = await supabase + .from('prestasi_mahasiswa') + .select(` + *, + mahasiswa!inner(nama, nim) + `) + .eq('id_prestasi', id) + .single(); + + if (error || !data) { + return NextResponse.json({ message: 'Prestasi mahasiswa not found' }, { status: 404 }); + } + + // Transform the data to flatten the nama and nim fields + const transformedData = { + ...data, + nama: data.mahasiswa.nama, + nim: data.mahasiswa.nim + }; + delete transformedData.mahasiswa; + + return NextResponse.json(transformedData); + } + + // If id_mahasiswa is provided, fetch prestasi for specific student with join + if (id_mahasiswa) { + const { data, error } = await supabase + .from('prestasi_mahasiswa') + .select(` + *, + mahasiswa!inner(nama, nim) + `) + .eq('id_mahasiswa', id_mahasiswa) + .order('tanggal_prestasi', { ascending: false }); + + if (error) { + console.error('Error fetching data:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } + + // Transform the data to flatten the nama and nim fields + const transformedData = data.map(item => ({ + ...item, + nama: item.mahasiswa.nama, + nim: item.mahasiswa.nim + })).map(({ mahasiswa, ...rest }) => rest); + + return NextResponse.json(transformedData); + } + + // Build the query based on filters with join + let query = supabase.from('prestasi_mahasiswa').select(` + *, + mahasiswa!inner(nama, nim) + `); + + // Add search condition if provided + if (search) { + query = query.or(`mahasiswa.nama.ilike.%${search}%,mahasiswa.nim.ilike.%${search}%,nama_prestasi.ilike.%${search}%,peringkat.ilike.%${search}%,keterangan.ilike.%${search}%`); + } + + // Add jenis_prestasi filter if provided + if (jenisPrestasi) { + query = query.eq('jenis_prestasi', jenisPrestasi); + } + + // Add tingkat filter if provided + if (tingkat) { + query = query.eq('tingkat_prestasi', tingkat); + } + + // Add order by + query = query.order('tanggal_prestasi', { ascending: false }); + + // Execute the query + const { data, error } = await query; + + if (error) { + console.error('Error fetching data:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } + + // Transform the data to flatten the nama and nim fields + const transformedData = data.map(item => ({ + ...item, + nama: item.mahasiswa.nama, + nim: item.mahasiswa.nim + })).map(({ mahasiswa, ...rest }) => rest); + + return NextResponse.json(transformedData); + } catch (error) { + console.error('Error fetching data:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } +} + +// POST - Create a new prestasi mahasiswa +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { + nim, + jenis_prestasi, + nama_prestasi, + tingkat_prestasi, + peringkat, + tanggal_prestasi, + keterangan + } = body; + + // Validate required fields + if (!nim || !jenis_prestasi || !nama_prestasi || !tingkat_prestasi || !peringkat || !tanggal_prestasi) { + return NextResponse.json( + { message: 'Missing required fields: nim, jenis_prestasi, nama_prestasi, tingkat_prestasi, peringkat, tanggal_prestasi' }, + { status: 400 } + ); + } + + // Check if mahasiswa exists by NIM and get id_mahasiswa + const { data: mahasiswaExists, error: checkError } = await supabase + .from('mahasiswa') + .select('id_mahasiswa, nama') + .eq('nim', nim) + .single(); + + if (checkError || !mahasiswaExists) { + return NextResponse.json( + { message: `Mahasiswa dengan NIM ${nim} tidak terdaftar` }, + { status: 404 } + ); + } + + // Validate enum values + const validJenisPrestasi = ['Akademik', 'Non-Akademik']; + const validTingkat = ['Kabupaten', 'Provinsi', 'Nasional', 'Internasional']; + + if (!validJenisPrestasi.includes(jenis_prestasi)) { + return NextResponse.json( + { message: 'Invalid jenis_prestasi value. Must be one of: Akademik, Non-Akademik' }, + { status: 400 } + ); + } + + if (!validTingkat.includes(tingkat_prestasi)) { + return NextResponse.json( + { message: 'Invalid tingkat_prestasi value. Must be one of: Kabupaten, Provinsi, Nasional, Internasional' }, + { status: 400 } + ); + } + + // Insert new prestasi using id_mahasiswa + const { data, error } = await supabase + .from('prestasi_mahasiswa') + .insert({ + id_mahasiswa: mahasiswaExists.id_mahasiswa, + jenis_prestasi, + nama_prestasi, + tingkat_prestasi, + peringkat, + tanggal_prestasi, + keterangan: keterangan || null + }) + .select() + .single(); + + if (error) { + console.error('Error creating prestasi mahasiswa:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } + + return NextResponse.json( + { + message: `Prestasi berhasil ditambahkan`, + id: data.id_prestasi + }, + { status: 201 } + ); + } catch (error) { + console.error('Error creating prestasi mahasiswa:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } +} + +// PUT - Update an existing prestasi mahasiswa +export async function PUT(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + + if (!id) { + return NextResponse.json({ message: 'ID is required' }, { status: 400 }); + } + + const body = await request.json(); + const { + nim, + jenis_prestasi, + nama_prestasi, + tingkat_prestasi, + peringkat, + tanggal_prestasi, + keterangan + } = body; + + // Validate required fields + if (!nim || !jenis_prestasi || !nama_prestasi || !tingkat_prestasi || !peringkat || !tanggal_prestasi) { + return NextResponse.json( + { message: 'Missing required fields: nim, jenis_prestasi, nama_prestasi, tingkat_prestasi, peringkat, tanggal_prestasi' }, + { status: 400 } + ); + } + + // Check if prestasi exists + const { data: existing, error: checkError } = await supabase + .from('prestasi_mahasiswa') + .select('*') + .eq('id_prestasi', id) + .single(); + + if (checkError || !existing) { + return NextResponse.json({ message: 'Prestasi mahasiswa not found' }, { status: 404 }); + } + + // Check if mahasiswa exists by NIM and get id_mahasiswa + const { data: mahasiswaExists, error: mahasiswaCheckError } = await supabase + .from('mahasiswa') + .select('id_mahasiswa, nama') + .eq('nim', nim) + .single(); + + if (mahasiswaCheckError || !mahasiswaExists) { + return NextResponse.json( + { message: `Mahasiswa dengan NIM ${nim} tidak terdaftar` }, + { status: 404 } + ); + } + + // Validate enum values + const validJenisPrestasi = ['Akademik', 'Non-Akademik']; + const validTingkat = ['Kabupaten', 'Provinsi', 'Nasional', 'Internasional']; + + if (!validJenisPrestasi.includes(jenis_prestasi)) { + return NextResponse.json( + { message: 'Invalid jenis_prestasi value. Must be one of: Akademik, Non-Akademik' }, + { status: 400 } + ); + } + + if (!validTingkat.includes(tingkat_prestasi)) { + return NextResponse.json( + { message: 'Invalid tingkat_prestasi value. Must be one of: Kabupaten, Provinsi, Nasional, Internasional' }, + { status: 400 } + ); + } + + // Update prestasi using id_mahasiswa + const { error } = await supabase + .from('prestasi_mahasiswa') + .update({ + id_mahasiswa: mahasiswaExists.id_mahasiswa, + jenis_prestasi, + nama_prestasi, + tingkat_prestasi, + peringkat, + tanggal_prestasi, + keterangan: keterangan || null + }) + .eq('id_prestasi', id); + + if (error) { + console.error('Error updating prestasi mahasiswa:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } + + return NextResponse.json({ + message: `Prestasi berhasil diperbarui` + }); + } catch (error) { + console.error('Error updating prestasi mahasiswa:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } +} + +// DELETE - Delete a prestasi mahasiswa +export async function DELETE(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + + if (!id) { + return NextResponse.json({ message: 'ID is required' }, { status: 400 }); + } + + // Check if prestasi exists + const { data: existing, error: checkError } = await supabase + .from('prestasi_mahasiswa') + .select('id_prestasi') + .eq('id_prestasi', id) + .single(); + + if (checkError || !existing) { + return NextResponse.json({ message: 'Prestasi mahasiswa not found' }, { status: 404 }); + } + + // Delete prestasi + const { error } = await supabase + .from('prestasi_mahasiswa') + .delete() + .eq('id_prestasi', id); + + if (error) { + console.error('Error deleting prestasi mahasiswa:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } + + return NextResponse.json({ message: 'Prestasi mahasiswa deleted successfully' }); + } catch (error) { + console.error('Error deleting prestasi mahasiswa:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/keloladata/data-prestasi-mahasiswa/upload/route.ts b/app/api/keloladata/data-prestasi-mahasiswa/upload/route.ts new file mode 100644 index 0000000..a28130f --- /dev/null +++ b/app/api/keloladata/data-prestasi-mahasiswa/upload/route.ts @@ -0,0 +1,470 @@ +import { NextRequest, NextResponse } from 'next/server'; +import * as XLSX from 'xlsx'; +import supabase from '@/lib/db'; + +export async function POST(request: NextRequest) { + try { + const formData = await request.formData(); + const file = formData.get('file') as File; + + if (!file) { + return NextResponse.json({ message: 'File tidak ditemukan' }, { status: 400 }); + } + + let validData = []; + let errors: string[] = []; + + if (file.name.endsWith('.csv') || file.type === 'text/csv') { + const fileContent = await file.text(); + const result = await processCSVData(fileContent); + validData = result.validData; + errors = result.errors; + } else { + const fileBuffer = await file.arrayBuffer(); + const result = await processExcelData(fileBuffer); + validData = result.validData; + errors = result.errors; + } + + if (validData.length === 0) { + return NextResponse.json({ + message: 'Tidak ada data valid yang ditemukan dalam file', + errors + }, { status: 400 }); + } + + const { imported, errorCount, errorMessages } = await insertDataToDatabase(validData); + const allErrors = [...errors, ...errorMessages]; + + return NextResponse.json({ + message: 'Upload berhasil', + imported, + errors: errorCount, + errorDetails: allErrors.length > 0 ? allErrors : undefined + }); + } catch (error) { + console.error('Error uploading file:', error); + return NextResponse.json( + { message: `Terjadi kesalahan: ${(error as Error).message}` }, + { status: 500 } + ); + } +} + +async function processExcelData(fileBuffer: ArrayBuffer) { + try { + const workbook = XLSX.read(fileBuffer, { + type: 'array', + cellDates: true, + dateNF: 'yyyy-mm-dd' + }); + const sheetName = workbook.SheetNames[0]; + const worksheet = workbook.Sheets[sheetName]; + + let jsonData = XLSX.utils.sheet_to_json(worksheet, { + header: 1, + raw: false, + dateNF: 'yyyy-mm-dd' + }) as any[][]; + + if (jsonData.length === 0) { + return { validData: [], errors: ['File Excel kosong'] }; + } + + jsonData = jsonData.map(row => { + if (!row) return row; + return row.map(cell => { + if (cell && typeof cell === 'object' && 'toISOString' in cell) { + return cell.toISOString().split('T')[0]; + } + return cell; + }); + }); + + const headers = jsonData[0].map(h => String(h).toLowerCase()); + const rows = jsonData.slice(1); + + return processData(headers, rows); + } catch (error) { + return { validData: [], errors: [(error as Error).message] }; + } +} + +async function processCSVData(fileContent: string) { + const lines = fileContent.split(/\r?\n/).filter(line => line.trim() !== ''); + + if (lines.length === 0) { + return { validData: [], errors: ['File CSV kosong'] }; + } + + const headerLine = lines[0].toLowerCase(); + const headers = headerLine.split(',').map(h => h.trim()); + const rows = lines.slice(1).map(line => line.split(',').map(v => v.trim())); + + return processData(headers, rows); +} + +function processData(headers: string[], rows: any[][]) { + const expectedHeaderMap = { + nim: ['nim', 'nomor induk', 'nomor mahasiswa'], + jenis_prestasi: ['jenis prestasi', 'jenis_prestasi', 'jenisprestasi'], + nama_prestasi: ['nama prestasi', 'nama_prestasi', 'namaprestasi', 'prestasi'], + tingkat_prestasi: ['tingkat prestasi', 'tingkat_prestasi', 'tingkatprestasi', 'tingkat'], + peringkat: ['peringkat', 'ranking', 'juara', 'posisi'], + tanggal_prestasi: ['tanggal prestasi', 'tanggal_prestasi', 'tanggalprestasi', 'tanggal'] + }; + + const headerMap: { [key: string]: number } = {}; + + for (const [expectedHeader, variations] of Object.entries(expectedHeaderMap)) { + const index = headers.findIndex(h => { + if (!h) return false; + const headerStr = String(h).toLowerCase().trim(); + return variations.some(variation => headerStr === variation); + }); + + if (index !== -1) { + headerMap[expectedHeader] = index; + } + } + + for (const [expectedHeader, variations] of Object.entries(expectedHeaderMap)) { + if (headerMap[expectedHeader] !== undefined) continue; + + const index = headers.findIndex(h => { + if (!h) return false; + const headerStr = String(h).toLowerCase().trim(); + return variations.some(variation => headerStr.includes(variation)); + }); + + if (index !== -1) { + headerMap[expectedHeader] = index; + } + } + + const requiredHeaders = ['nim', 'jenis_prestasi', 'nama_prestasi', 'tingkat_prestasi', 'peringkat', 'tanggal_prestasi']; + const missingHeaders = requiredHeaders.filter(h => headerMap[h] === undefined); + + if (missingHeaders.length > 0) { + return { + validData: [], + errors: [`Kolom berikut tidak ditemukan: ${missingHeaders.join(', ')}. Pastikan file memiliki kolom: NIM, Jenis Prestasi, Nama Prestasi, Tingkat Prestasi, Peringkat, dan Tanggal.`] + }; + } + + const validData = []; + const errors = []; + const validJenisPrestasi = ['Akademik', 'Non-Akademik']; + const validTingkatPrestasi = ['Kabupaten', 'Provinsi', 'Nasional', 'Internasional']; + + for (let i = 0; i < rows.length; i++) { + const values = rows[i]; + if (!values || values.length === 0) continue; + + try { + const nim = String(values[headerMap.nim] || '').trim(); + let jenis_prestasi = String(values[headerMap.jenis_prestasi] || '').trim(); + const nama_prestasi = String(values[headerMap.nama_prestasi] || '').trim(); + let tingkat_prestasi = String(values[headerMap.tingkat_prestasi] || '').trim(); + const peringkat = String(values[headerMap.peringkat] || '').trim(); + let tanggal_prestasi = String(values[headerMap.tanggal_prestasi] || '').trim(); + + if (!nim || !jenis_prestasi || !nama_prestasi || !tingkat_prestasi || !peringkat || !tanggal_prestasi) { + const errorMsg = `Baris ${i+2}: Data tidak lengkap (NIM: ${nim || 'kosong'})`; + errors.push(errorMsg); + continue; + } + + jenis_prestasi = normalizeJenisPrestasi(jenis_prestasi); + + if (!validJenisPrestasi.includes(jenis_prestasi)) { + const errorMsg = `Baris ${i+2}: Jenis prestasi tidak valid "${jenis_prestasi}" untuk NIM ${nim}. Harus salah satu dari: ${validJenisPrestasi.join(', ')}`; + errors.push(errorMsg); + continue; + } + + tingkat_prestasi = normalizeTingkatPrestasi(tingkat_prestasi); + + if (!validTingkatPrestasi.includes(tingkat_prestasi)) { + const errorMsg = `Baris ${i+2}: Tingkat prestasi tidak valid "${tingkat_prestasi}" untuk NIM ${nim}. Harus salah satu dari: ${validTingkatPrestasi.join(', ')}`; + errors.push(errorMsg); + continue; + } + + const datePattern = /^\d{4}-\d{2}-\d{2}$/; + if (!datePattern.test(tanggal_prestasi)) { + try { + const ddmmyyyyPattern = /^(\d{1,2})-(\d{1,2})-(\d{4})$/; + const ddmmyyyyMatch = tanggal_prestasi.match(ddmmyyyyPattern); + + const ddmmyyyySlashPattern = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/; + const ddmmyyyySlashMatch = tanggal_prestasi.match(ddmmyyyySlashPattern); + + if (ddmmyyyyMatch) { + const day = ddmmyyyyMatch[1].padStart(2, '0'); + const month = ddmmyyyyMatch[2].padStart(2, '0'); + const year = ddmmyyyyMatch[3]; + + if (parseInt(year) < 1900 || parseInt(year) > 2100) { + const errorMsg = `Baris ${i+2}: Tahun tidak valid "${year}" untuk NIM ${nim}. Tahun harus antara 1900-2100`; + errors.push(errorMsg); + continue; + } + + tanggal_prestasi = `${year}-${month}-${day}`; + } + else if (ddmmyyyySlashMatch) { + const day = ddmmyyyySlashMatch[1].padStart(2, '0'); + const month = ddmmyyyySlashMatch[2].padStart(2, '0'); + const year = ddmmyyyySlashMatch[3]; + + if (parseInt(year) < 1900 || parseInt(year) > 2100) { + const errorMsg = `Baris ${i+2}: Tahun tidak valid "${year}" untuk NIM ${nim}. Tahun harus antara 1900-2100`; + errors.push(errorMsg); + continue; + } + + tanggal_prestasi = `${year}-${month}-${day}`; + } + else { + const numericValue = Number(tanggal_prestasi); + if (!isNaN(numericValue)) { + let dateObj; + + if (numericValue > 60) { + const adjustedValue = numericValue - 1; + const daysToMs = adjustedValue * 24 * 60 * 60 * 1000; + dateObj = new Date(new Date(1899, 11, 30).getTime() + daysToMs); + } else { + const daysToMs = numericValue * 24 * 60 * 60 * 1000; + dateObj = new Date(new Date(1899, 11, 30).getTime() + daysToMs); + } + + if (isValidDate(dateObj)) { + tanggal_prestasi = dateObj.toISOString().split('T')[0]; + } else { + const errorMsg = `Baris ${i+2}: Format tanggal tidak valid "${tanggal_prestasi}" untuk NIM ${nim}. Tahun harus antara 1900-2100`; + errors.push(errorMsg); + continue; + } + } else { + const dateObj = new Date(tanggal_prestasi); + if (!isValidDate(dateObj)) { + const errorMsg = `Baris ${i+2}: Format tanggal tidak valid "${tanggal_prestasi}" untuk NIM ${nim}. Gunakan format DD-MM-YYYY, DD/MM/YYYY, atau YYYY-MM-DD`; + errors.push(errorMsg); + continue; + } + tanggal_prestasi = dateObj.toISOString().split('T')[0]; + } + } + } catch (e) { + const errorMsg = `Baris ${i+2}: Format tanggal tidak valid "${tanggal_prestasi}" untuk NIM ${nim}. Gunakan format DD-MM-YYYY, DD/MM/YYYY, atau YYYY-MM-DD`; + errors.push(errorMsg); + continue; + } + } + + validData.push({ + nim, + jenis_prestasi, + nama_prestasi, + tingkat_prestasi, + peringkat, + tanggal_prestasi, + keterangan: null + }); + + } catch (error) { + const errorMsg = `Baris ${i+2}: Error memproses data - ${(error as Error).message}`; + errors.push(errorMsg); + } + } + + return { validData, errors }; +} + +function normalizeJenisPrestasi(value: string): string { + const lowerValue = value.toLowerCase(); + + if (['akademik', 'academic', 'akademis', 'a'].includes(lowerValue)) { + return 'Akademik'; + } + + if (['non-akademik', 'non akademik', 'nonakademik', 'non academic', 'na', 'n'].includes(lowerValue)) { + return 'Non-Akademik'; + } + + return value; +} + +function normalizeTingkatPrestasi(value: string): string { + const lowerValue = value.toLowerCase(); + + if (['kabupaten', 'kota', 'city', 'kab', 'k'].includes(lowerValue)) { + return 'Kabupaten'; + } + + if (['provinsi', 'province', 'prov', 'p'].includes(lowerValue)) { + return 'Provinsi'; + } + + if (['nasional', 'national', 'nas', 'n'].includes(lowerValue)) { + return 'Nasional'; + } + + if (['internasional', 'international', 'int', 'i'].includes(lowerValue)) { + return 'Internasional'; + } + + return value; +} + +function isValidDate(date: Date): boolean { + return !isNaN(date.getTime()) && + date.getFullYear() >= 1900 && + date.getFullYear() <= 2100; +} + +async function insertDataToDatabase(data: any[]) { + let imported = 0; + let errorCount = 0; + const errorMessages: string[] = []; + + console.log('=== DEBUG: Starting prestasi data insertion process ==='); + console.log(`Total data items to process: ${data.length}`); + console.log('Sample data items:', data.slice(0, 3)); + + // First, validate all NIMs exist before processing + const uniqueNims = [...new Set(data.map(item => item.nim))]; + console.log(`Unique NIMs found: ${uniqueNims.length}`); + console.log('Unique NIMs:', uniqueNims); + + const nimValidationMap = new Map(); + + // Batch check all NIMs for existence + console.log('=== DEBUG: Starting NIM validation ==='); + for (const nim of uniqueNims) { + try { + console.log(`Checking NIM: ${nim}`); + const { data: mahasiswaData, error: checkError } = await supabase + .from('mahasiswa') + .select('id_mahasiswa, nama') + .eq('nim', nim) + .single(); + + if (checkError || !mahasiswaData) { + console.log(`❌ NIM ${nim}: NOT FOUND in database`); + console.log(`Error details:`, checkError); + nimValidationMap.set(nim, { exists: false, error: 'Mahasiswa dengan NIM ini tidak ditemukan dalam database' }); + } else { + console.log(`✅ NIM ${nim}: FOUND - ID: ${mahasiswaData.id_mahasiswa}, Nama: ${mahasiswaData.nama}`); + nimValidationMap.set(nim, { exists: true, id_mahasiswa: mahasiswaData.id_mahasiswa, nama: mahasiswaData.nama }); + } + } catch (error) { + console.log(`❌ NIM ${nim}: ERROR during validation`); + console.log(`Error details:`, error); + nimValidationMap.set(nim, { exists: false, error: `Error checking NIM: ${(error as Error).message}` }); + } + } + + console.log('=== DEBUG: NIM validation results ==='); + console.log('Validation map:', Object.fromEntries(nimValidationMap)); + + // Process each data item + console.log('=== DEBUG: Starting prestasi data processing ==='); + for (const item of data) { + try { + console.log(`\n--- Processing prestasi item: NIM ${item.nim} ---`); + console.log('Item data:', item); + + const nimValidation = nimValidationMap.get(item.nim); + console.log('NIM validation result:', nimValidation); + + if (!nimValidation || !nimValidation.exists) { + errorCount++; + const errorMsg = nimValidation?.error || `NIM ${item.nim}: Mahasiswa dengan NIM ini tidak ditemukan dalam database`; + console.log(`❌ Skipping item - ${errorMsg}`); + errorMessages.push(errorMsg); + continue; + } + + console.log(`✅ NIM ${item.nim} is valid, proceeding with prestasi check/insert`); + + // Check if prestasi already exists for this mahasiswa + console.log(`Checking existing prestasi for mahasiswa ID: ${nimValidation.id_mahasiswa}, nama_prestasi: ${item.nama_prestasi}, tanggal: ${item.tanggal_prestasi}`); + const { data: existingPrestasi, error: prestasiCheckError } = await supabase + .from('prestasi_mahasiswa') + .select('id_prestasi') + .eq('id_mahasiswa', nimValidation.id_mahasiswa) + .eq('nama_prestasi', item.nama_prestasi) + .eq('tanggal_prestasi', item.tanggal_prestasi) + .single(); + + if (prestasiCheckError && prestasiCheckError.code !== 'PGRST116') { + console.log(`❌ Error checking existing prestasi:`, prestasiCheckError); + } + + if (existingPrestasi) { + console.log(`📝 Updating existing prestasi (ID: ${existingPrestasi.id_prestasi})`); + // Update existing prestasi + const { error: updateError } = await supabase + .from('prestasi_mahasiswa') + .update({ + jenis_prestasi: item.jenis_prestasi, + tingkat_prestasi: item.tingkat_prestasi, + peringkat: item.peringkat, + keterangan: item.keterangan || null + }) + .eq('id_prestasi', existingPrestasi.id_prestasi); + + if (updateError) { + errorCount++; + const errorMsg = `NIM ${item.nim} (${nimValidation.nama}): Gagal memperbarui prestasi: ${updateError.message}`; + console.log(`❌ Update failed: ${errorMsg}`); + errorMessages.push(errorMsg); + continue; + } else { + console.log(`✅ Prestasi updated successfully`); + } + } else { + console.log(`📝 Inserting new prestasi for mahasiswa ID: ${nimValidation.id_mahasiswa}`); + // Insert new prestasi + const { error: insertError } = await supabase + .from('prestasi_mahasiswa') + .insert({ + id_mahasiswa: nimValidation.id_mahasiswa, + jenis_prestasi: item.jenis_prestasi, + nama_prestasi: item.nama_prestasi, + tingkat_prestasi: item.tingkat_prestasi, + peringkat: item.peringkat, + tanggal_prestasi: item.tanggal_prestasi, + keterangan: item.keterangan || null + }); + + if (insertError) { + errorCount++; + const errorMsg = `NIM ${item.nim} (${nimValidation.nama}): Gagal menyimpan prestasi: ${insertError.message}`; + console.log(`❌ Insert failed: ${errorMsg}`); + errorMessages.push(errorMsg); + continue; + } else { + console.log(`✅ Prestasi inserted successfully`); + } + } + + imported++; + console.log(`✅ Item processed successfully. Imported count: ${imported}`); + } catch (error) { + console.error(`❌ Error processing record for NIM ${item.nim}:`, error); + errorCount++; + errorMessages.push(`NIM ${item.nim}: Terjadi kesalahan: ${(error as Error).message}`); + } + } + + console.log('=== DEBUG: Final results ==='); + console.log(`Total imported: ${imported}`); + console.log(`Total errors: ${errorCount}`); + console.log(`Error messages:`, errorMessages); + + return { imported, errorCount, errorMessages }; +} \ No newline at end of file diff --git a/app/api/keloladata/setting-jenis-pendaftaran/route.ts b/app/api/keloladata/setting-jenis-pendaftaran/route.ts new file mode 100644 index 0000000..0aa4287 --- /dev/null +++ b/app/api/keloladata/setting-jenis-pendaftaran/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from 'next/server'; +import supabase from '@/lib/db'; + +// GET - Fetch all unique jenis_pendaftaran values +export async function GET() { + try { + // Get all unique jenis_pendaftaran values + const { data, error } = await supabase + .from('mahasiswa') + .select('jenis_pendaftaran') + .not('jenis_pendaftaran', 'is', null) + .order('jenis_pendaftaran'); + + if (error) { + console.error('Error fetching jenis pendaftaran data:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } + + // Get unique values + const uniqueValues = [...new Set(data.map(item => item.jenis_pendaftaran))]; + + return NextResponse.json(uniqueValues.map(value => ({ jenis_pendaftaran: value }))); + } catch (error) { + console.error('Error fetching jenis pendaftaran data:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } +} + +// PUT - Update jenis_pendaftaran value +export async function PUT(request: NextRequest) { + try { + const body = await request.json(); + const { oldValue, newValue } = body; + + // Validate required fields + if (!oldValue || !newValue) { + return NextResponse.json( + { message: 'Missing required fields: oldValue, newValue' }, + { status: 400 } + ); + } + + // Update jenis_pendaftaran + const { data, error } = await supabase + .from('mahasiswa') + .update({ jenis_pendaftaran: newValue }) + .eq('jenis_pendaftaran', oldValue) + .select(); + + if (error) { + console.error('Error updating jenis pendaftaran:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } + + return NextResponse.json({ + message: 'Jenis pendaftaran updated successfully', + affectedRows: data?.length || 0 + }); + } catch (error) { + console.error('Error updating jenis pendaftaran:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/keloladata/update-semester/route.ts b/app/api/keloladata/update-semester/route.ts new file mode 100644 index 0000000..37dc807 --- /dev/null +++ b/app/api/keloladata/update-semester/route.ts @@ -0,0 +1,94 @@ +import { NextResponse } from "next/server"; +import supabase from '@/lib/db'; + +export async function POST() { + try { + // Get current date + const currentDate = new Date(); + const currentYear = currentDate.getFullYear(); + const currentMonth = currentDate.getMonth() + 1; // getMonth() returns 0-11 + + // Get all active students + const { data: activeStudents, error: fetchError } = await supabase + .from('mahasiswa') + .select('nim, tahun_angkatan, semester') + .eq('status_kuliah', 'Aktif'); + + if (fetchError) { + console.error('Error fetching active students:', fetchError); + return NextResponse.json( + { message: "Gagal mengambil data mahasiswa aktif" }, + { status: 500 } + ); + } + + if (!activeStudents || activeStudents.length === 0) { + return NextResponse.json({ + message: "Tidak ada mahasiswa aktif yang ditemukan", + affectedRows: 0 + }); + } + + let updatedCount = 0; + const errors: string[] = []; + + // Update semester for each active student + for (const student of activeStudents) { + try { + const tahunAngkatan = student.tahun_angkatan; + if (!tahunAngkatan) { + errors.push(`Mahasiswa NIM ${student.nim}: Tahun angkatan tidak ditemukan`); + continue; + } + + // Calculate current semester based on tahun_angkatan and current date + const yearsSinceEnrollment = currentYear - tahunAngkatan; + let currentSemester = yearsSinceEnrollment * 2; // 2 semesters per year + + // Adjust for current month (odd months = odd semesters, even months = even semesters) + if (currentMonth >= 2 && currentMonth <= 7) { + // February to July = odd semester (1, 3, 5, etc.) + currentSemester += 1; + } else { + // August to January = even semester (2, 4, 6, etc.) + currentSemester += 2; + } + + // Cap at semester 14 (7 years) + if (currentSemester > 14) { + currentSemester = 14; + } + + // Update semester if different + if (student.semester !== currentSemester) { + const { error: updateError } = await supabase + .from('mahasiswa') + .update({ semester: currentSemester }) + .eq('nim', student.nim); + + if (updateError) { + errors.push(`Mahasiswa NIM ${student.nim}: Gagal memperbarui semester: ${updateError.message}`); + } else { + updatedCount++; + } + } + } catch (error) { + console.error(`Error updating semester for mahasiswa NIM ${student.nim}:`, error); + errors.push(`Mahasiswa NIM ${student.nim}: Terjadi kesalahan: ${(error as Error).message}`); + } + } + + return NextResponse.json({ + message: `Berhasil memperbarui semester untuk ${updatedCount} mahasiswa`, + affectedRows: updatedCount, + errors: errors.length > 0 ? errors : undefined + }); + + } catch (error) { + console.error('Error in update semester:', error); + return NextResponse.json( + { message: "Terjadi kesalahan internal server" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/mahasiswa/status-kuliah/route.ts b/app/api/mahasiswa/status-kuliah/route.ts index 40f5176..39d3830 100644 --- a/app/api/mahasiswa/status-kuliah/route.ts +++ b/app/api/mahasiswa/status-kuliah/route.ts @@ -15,7 +15,7 @@ export async function GET(request: Request) { let query = supabase .from('mahasiswa') .select('tahun_angkatan, status_kuliah') - .in('status_kuliah', ['Lulus', 'Cuti', 'Aktif', 'DO']); + .in('status_kuliah', ['Lulus', 'Cuti', 'Aktif', 'Non-Aktif']); if (tahunAngkatan && tahunAngkatan !== 'all') { query = query.eq('tahun_angkatan', tahunAngkatan); diff --git a/app/api/mahasiswa/status/route.ts b/app/api/mahasiswa/status/route.ts index 429a88f..69aab69 100644 --- a/app/api/mahasiswa/status/route.ts +++ b/app/api/mahasiswa/status/route.ts @@ -12,7 +12,7 @@ export async function GET() { const { data, error } = await supabase .from('mahasiswa') .select('status_kuliah, tahun_angkatan') - .in('status_kuliah', ['Lulus', 'Cuti', 'Aktif', 'DO']); + .in('status_kuliah', ['Lulus', 'Cuti', 'Aktif', 'Non-Aktif']); if (error) { console.error('Error fetching status data:', error); diff --git a/app/api/mahasiswa/total/route.ts b/app/api/mahasiswa/total/route.ts index af80d47..5d99d4d 100644 --- a/app/api/mahasiswa/total/route.ts +++ b/app/api/mahasiswa/total/route.ts @@ -37,7 +37,7 @@ export async function OPTIONS() { export async function GET() { try { - // Get total mahasiswa + // jumlah mahasiswa const { count: totalMahasiswa } = await supabase .from('mahasiswa') .select('*', { count: 'exact', head: true }); diff --git a/app/dashboard/mahasiswa/total/page.tsx b/app/dashboard/mahasiswa/total/page.tsx index cc83ac9..12064c9 100644 --- a/app/dashboard/mahasiswa/total/page.tsx +++ b/app/dashboard/mahasiswa/total/page.tsx @@ -9,7 +9,6 @@ import IPKChart from "@/components/charts/IPKChart"; import FilterTahunAngkatan from "@/components/FilterTahunAngkatan"; import JenisPendaftaranPerAngkatanChart from "@/components/charts/JenisPendaftaranPerAngkatanChart"; import AsalDaerahPerAngkatanChart from "@/components/charts/AsalDaerahPerAngkatanChart"; -import IPKJenisKelaminChart from "@/components/charts/IPKJenisKelaminChart"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; export default function TotalMahasiswaPage() { diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 3c95c5b..da8bb69 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -9,6 +9,10 @@ import StatistikMahasiswaChart from "@/components/charts/StatistikMahasiswaChart import JenisPendaftaranChart from "@/components/charts/JenisPendaftaranChart"; import AsalDaerahChart from "@/components/charts/AsalDaerahChart"; import IPKChart from '@/components/charts/IPKChart'; +import FilterTahunAngkatan from "@/components/FilterTahunAngkatan"; +import StatistikPerAngkatanChart from "@/components/charts/StatistikPerAngkatanChart"; +import JenisPendaftaranPerAngkatanChart from "@/components/charts/JenisPendaftaranPerAngkatanChart"; +import AsalDaerahPerAngkatanChart from "@/components/charts/AsalDaerahPerAngkatanChart"; interface MahasiswaTotal { total_mahasiswa: number; @@ -56,7 +60,7 @@ export default function DashboardPage() { ipk_rata_rata_lulus: 0, total_mahasiswa_aktif_lulus: 0 }); - + const [selectedYear, setSelectedYear] = useState("all"); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -196,20 +200,37 @@ export default function DashboardPage() { - {/* Diagram Statistik Mahasiswa */} - + + + + Filter Data + + + +
+ +
+
+
- {/* Diagram Status Mahasiswa */} - - - {/* Diagram Jenis Pendaftaran */} - - - {/* Diagram Asal Daerah */} - - - {/* Diagram IPK */} - + {selectedYear === "all" ? ( +
+ + + + + +
+ ) : ( +
+ + + +
+ )} )} diff --git a/app/keloladata/beasiswa/page.tsx b/app/keloladata/beasiswa/page.tsx new file mode 100644 index 0000000..1d36dc9 --- /dev/null +++ b/app/keloladata/beasiswa/page.tsx @@ -0,0 +1,9 @@ +import DataTableBeasiswaMahasiswa from "@/components/data-table-beasiswa-mahasiswa"; + +export default function BeasiswaPage() { + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/app/keloladata/mahasiswa/page.tsx b/app/keloladata/mahasiswa/page.tsx new file mode 100644 index 0000000..d3aefc5 --- /dev/null +++ b/app/keloladata/mahasiswa/page.tsx @@ -0,0 +1,9 @@ +import DataTableMahasiswa from "@/components/datatable/data-table-mahasiswa"; + +export default function DashboardPage() { + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/app/keloladata/prestasi/page.tsx b/app/keloladata/prestasi/page.tsx new file mode 100644 index 0000000..0fca1da --- /dev/null +++ b/app/keloladata/prestasi/page.tsx @@ -0,0 +1,9 @@ +import DataTablePrestasiMahasiswa from "@/components/data-table-prestasi-mahasiswa"; + +export default function PrestasiPage() { + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index b9719d5..ba6607c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,7 +1,7 @@ import type { Metadata } from 'next'; import { Geist, Geist_Mono } from 'next/font/google'; import './globals.css'; -import { ThemeProvider } from '@/components/theme-provider'; +import ClientLayout from '@/components/ClientLayout'; const geistSans = Geist({ variable: '--font-geist-sans', @@ -27,9 +27,13 @@ export default function RootLayout({ + + + + - {children} + {children} ); diff --git a/app/page.tsx b/app/page.tsx index 1fc7a13..24066e6 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,390 +1,238 @@ 'use client'; -import { useState, useEffect } from 'react'; -import { useRouter } from 'next/navigation'; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Loader2, Eye, EyeOff } from "lucide-react"; -import { ThemeProvider } from '@/components/theme-provider'; -import { Toaster } from '@/components/ui/toaster'; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Users, GraduationCap, Trophy, BookOpen } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useTheme } from "next-themes"; +import StatusMahasiswaChart from "@/components/charts/StatusMahasiswaChart"; +import StatistikMahasiswaChart from "@/components/charts/StatistikMahasiswaChart"; +import JenisPendaftaranChart from "@/components/charts/JenisPendaftaranChart"; +import AsalDaerahChart from "@/components/charts/AsalDaerahChart"; +import IPKChart from '@/components/charts/IPKChart'; +import FilterTahunAngkatan from "@/components/FilterTahunAngkatan"; +import StatistikPerAngkatanChart from "@/components/charts/StatistikPerAngkatanChart"; +import JenisPendaftaranPerAngkatanChart from "@/components/charts/JenisPendaftaranPerAngkatanChart"; +import AsalDaerahPerAngkatanChart from "@/components/charts/AsalDaerahPerAngkatanChart"; -export default function LandingPage() { - const router = useRouter(); - const [isLoginOpen, setIsLoginOpen] = useState(true); - const [isRegisterOpen, setIsRegisterOpen] = useState(false); - const [activeTab, setActiveTab] = useState('dosen'); - const [loading, setLoading] = useState(false); - const [showPassword, setShowPassword] = useState(false); - const [error, setError] = useState(''); - - // Admin form state - const [adminForm, setAdminForm] = useState({ - username: '', - password: '' - }); - - // Dosen form state - const [dosenForm, setDosenForm] = useState({ - nip: '', - password: '' +interface MahasiswaTotal { + total_mahasiswa: number; + mahasiswa_aktif: number; + total_lulus: number; + pria_lulus: number; + wanita_lulus: number; + total_berprestasi: number; + prestasi_akademik: number; + prestasi_non_akademik: number; + ipk_rata_rata_aktif: number; + ipk_rata_rata_lulus: number; + total_mahasiswa_aktif_lulus: number; +} + +// Skeleton loading component +const CardSkeleton = () => ( + + +
+
+
+ +
+
+
+
+
+
+
+); + +export default function DashboardPage() { + const { theme } = useTheme(); + const [mahasiswaData, setMahasiswaData] = useState({ + total_mahasiswa: 0, + mahasiswa_aktif: 0, + total_lulus: 0, + pria_lulus: 0, + wanita_lulus: 0, + total_berprestasi: 0, + prestasi_akademik: 0, + prestasi_non_akademik: 0, + ipk_rata_rata_aktif: 0, + ipk_rata_rata_lulus: 0, + total_mahasiswa_aktif_lulus: 0 }); + const [selectedYear, setSelectedYear] = useState("all"); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); - // Register form state - const [registerForm, setRegisterForm] = useState({ - nip: '', - password: '', - confirmPassword: '' - }); + useEffect(() => { + const fetchData = async () => { + try { + // Menggunakan cache API untuk mempercepat loading + const cacheKey = 'mahasiswa-total-data'; + const cachedData = sessionStorage.getItem(cacheKey); + const cachedTimestamp = sessionStorage.getItem(`${cacheKey}-timestamp`); + + // Cek apakah data cache masih valid (kurang dari 60 detik) + const isCacheValid = cachedTimestamp && + (Date.now() - parseInt(cachedTimestamp)) < 60000; - const handleAdminLogin = async (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); - setError(''); - - try { - const response = await fetch('/api/auth/login', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - username: adminForm.username, - password: adminForm.password, - role: 'admin' - }), + if (cachedData && isCacheValid) { + setMahasiswaData(JSON.parse(cachedData)); + } + + // Fetch data total mahasiswa + const totalResponse = await fetch('/api/mahasiswa/total', { + cache: 'no-store', }); - const data = await response.json(); - - if (response.ok) { - setIsLoginOpen(false); - router.push('/dashboard'); - } else { - setError(data.error || 'Login gagal'); - } - } catch (err) { - setError('Terjadi kesalahan saat login'); - } finally { - setLoading(false); - } - }; - - const handleDosenLogin = async (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); - setError(''); - - try { - const response = await fetch('/api/auth/login', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - nip: dosenForm.nip, - password: dosenForm.password, - role: 'dosen' - }), - }); - - const data = await response.json(); - - if (response.ok) { - setIsLoginOpen(false); - router.push('/dashboard'); - } else { - setError(data.error || 'Login gagal'); - } - } catch (err) { - setError('Terjadi kesalahan saat login'); - } finally { - setLoading(false); - } - }; - - const handleRegister = async (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); - setError(''); - - if (registerForm.password !== registerForm.confirmPassword) { - setError('Password dan konfirmasi password tidak cocok'); - setLoading(false); - return; - } - - try { - const response = await fetch('/api/auth/register', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - nip: registerForm.nip, - password: registerForm.password - }), - }); - - const data = await response.json(); - - if (response.ok) { - setIsRegisterOpen(false); - setIsLoginOpen(true); - setActiveTab('dosen'); - setError(''); - } else { - setError(data.error || 'Registrasi gagal'); + if (!totalResponse.ok) { + throw new Error('Failed to fetch total data'); } + + const totalData = await totalResponse.json(); + setMahasiswaData(totalData); + + // Menyimpan data dan timestamp ke sessionStorage + sessionStorage.setItem(cacheKey, JSON.stringify(totalData)); + sessionStorage.setItem(`${cacheKey}-timestamp`, Date.now().toString()); } catch (err) { - setError('Terjadi kesalahan saat registrasi'); + setError(err instanceof Error ? err.message : 'An error occurred'); + console.error('Error fetching data:', err); } finally { setLoading(false); } }; - const openRegister = () => { - setIsLoginOpen(false); - setIsRegisterOpen(true); - }; - - const openLogin = () => { - setIsRegisterOpen(false); - setIsLoginOpen(true); - }; + fetchData(); + }, []); return ( - -
- {/* Login Dialog */} - {}}> - - - Login Portal Data Informatika - - Silakan login sesuai dengan role Anda - - - - - Dosen - Admin - - - -
-
- - setDosenForm({ ...dosenForm, nip: e.target.value })} - required - /> -
-
- -
- setDosenForm({ ...dosenForm, password: e.target.value })} - required - /> - -
-
- -
- -
-

- Belum punya akun?{' '} -

- -
-
- - -
-
- - setAdminForm({ ...adminForm, username: e.target.value })} - required - /> -
-
- -
- setAdminForm({ ...adminForm, password: e.target.value })} - required - /> - -
-
- -
-
-
- - {error && ( - - - {error} - - - )} -
-
- - {/* Register Dialog */} - {}}> - - - Registrasi Dosen - - Daftar akun baru untuk dosen Portal Data Informatika - - -
-
- - setRegisterForm({ ...registerForm, nip: e.target.value })} - required - /> -
-
- -
- setRegisterForm({ ...registerForm, password: e.target.value })} - required - /> - -
-
-
- - setRegisterForm({ ...registerForm, confirmPassword: e.target.value })} - required - /> -
- -
- -
-

- Sudah punya akun?{' '} -

- +
+

Dashboard Portal Data Informatika

+ + {loading ? ( +
+ + + + +
+ ) : error ? ( +
+
+
+ + +
- - {error && ( - - - {error} - - - )} - -
+
+

{error}

+
+
+ + ) : ( + <> +
+ {/* Kartu Total Mahasiswa */} + + + + Total Mahasiswa + + + + +
{mahasiswaData.total_mahasiswa}
+
+ Aktif: {mahasiswaData.mahasiswa_aktif} +
+
+
+ + {/* Kartu Total Kelulusan */} + + + + Total Kelulusan + + + + +
{mahasiswaData.total_lulus}
+
+ Laki-laki: {mahasiswaData.pria_lulus} + Perempuan: {mahasiswaData.wanita_lulus} +
+
+
+ + {/* Kartu Total Prestasi */} + + + + Mahasiswa Berprestasi + + + + +
{mahasiswaData.total_berprestasi}
+
+ Akademik: {mahasiswaData.prestasi_akademik} + Non-Akademik: {mahasiswaData.prestasi_non_akademik} +
+
+
+ + {/* Kartu Rata-rata IPK */} + + + + Rata-rata IPK + + + + +
{mahasiswaData.mahasiswa_aktif}
+
+ Aktif: {mahasiswaData.ipk_rata_rata_aktif} +
+
+
+
+ + + + + Filter Data + + + +
+ +
+
+
+ + {selectedYear === "all" ? ( +
+ + + + + +
+ ) : ( +
+ + + +
+ )} + + )} - -
); } diff --git a/app/visualisasi/beasiswa/page.tsx b/app/visualisasi/beasiswa/page.tsx new file mode 100644 index 0000000..7f24132 --- /dev/null +++ b/app/visualisasi/beasiswa/page.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { useState } from "react"; +import FilterTahunAngkatan from "@/components/FilterTahunAngkatan"; +import FilterJenisBeasiswa from "@/components/FilterJenisBeasiswa"; +import TotalBeasiswaChart from "@/components/charts/TotalBeasiswaChart"; +import TotalBeasiswaPieChart from "@/components/charts/TotalBeasiswaPieChart"; +import NamaBeasiswaChart from "@/components/charts/NamaBeasiswaChart"; +import NamaBeasiswaPieChart from "@/components/charts/NamaBeasiswaPieChart"; +import JenisPendaftaranBeasiswaChart from "@/components/charts/JenisPendaftaranBeasiswaChart"; +import JenisPendaftaranBeasiswaPieChart from "@/components/charts/JenisPendaftaranBeasiswaPieChart"; +import AsalDaerahBeasiswaChart from "@/components/charts/AsalDaerahBeasiswaChart"; +import IPKBeasiswaChart from "@/components/charts/IPKBeasiswaChart"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +export default function BeasiswaMahasiswaPage() { + const [selectedYear, setSelectedYear] = useState("all"); + const [selectedJenisBeasiswa, setSelectedJenisBeasiswa] = useState("Pemerintah"); + + return ( +
+

Mahasiswa Beasiswa

+ +
+

+ Mahasiswa yang mendapatkan beasiswa di program studi Informatika Fakultas Teknik Universitas Tanjungpura. +

+
+ + + + + Filter Data + + + +
+ + +
+
+
+ + {selectedYear === "all" ? ( + <> + + + + + ) : ( + <> + + + + + )} + + + + {selectedYear === "all" && ( + + )} +
+ ); + } \ No newline at end of file diff --git a/app/visualisasi/berprestasi/page.tsx b/app/visualisasi/berprestasi/page.tsx new file mode 100644 index 0000000..4601074 --- /dev/null +++ b/app/visualisasi/berprestasi/page.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { useState } from "react"; +import FilterTahunAngkatan from "@/components/FilterTahunAngkatan"; +import FilterJenisPrestasi from "@/components/FilterJenisPrestasi"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import TotalPrestasiChart from "@/components/charts/TotalPrestasiChart"; +import TotalPrestasiPieChart from "@/components/charts/TotalPrestasiPieChart"; +import TingkatPrestasiChart from "@/components/charts/TingkatPrestasiChart"; +import TingkatPrestasiPieChart from "@/components/charts/TingkatPrestasiPieChart"; +import JenisPendaftaranPrestasiChart from "@/components/charts/JenisPendaftaranPrestasiChart"; +import JenisPendaftaranPrestasiPieChart from "@/components/charts/JenisPendaftaranPrestasiPieChart"; +import AsalDaerahPrestasiChart from "@/components/charts/AsalDaerahPrestasiChart"; +import IPKPrestasiChart from "@/components/charts/IPKPrestasiChart"; + +export default function BerprestasiMahasiswaPage() { + const [selectedYear, setSelectedYear] = useState("all"); + const [selectedJenisPrestasi, setSelectedJenisPrestasi] = useState("Akademik"); + + return ( +
+

Mahasiswa Berprestasi

+ +
+

+ Mahasiswa yang mendapatkan prestasi akademik dan non akademik di program studi Informatika Fakultas Teknik Universitas Tanjungpura. +

+
+ + + + + Filter Data + + + +
+ + +
+
+
+ + {selectedYear === "all" ? ( + <> + + + + + ) : ( + <> + + + + + )} + + + + {selectedYear === "all" && ( + + )} +
+ ); + } \ No newline at end of file diff --git a/app/visualisasi/status/page.tsx b/app/visualisasi/status/page.tsx new file mode 100644 index 0000000..8b1054c --- /dev/null +++ b/app/visualisasi/status/page.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { useState } from "react"; +import FilterTahunAngkatan from "@/components/FilterTahunAngkatan"; +import FilterStatusKuliah from "@/components/FilterStatusKuliah"; +import StatusMahasiswaFilterChart from "@/components/charts/StatusMahasiswaFilterChart"; +import StatusMahasiswaFilterPieChart from "@/components/charts/StatusMahasiswaFilterPieChart"; +import JenisPendaftaranStatusChart from "@/components/charts/JenisPendaftaranStatusChart"; +import JenisPendaftaranStatusPieChart from "@/components/charts/JenisPendaftaranStatusPieChart"; +import AsalDaerahStatusChart from '@/components/charts/AsalDaerahStatusChart'; +import IpkStatusChart from '@/components/charts/IpkStatusChart'; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +export default function StatusMahasiswaPage() { + const [selectedYear, setSelectedYear] = useState("all"); + const [selectedStatus, setSelectedStatus] = useState("Aktif"); + + return ( +
+

Status Mahasiswa

+ +
+

+ Mahasiswa status adalah status kuliah mahasiswa program studi Informatika Fakultas Teknik Universitas Tanjungpura. +

+
+ + + + + Filter Data + + + +
+ + +
+
+
+ + {selectedYear === "all" ? ( + <> + + + + ) : ( + <> + + + + )} + + + + {selectedYear === "all" && ( + + )} + +
+ ); + } \ No newline at end of file diff --git a/app/visualisasi/tipekelulusan/page.tsx b/app/visualisasi/tipekelulusan/page.tsx new file mode 100644 index 0000000..543ad20 --- /dev/null +++ b/app/visualisasi/tipekelulusan/page.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { useState } from "react"; +import FilterTahunAngkatan from "@/components/FilterTahunAngkatan"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import LulusTepatWaktuChart from "@/components/charts/LulusTepatWaktuChart"; +import LulusTepatWaktuPieChart from "@/components/charts/LulusTepatWaktuPieChart"; +import JenisPendaftaranLulusChart from "@/components/charts/JenisPendaftaranLulusChart"; +import JenisPendaftaranLulusPieChart from "@/components/charts/JenisPendaftaranLulusPieChart"; +import AsalDaerahLulusChart from "@/components/charts/AsalDaerahLulusChart"; +import IPKLulusTepatChart from "@/components/charts/IPKLulusTepatChart"; + +export default function LulusTepatWaktuPage() { + const [selectedYear, setSelectedYear] = useState("all"); + + return ( +
+

Mahasiswa Lulus Tepat Waktu

+ +
+

+ Mahasiswa yang lulus tepat waktu sesuai dengan masa studi ≤ 4 tahun program studi Informatika Fakultas Teknik Universitas Tanjungpura. +

+
+ + + + + Filter Data + + + +
+ +
+
+
+ + {selectedYear === "all" ? ( + <> + + + + ) : ( + <> + + + + )} + + + {selectedYear === "all" && ( + + )} + +
+ ); + } \ No newline at end of file diff --git a/app/visualisasi/total/page.tsx b/app/visualisasi/total/page.tsx new file mode 100644 index 0000000..12064c9 --- /dev/null +++ b/app/visualisasi/total/page.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { useState } from "react"; +import StatistikMahasiswaChart from "@/components/charts/StatistikMahasiswaChart"; +import StatistikPerAngkatanChart from "@/components/charts/StatistikPerAngkatanChart"; +import JenisPendaftaranChart from "@/components/charts/JenisPendaftaranChart"; +import AsalDaerahChart from "@/components/charts/AsalDaerahChart"; +import IPKChart from "@/components/charts/IPKChart"; +import FilterTahunAngkatan from "@/components/FilterTahunAngkatan"; +import JenisPendaftaranPerAngkatanChart from "@/components/charts/JenisPendaftaranPerAngkatanChart"; +import AsalDaerahPerAngkatanChart from "@/components/charts/AsalDaerahPerAngkatanChart"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +export default function TotalMahasiswaPage() { + const [selectedYear, setSelectedYear] = useState("all"); + + return ( +
+

Total Mahasiswa

+ +
+

+ Mahasiswa total adalah jumlah mahasiswa program studi Informatika Fakultas Teknik Universitas Tanjungpura. +

+
+ + + + + Filter Data + + + +
+ +
+
+
+ + {selectedYear === "all" ? ( + <> + + + + + + ) : ( + <> + + + + + )} +
+ ); +} diff --git a/components/ClientLayout.tsx b/components/ClientLayout.tsx index 72319f2..90a3ce5 100644 --- a/components/ClientLayout.tsx +++ b/components/ClientLayout.tsx @@ -1,30 +1,14 @@ "use client"; -import { useState, useEffect } from 'react'; -import Navbar from '@/components/ui/Navbar'; -import Sidebar from '@/components/ui/Sidebar'; import { ThemeProvider } from '@/components/theme-provider'; import { Toaster } from '@/components/ui/toaster'; +import Navbar from '@/components/ui/Navbar'; interface ClientLayoutProps { children: React.ReactNode; } export default function ClientLayout({ children }: ClientLayoutProps) { - const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); - - useEffect(() => { - const savedState = localStorage.getItem('sidebarCollapsed'); - if (savedState !== null) { - setIsSidebarCollapsed(JSON.parse(savedState)); - } - }, []); - - // Save sidebar state to localStorage when it changes - useEffect(() => { - localStorage.setItem('sidebarCollapsed', JSON.stringify(isSidebarCollapsed)); - }, [isSidebarCollapsed]); - return (
- -
- setIsSidebarCollapsed(!isSidebarCollapsed)} isSidebarCollapsed={isSidebarCollapsed} /> -
- {children} -
-
+ +
+ {children} +
diff --git a/components/FilterStatusKuliah.tsx b/components/FilterStatusKuliah.tsx index aac9641..db0922b 100644 --- a/components/FilterStatusKuliah.tsx +++ b/components/FilterStatusKuliah.tsx @@ -13,7 +13,7 @@ export default function FilterStatusKuliah({ selectedStatus, onStatusChange }: P { value: 'Aktif', label: 'Aktif' }, { value: 'Lulus', label: 'Lulus' }, { value: 'Cuti', label: 'Cuti' }, - { value: 'DO', label: 'DO' } + { value: 'Non-Aktif', label: 'Non-Aktif' } ]; return ( diff --git a/components/charts/JenisPendaftaranChart.tsx b/components/charts/JenisPendaftaranChart.tsx index 1a01080..59eacda 100644 --- a/components/charts/JenisPendaftaranChart.tsx +++ b/components/charts/JenisPendaftaranChart.tsx @@ -280,13 +280,13 @@ export default function JenisPendaftaranChart() { -
+
diff --git a/components/charts/StatistikMahasiswaChart.tsx b/components/charts/StatistikMahasiswaChart.tsx index 1e00653..c691b7c 100644 --- a/components/charts/StatistikMahasiswaChart.tsx +++ b/components/charts/StatistikMahasiswaChart.tsx @@ -317,7 +317,7 @@ export default function StatistikMahasiswaChart() { series={chartSeries} type="bar" height="100%" - width="90%" + width="100%" />
diff --git a/components/charts/StatusMahasiswaChart.tsx b/components/charts/StatusMahasiswaChart.tsx index 916b74c..d4a41aa 100644 --- a/components/charts/StatusMahasiswaChart.tsx +++ b/components/charts/StatusMahasiswaChart.tsx @@ -134,7 +134,7 @@ export default function StatusMahasiswaChart() { // Process data to create series const tahunAngkatan = [...new Set(result.map(item => item.tahun_angkatan))].sort(); - const statuses = ['Aktif', 'Lulus', 'Cuti', 'DO']; + const statuses = ['Aktif', 'Lulus', 'Cuti', 'Non-Aktif']; const seriesData = statuses.map(status => ({ name: status, @@ -207,7 +207,7 @@ export default function StatusMahasiswaChart() { series={series} type="bar" height="100%" - width="90%" + width="100%" /> )} diff --git a/components/datatable/data-table-mahasiswa.tsx b/components/datatable/data-table-mahasiswa.tsx new file mode 100644 index 0000000..3b9244d --- /dev/null +++ b/components/datatable/data-table-mahasiswa.tsx @@ -0,0 +1,929 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogFooter, + DialogClose +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@/components/ui/select"; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; +import { + PlusCircle, + Pencil, + Trash2, + Search, + X, + Loader2, + RefreshCw +} from "lucide-react"; +import EditJenisPendaftaran from "@/components/datatable/edit-jenis-pendaftaran"; +import UploadExcelMahasiswa from "@/components/datatable/upload-excel-mahasiswa"; + +// Define the Mahasiswa type based on API route structure +interface Mahasiswa { + nim: string; + nama: string; + jk: "Pria" | "Wanita"; + agama: string | null; + kabupaten: string | null; + provinsi: string | null; + jenis_pendaftaran: string | null; + tahun_angkatan: string; + ipk: number | null; + id_kelompok_keahlian: number | null; + nama_kelompok_keahlian: string | null; + status_kuliah: "Aktif" | "Cuti" | "Lulus" | "Non-Aktif"; + semester: number; + created_at: string; + updated_at: string; +} + +export default function DataTableMahasiswa() { + // State for data + const [mahasiswa, setMahasiswa] = useState([]); + const [filteredData, setFilteredData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // State for filtering + const [searchTerm, setSearchTerm] = useState(""); + const [filterAngkatan, setFilterAngkatan] = useState(""); + const [filterStatus, setFilterStatus] = useState(""); + + // State for pagination + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [paginatedData, setPaginatedData] = useState([]); + + // State for form + const [formMode, setFormMode] = useState<"add" | "edit">("add"); + const [formData, setFormData] = useState>({ + jk: "Pria" + }); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isDialogOpen, setIsDialogOpen] = useState(false); + + // State for delete confirmation + const [deleteNim, setDeleteNim] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + + // State for updating semester + const [isUpdatingSemester, setIsUpdatingSemester] = useState(false); + + // State for jenis pendaftaran options + const [jenisPendaftaranOptions, setJenisPendaftaranOptions] = useState([]); + + // State for kelompok keahlian options + const [kelompokKeahlianOptions, setKelompokKeahlianOptions] = useState>([]); + + // Fetch data on component mount + useEffect(() => { + fetchMahasiswa(); + fetchJenisPendaftaranOptions(); + fetchKelompokKeahlianOptions(); + }, []); + + // Filter data when search term or filter changes + useEffect(() => { + filterData(); + }, [searchTerm, filterAngkatan, filterStatus, mahasiswa]); + + // Update paginated data when filtered data or pagination settings change + useEffect(() => { + paginateData(); + }, [filteredData, currentPage, pageSize]); + + // Fetch mahasiswa data from API + const fetchMahasiswa = async () => { + try { + setLoading(true); + const response = await fetch("/api/keloladata/data-mahasiswa"); + + if (!response.ok) { + throw new Error("Failed to fetch data"); + } + + const data = await response.json(); + setMahasiswa(data); + setFilteredData(data); + setError(null); + } catch (err) { + setError("Error fetching data. Please try again later."); + console.error("Error fetching data:", err); + } finally { + setLoading(false); + } + }; + + // Update semester for active students + const handleUpdateSemester = async () => { + try { + setIsUpdatingSemester(true); + + const response = await fetch("/api/keloladata/update-semester", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || "Failed to update semesters"); + } + + + // Refresh data after successful update + await fetchMahasiswa(); + } catch (err) { + console.error("Error updating semesters:", err); + } finally { + setIsUpdatingSemester(false); + } + }; + + // Filter data based on search term and filters + const filterData = () => { + let filtered = [...mahasiswa]; + + // Filter by search term (NIM or name) + if (searchTerm) { + filtered = filtered.filter( + (item) => + item.nim.toLowerCase().includes(searchTerm.toLowerCase()) || + item.nama.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + // Filter by angkatan + if (filterAngkatan && filterAngkatan !== "all") { + filtered = filtered.filter((item) => item.tahun_angkatan === filterAngkatan); + } + + // Filter by status + if (filterStatus && filterStatus !== "all") { + filtered = filtered.filter((item) => item.status_kuliah === filterStatus); + } + + setFilteredData(filtered); + // Reset to first page when filters change + setCurrentPage(1); + }; + + // Paginate data + const paginateData = () => { + const startIndex = (currentPage - 1) * pageSize; + const endIndex = startIndex + pageSize; + setPaginatedData(filteredData.slice(startIndex, endIndex)); + }; + + // Get total number of pages + const getTotalPages = () => { + return Math.ceil(filteredData.length / pageSize); + }; + + // Handle page change + const handlePageChange = (page: number) => { + setCurrentPage(page); + }; + + // Handle page size change + const handlePageSizeChange = (size: string) => { + setPageSize(Number(size)); + setCurrentPage(1); // Reset to first page when changing page size + }; + + // Reset form data + const resetForm = () => { + setFormData({ + jk: "Pria", + status_kuliah: "Aktif", + semester: 1 + }); + }; + + // Handle form input changes + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + if (name === "semester") { + const numValue = value === "" ? 1 : parseInt(value); + setFormData((prev) => ({ ...prev, [name]: numValue })); + } else { + setFormData((prev) => ({ ...prev, [name]: value })); + } + }; + + // Handle select input changes + const handleSelectChange = (name: string, value: string) => { + // Handle numeric fields + if (name === "id_kelompok_keahlian") { + const numValue = value === "" ? null : parseInt(value); + setFormData((prev) => ({ ...prev, [name]: numValue })); + } else if (name === "semester") { + const numValue = value === "" ? 1 : parseInt(value); + setFormData((prev) => ({ ...prev, [name]: numValue })); + } else { + setFormData((prev) => ({ ...prev, [name]: value })); + } + }; + + // Fetch jenis pendaftaran options + const fetchJenisPendaftaranOptions = async () => { + try { + const response = await fetch("/api/keloladata/setting-jenis-pendaftaran"); + + if (!response.ok) { + throw new Error("Failed to fetch jenis pendaftaran options"); + } + + const data = await response.json(); + const options = data.map((item: any) => item.jenis_pendaftaran); + setJenisPendaftaranOptions(options); + } catch (err) { + console.error("Error fetching jenis pendaftaran options:", err); + } + }; + + // Fetch kelompok keahlian options + const fetchKelompokKeahlianOptions = async () => { + try { + const response = await fetch("/api/keloladata/data-kelompok-keahlian"); + + if (!response.ok) { + throw new Error("Failed to fetch kelompok keahlian options"); + } + + const data = await response.json(); + setKelompokKeahlianOptions(data); + } catch (err) { + console.error("Error fetching kelompok keahlian options:", err); + } + }; + + // Open form dialog for adding new mahasiswa + const handleAdd = () => { + setFormMode("add"); + resetForm(); + setIsDialogOpen(true); + // Make sure we have the latest options + fetchJenisPendaftaranOptions(); + fetchKelompokKeahlianOptions(); + }; + + // Open form dialog for editing mahasiswa + const handleEdit = (data: Mahasiswa) => { + setFormMode("edit"); + setFormData(data); + setIsDialogOpen(true); + // Make sure we have the latest options + fetchJenisPendaftaranOptions(); + fetchKelompokKeahlianOptions(); + }; + + // Open delete confirmation dialog + const handleDeleteConfirm = (nim: string) => { + setDeleteNim(nim); + setIsDeleteDialogOpen(true); + }; + + // Submit form for add/edit + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + try { + setIsSubmitting(true); + + if (formMode === "add") { + // Add new mahasiswa + const response = await fetch("/api/keloladata/data-mahasiswa", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(formData), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || "Failed to add mahasiswa"); + } + + } else { + // Edit existing mahasiswa + const response = await fetch(`/api/keloladata/data-mahasiswa?nim=${formData.nim}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(formData), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || "Failed to update mahasiswa"); + } + + } + + // Refresh data after successful operation + await fetchMahasiswa(); + setIsDialogOpen(false); + resetForm(); + } catch (err) { + console.error("Error submitting form:", err); + } finally { + setIsSubmitting(false); + } + }; + + // Delete mahasiswa + const handleDelete = async () => { + if (!deleteNim) return; + + try { + setIsDeleting(true); + + const response = await fetch(`/api/keloladata/data-mahasiswa?nim=${deleteNim}`, { + method: "DELETE", + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || "Failed to delete mahasiswa"); + } + + + // Refresh data after successful deletion + await fetchMahasiswa(); + setIsDeleteDialogOpen(false); + setDeleteNim(null); + } catch (err) { + console.error("Error deleting mahasiswa:", err); + } finally { + setIsDeleting(false); + } + }; + + // Get unique angkatan years for filter + const getUniqueAngkatan = () => { + const years = new Set(); + mahasiswa.forEach((m) => years.add(m.tahun_angkatan)); + return Array.from(years).sort(); + }; + + // Generate pagination items + const renderPaginationItems = () => { + const totalPages = getTotalPages(); + const items = []; + + // Always show first page + items.push( + + handlePageChange(1)} + > + 1 + + + ); + + // Show ellipsis if needed + if (currentPage > 3) { + items.push( + + + + ); + } + + // Show pages around current page + for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) { + if (i === 1 || i === totalPages) continue; // Skip first and last pages as they're always shown + items.push( + + handlePageChange(i)} + > + {i} + + + ); + } + + // Show ellipsis if needed + if (currentPage < totalPages - 2) { + items.push( + + + + ); + } + + // Always show last page if there's more than one page + if (totalPages > 1) { + items.push( + + handlePageChange(totalPages)} + > + {totalPages} + + + ); + } + + return items; + }; + + // Calculate the range of entries being displayed + const getDisplayRange = () => { + if (filteredData.length === 0) return { start: 0, end: 0 }; + + const start = (currentPage - 1) * pageSize + 1; + const end = Math.min(currentPage * pageSize, filteredData.length); + + return { start, end }; + }; + + return ( +
+
+

Data Mahasiswa

+
+ + + + +
+
+ + {/* Filters */} +
+
+ + setSearchTerm(e.target.value)} + /> + {searchTerm && ( + setSearchTerm("")} + /> + )} +
+ + +
+ + {/* Show entries selector */} +
+ Show + + entries +
+ + {/* Table */} + {loading ? ( +
+ +
+ ) : error ? ( +
+ {error} +
+ ) : ( +
+ + + + NIM + Nama + Jenis Kelamin + Agama + Kabupaten + Provinsi + Jenis Pendaftaran + Tahun Angkatan + Semester + IPK + Status Kuliah + Kelompok Keahlian + Aksi + + + + {paginatedData.length === 0 ? ( + + + Tidak ada data yang sesuai dengan filter + + + ) : ( + paginatedData.map((mhs) => ( + + {mhs.nim} + {mhs.nama} + {mhs.jk} + {mhs.agama} + {mhs.kabupaten} + {mhs.provinsi} + {mhs.jenis_pendaftaran} + {mhs.tahun_angkatan} + {mhs.semester} + {mhs.ipk ? Number(mhs.ipk).toFixed(2) : "-"} + + + {mhs.status_kuliah} + + + {mhs.nama_kelompok_keahlian || "-"} + +
+ + +
+
+
+ )) + )} +
+
+
+ )} + + {/* Pagination info and controls */} + {!loading && !error && filteredData.length > 0 && ( +
+
+ Showing {getDisplayRange().start} to {getDisplayRange().end} of {filteredData.length} entries +
+ + + + handlePageChange(Math.max(1, currentPage - 1))} + className={currentPage === 1 ? "pointer-events-none opacity-50" : ""} + /> + + + {renderPaginationItems()} + + + handlePageChange(Math.min(getTotalPages(), currentPage + 1))} + className={currentPage === getTotalPages() ? "pointer-events-none opacity-50" : ""} + /> + + + +
+ )} + + {/* Add/Edit Dialog */} + + + + + {formMode === "add" ? "Tambah Mahasiswa" : "Edit Mahasiswa"} + + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + + + +
+
+
+ + {/* Delete Confirmation Dialog */} + + + + Konfirmasi Hapus + +
+

Apakah Anda yakin ingin menghapus data mahasiswa ini?

+

+ Tindakan ini tidak dapat dibatalkan. +

+
+ + + + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/components/datatable/edit-jenis-pendaftaran.tsx b/components/datatable/edit-jenis-pendaftaran.tsx new file mode 100644 index 0000000..2f9769c --- /dev/null +++ b/components/datatable/edit-jenis-pendaftaran.tsx @@ -0,0 +1,224 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogClose +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@/components/ui/select"; +import { Loader2, Settings } from "lucide-react"; + +interface JenisPendaftaran { + jenis_pendaftaran: string; +} + +interface EditJenisPendaftaranProps { + onUpdateSuccess?: () => void; +} + +export default function EditJenisPendaftaran({ onUpdateSuccess }: EditJenisPendaftaranProps) { + // Toast hook + + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [jenisPendaftaranList, setJenisPendaftaranList] = useState([]); + const [loading, setLoading] = useState(false); + const [updating, setUpdating] = useState(false); + const [error, setError] = useState(null); + + // State for selected jenis pendaftaran and new value + const [selectedJenisPendaftaran, setSelectedJenisPendaftaran] = useState(""); + const [newValue, setNewValue] = useState(""); + + // Fetch jenis pendaftaran data when dialog opens + useEffect(() => { + if (isDialogOpen) { + fetchJenisPendaftaran(); + resetForm(); + } + }, [isDialogOpen]); + + // Update new value when selected jenis pendaftaran changes + useEffect(() => { + if (selectedJenisPendaftaran) { + setNewValue(selectedJenisPendaftaran); + } + }, [selectedJenisPendaftaran]); + + // Reset form + const resetForm = () => { + setSelectedJenisPendaftaran(""); + setNewValue(""); + setError(null); + }; + + // Fetch unique jenis pendaftaran values + const fetchJenisPendaftaran = async () => { + try { + setLoading(true); + setError(null); + + const response = await fetch("/api/keloladata/setting-jenis-pendaftaran"); + + if (!response.ok) { + throw new Error("Failed to fetch data"); + } + + const data = await response.json(); + setJenisPendaftaranList(data); + } catch (err) { + setError("Error fetching data. Please try again later."); + console.error("Error fetching data:", err); + } finally { + setLoading(false); + } + }; + + // Handle save changes + const handleSaveChanges = async () => { + try { + if (!selectedJenisPendaftaran || !newValue) { + setError("Pilih jenis pendaftaran dan masukkan nilai baru"); + return; + } + + if (selectedJenisPendaftaran === newValue) { + setError("Nilai baru harus berbeda dengan nilai lama"); + return; + } + + setUpdating(true); + setError(null); + + const response = await fetch("/api/keloladata/setting-jenis-pendaftaran", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + oldValue: selectedJenisPendaftaran, + newValue: newValue + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || "Failed to update jenis pendaftaran"); + } + + // Reset form and notify parent component + resetForm(); + + // Refresh the list + await fetchJenisPendaftaran(); + + // Show success message + + // Close dialog + setIsDialogOpen(false); + + // Notify parent component + if (onUpdateSuccess) { + onUpdateSuccess(); + } + } catch (err) { + setError((err as Error).message); + console.error("Error updating jenis pendaftaran:", err); + } finally { + setUpdating(false); + } + }; + + return ( + <> + + + + + + Edit Jenis Pendaftaran + + + {loading ? ( +
+ +
+ ) : error ? ( +
+ {error} +
+ ) : ( +
+
+ + +
+ +
+ + setNewValue(e.target.value)} + placeholder="Masukkan jenis pendaftaran baru" + disabled={!selectedJenisPendaftaran} + /> +
+
+ )} + + + + + + + +
+
+ + ); +} \ No newline at end of file diff --git a/components/datatable/upload-excel-mahasiswa.tsx b/components/datatable/upload-excel-mahasiswa.tsx new file mode 100644 index 0000000..a793b01 --- /dev/null +++ b/components/datatable/upload-excel-mahasiswa.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogFooter, + DialogClose +} from "@/components/ui/dialog"; +import { + FileUp, + Loader2, + AlertCircle +} from "lucide-react"; + +interface UploadExcelMahasiswaProps { + onUploadSuccess: () => void; +} + +export default function UploadExcelMahasiswa({ onUploadSuccess }: UploadExcelMahasiswaProps) { + const [file, setFile] = useState(null); + const [isUploading, setIsUploading] = useState(false); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [error, setError] = useState(null); + + const handleFileChange = (e: React.ChangeEvent) => { + const selectedFile = e.target.files?.[0]; + setError(null); + + if (!selectedFile) { + setFile(null); + return; + } + + // Check file type + const fileType = selectedFile.type; + const validTypes = [ + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'text/csv', + 'application/csv', + 'text/plain' + ]; + + if (!validTypes.includes(fileType) && + !selectedFile.name.endsWith('.csv') && + !selectedFile.name.endsWith('.xlsx') && + !selectedFile.name.endsWith('.xls')) { + setError("Format file tidak valid. Harap unggah file Excel (.xlsx, .xls) atau CSV (.csv)"); + setFile(null); + return; + } + + // Check file size (max 5MB) + if (selectedFile.size > 5 * 1024 * 1024) { + setError("Ukuran file terlalu besar. Maksimum 5MB"); + setFile(null); + return; + } + + setFile(selectedFile); + }; + + const handleUpload = async () => { + if (!file) { + setError("Pilih file terlebih dahulu"); + return; + } + + try { + setIsUploading(true); + setError(null); + + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch('/api/data-mahasiswa/upload', { + method: 'POST', + body: formData, + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.message || 'Terjadi kesalahan saat mengunggah file'); + } + + setIsDialogOpen(false); + setFile(null); + onUploadSuccess(); + + } catch (err) { + console.error('Error uploading file:', err); + setError((err as Error).message || 'Terjadi kesalahan saat mengunggah file'); + } finally { + setIsUploading(false); + } + }; + + return ( + + + + + + + Upload Data Mahasiswa + +
+
+

Upload file Excel (.xlsx, .xls) atau CSV (.csv)

+
+ +
+ + + {file && ( +

+ File terpilih: {file.name} +

+ )} +
+ + {error && ( +
+ + {error} +
+ )} +
+ + + + + + +
+
+ ); +} \ No newline at end of file diff --git a/components/ui/Navbar.tsx b/components/ui/Navbar.tsx index 425d767..ced9b0b 100644 --- a/components/ui/Navbar.tsx +++ b/components/ui/Navbar.tsx @@ -1,21 +1,58 @@ 'use client'; +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; import { ThemeToggle } from '@/components/theme-toggle'; -import { Menu, PanelLeftClose, PanelLeft, LogOut } from 'lucide-react'; +import { Menu, ChevronDown, School, GraduationCap, Clock, BookOpen, Award, Home, LogOut, User } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; -import SidebarContent from '@/components/ui/SidebarContent'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import Link from 'next/link'; -import { useRouter } from 'next/navigation'; +import LoginDialog from './login-dialog'; +import { useToast } from '@/components/ui/use-toast'; -interface NavbarProps { - onSidebarToggle: () => void; - isSidebarCollapsed: boolean; +interface UserData { + id_user: number; + username?: string; + nip?: string; + role_user: string; } -const Navbar = ({ onSidebarToggle, isSidebarCollapsed }: NavbarProps) => { +const Navbar = () => { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const { toast } = useToast(); const router = useRouter(); + // Check for existing user session on mount + useEffect(() => { + checkUserSession(); + }, []); + + const checkUserSession = async () => { + try { + const response = await fetch('/api/auth/user'); + if (response.ok) { + const data = await response.json(); + setUser(data.user); + } + } catch (error) { + console.error('Error checking session:', error); + } finally { + setIsLoading(false); + } + }; + + const handleLoginSuccess = (userData: any) => { + setUser(userData.user); + }; + const handleLogout = async () => { try { const response = await fetch('/api/auth/logout', { @@ -23,16 +60,174 @@ const Navbar = ({ onSidebarToggle, isSidebarCollapsed }: NavbarProps) => { }); if (response.ok) { + setUser(null); + toast({ + title: "Logout Berhasil", + description: "Anda telah keluar dari sistem", + }); + // Redirect to root page after successful logout router.push('/'); } } catch (error) { console.error('Logout error:', error); + toast({ + variant: "destructive", + title: "Error", + description: "Terjadi kesalahan saat logout", + }); } }; + if (isLoading) { + return ( +
+
+
+ + PODIF Logo + PODIF + +
+
+ +
+
+
+ ); + } + return ( -
-
+
+
+ {/* Logo */} +
+ + PODIF Logo + PODIF + +
+ + {/* Desktop Navigation - Centered */} +
+ {/* Beranda - Always visible */} + + + Beranda + + + {/* Visualisasi Dropdown - Only when logged in */} + {user && ( + + + + + + + + + Mahasiswa + + + + + + Status Kuliah + + + + + + Tipe Kelulusan + + + + + + Beasiswa + + + + + + Prestasi + + + + + )} + + {/* Kelola Data Dropdown - Only for Admin */} + {user && user.role_user === 'admin' && ( + + + + + + + + + Mahasiswa + + + + + + Beasiswa + + + + + + Prestasi + + + + + + Kelompok Keahlian + + + + + )} +
+ + {/* Right Side - Theme Toggle, Login/User Menu, and Mobile Menu */} +
+ + + {user ? ( + + + + + + + + {user.role_user === 'ketuajurusan' ? user.nip : user.username} + + + + + Logout + + + + ) : ( + + )} + {/* Mobile Menu Button */}
@@ -43,48 +238,108 @@ const Navbar = ({ onSidebarToggle, isSidebarCollapsed }: NavbarProps) => { - +
- - {/* Desktop Sidebar Toggle Button */} -
-
- - - PODIF Logo - PODIF - -
- -
- -
); }; +// Mobile Navigation Content Component +interface MobileNavContentProps { + user: UserData | null; + onLogout: () => void; +} + +const MobileNavContent = ({ user, onLogout }: MobileNavContentProps) => { + return ( +
+
+

Dashboard PODIF

+ + + Beranda + +
+ + {user ? ( +
+

Menu Utama

+ +
+

Visualisasi

+ + + Mahasiswa + + + + Status Kuliah + + + + Tipe Kelulusan + + + + Beasiswa + + + + Prestasi + +
+ + {/* Kelola Data - Only for Admin */} + {user.role_user === 'admin' && ( +
+

Kelola Data

+ + + Mahasiswa + + + + Beasiswa + + + + Prestasi + + + + Kelompok Keahlian + +
+ )} + +
+
+ + {user.role_user === 'ketuajurusan' ? 'Ketua Jurusan' : 'Admin'} +
+ +
+
+ ) : ( +
+

Login

+

+ Silakan login untuk mengakses menu Visualisasi dan Kelola Data +

+
+ )} +
+ ); +}; + export default Navbar; diff --git a/components/ui/SidebarContent.tsx b/components/ui/SidebarContent.tsx index e5b6998..f490058 100644 --- a/components/ui/SidebarContent.tsx +++ b/components/ui/SidebarContent.tsx @@ -27,44 +27,76 @@ const SidebarContent = () => { - + - Dashboard + Beranda - - + +
- Data Mahasiswa + Visualisasi
- - - Mahasiswa Total - - + - Mahasiswa Status + Mahasiswa - + + + Status Kuliah + + - Mahasiswa Lulus Tepat Waktu + Tipe Kelulusan - + - Mahasiswa Beasiswa + Beasiswa - + - Mahasiswa Berprestasi + Prestasi + +
+
+
+
+
+ + + + +
+ + Kelola Data +
+
+ +
+ + + Mahasiswa + + + + Beasiswa + + + + Prestasi + + + + Kelompok Keahlian
@@ -73,14 +105,6 @@ const SidebarContent = () => {
- - - - - Profile - - -
); diff --git a/components/ui/button.tsx b/components/ui/button.tsx index 0660af6..a2df8dc 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -1,36 +1,39 @@ -import * as React from 'react'; -import { Slot } from '@radix-ui/react-slot'; -import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" -import { cn } from '@/lib/utils'; +import { cn } from "@/lib/utils" const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { - default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90', + default: + "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", destructive: - 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: - 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', - secondary: 'bg-secondary text-card shadow-xs hover:bg-secondary/80', - ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', - link: 'text-primary underline-offset-4 hover:underline', + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", }, size: { - default: 'h-9 px-4 py-2 has-[>svg]:px-3', - sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5', - lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', - icon: 'size-9', + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", }, }, defaultVariants: { - variant: 'default', - size: 'default', + variant: "default", + size: "default", }, } -); +) function Button({ className, @@ -38,11 +41,11 @@ function Button({ size, asChild = false, ...props -}: React.ComponentProps<'button'> & +}: React.ComponentProps<"button"> & VariantProps & { - asChild?: boolean; + asChild?: boolean }) { - const Comp = asChild ? Slot : 'button'; + const Comp = asChild ? Slot : "button" return ( - ); + ) } -export { Button, buttonVariants }; +export { Button, buttonVariants } diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx index 0c1fffc..ec51e9c 100644 --- a/components/ui/dropdown-menu.tsx +++ b/components/ui/dropdown-menu.tsx @@ -1,25 +1,34 @@ -'use client'; +"use client" -import * as React from 'react'; -import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; -import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'; +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" -import { cn } from '@/lib/utils'; +import { cn } from "@/lib/utils" -function DropdownMenu({ ...props }: React.ComponentProps) { - return ; +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return } function DropdownMenuPortal({ ...props }: React.ComponentProps) { - return ; + return ( + + ) } function DropdownMenuTrigger({ ...props }: React.ComponentProps) { - return ; + return ( + + ) } function DropdownMenuContent({ @@ -33,27 +42,31 @@ function DropdownMenuContent({ data-slot="dropdown-menu-content" sideOffset={sideOffset} className={cn( - 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', + "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", className )} {...props} /> - ); + ) } -function DropdownMenuGroup({ ...props }: React.ComponentProps) { - return ; +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) } function DropdownMenuItem({ className, inset, - variant = 'default', + variant = "default", ...props }: React.ComponentProps & { - inset?: boolean; - variant?: 'default' | 'destructive'; + inset?: boolean + variant?: "default" | "destructive" }) { return ( - ); + ) } function DropdownMenuCheckboxItem({ @@ -92,13 +105,18 @@ function DropdownMenuCheckboxItem({ {children} - ); + ) } function DropdownMenuRadioGroup({ ...props }: React.ComponentProps) { - return ; + return ( + + ) } function DropdownMenuRadioItem({ @@ -122,7 +140,7 @@ function DropdownMenuRadioItem({ {children} - ); + ) } function DropdownMenuLabel({ @@ -130,16 +148,19 @@ function DropdownMenuLabel({ inset, ...props }: React.ComponentProps & { - inset?: boolean; + inset?: boolean }) { return ( - ); + ) } function DropdownMenuSeparator({ @@ -149,24 +170,32 @@ function DropdownMenuSeparator({ return ( - ); + ) } -function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) { +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { return ( - ); + ) } -function DropdownMenuSub({ ...props }: React.ComponentProps) { - return ; +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return } function DropdownMenuSubTrigger({ @@ -175,14 +204,14 @@ function DropdownMenuSubTrigger({ children, ...props }: React.ComponentProps & { - inset?: boolean; + inset?: boolean }) { return ( - ); + ) } function DropdownMenuSubContent({ @@ -201,12 +230,12 @@ function DropdownMenuSubContent({ - ); + ) } export { @@ -225,4 +254,4 @@ export { DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, -}; +} diff --git a/components/ui/form.tsx b/components/ui/form.tsx new file mode 100644 index 0000000..524b986 --- /dev/null +++ b/components/ui/form.tsx @@ -0,0 +1,167 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + FormProvider, + useFormContext, + useFormState, + type ControllerProps, + type FieldPath, + type FieldValues, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState } = useFormContext() + const formState = useFormState({ name: fieldContext.name }) + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +function FormItem({ className, ...props }: React.ComponentProps<"div">) { + const id = React.useId() + + return ( + +
+ + ) +} + +function FormLabel({ + className, + ...props +}: React.ComponentProps) { + const { error, formItemId } = useFormField() + + return ( +