From 886fc59d281456ce756e6dd4babd740eb69fce83 Mon Sep 17 00:00:00 2001 From: Randa Firman Putra Date: Tue, 16 Sep 2025 14:22:37 +0700 Subject: [PATCH] ayo bisa --- app/api/keloladata/data-dosen/route.ts | 251 +++++++ app/api/keloladata/data-dosen/upload/route.ts | 167 +++++ app/api/keloladata/data-mahasiswa/route.ts | 40 +- .../keloladata/data-mahasiswa/upload/route.ts | 72 +- app/api/mahasiswa/bimbingan-dosen/route.ts | 101 +++ app/dashboard/page.tsx | 18 +- app/detail/bimbingan-dosen/page.tsx | 72 ++ app/keloladata/dosen/page.tsx | 9 + app/page.tsx | 2 +- components/charts/AsalDaerahChart.tsx | 9 +- .../charts/AsalDaerahPerAngkatanChart.tsx | 9 +- components/charts/BimbinganDosenChart.tsx | 341 ++++++++++ .../charts/BimbinganDosenPerAngkatanChart.tsx | 358 ++++++++++ components/datatable/data-table-dosen.tsx | 617 ++++++++++++++++++ components/datatable/data-table-mahasiswa.tsx | 117 +++- components/datatable/upload-file-dosen.tsx | 225 +++++++ components/ui/Navbar.tsx | 8 +- 17 files changed, 2386 insertions(+), 30 deletions(-) create mode 100644 app/api/keloladata/data-dosen/route.ts create mode 100644 app/api/keloladata/data-dosen/upload/route.ts create mode 100644 app/api/mahasiswa/bimbingan-dosen/route.ts create mode 100644 app/detail/bimbingan-dosen/page.tsx create mode 100644 app/keloladata/dosen/page.tsx create mode 100644 components/charts/BimbinganDosenChart.tsx create mode 100644 components/charts/BimbinganDosenPerAngkatanChart.tsx create mode 100644 components/datatable/data-table-dosen.tsx create mode 100644 components/datatable/upload-file-dosen.tsx diff --git a/app/api/keloladata/data-dosen/route.ts b/app/api/keloladata/data-dosen/route.ts new file mode 100644 index 0000000..45a2aad --- /dev/null +++ b/app/api/keloladata/data-dosen/route.ts @@ -0,0 +1,251 @@ +import { NextRequest, NextResponse } from 'next/server'; +import supabase from '@/lib/db'; + +// GET - Ambil semua data dosen atau filter berdasarkan kriteria +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + const search = searchParams.get('search'); + + // Jika ID diberikan, ambil data dosen spesifik berdasarkan ID + if (id) { + const { data, error } = await supabase + .from('dosen') + .select('*') + .eq('id_dosen', id) + .single(); + + if (error || !data) { + return NextResponse.json({ message: 'Dosen not found' }, { status: 404 }); + } + + return NextResponse.json(data); + } + + // Bangun query berdasarkan filter + let query = supabase.from('dosen').select('*'); + + // Tambahkan kondisi pencarian jika diberikan + if (search) { + query = query.or(`nama_dosen.ilike.%${search}%,nip.ilike.%${search}%`); + } + + // Tambahkan pengurutan berdasarkan nama dosen + query = query.order('nama_dosen', { ascending: true }); + + // Eksekusi query + const { data, error } = await query; + + if (error) { + console.error('Error fetching data:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } + + return NextResponse.json(data); + } catch (error) { + console.error('Error fetching data:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } +} + +// POST - Buat data dosen baru +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { + nama_dosen, + nip + } = body; + + // Validate required fields + if (!nama_dosen || !nip) { + return NextResponse.json( + { message: 'Missing required fields: nama_dosen, nip' }, + { status: 400 } + ); + } + + // Validate NIP length (harus 18 karakter) + if (nip.length !== 18) { + return NextResponse.json( + { message: 'NIP harus terdiri dari 18 karakter' }, + { status: 400 } + ); + } + + // Cek apakah NIP sudah ada + const { data: existingDosen, error: checkError } = await supabase + .from('dosen') + .select('nip') + .eq('nip', nip) + .single(); + + if (!checkError && existingDosen) { + return NextResponse.json( + { message: 'NIP sudah terdaftar dalam database' }, + { status: 409 } + ); + } + + // Insert data dosen baru + const { data, error } = await supabase + .from('dosen') + .insert({ + nama_dosen, + nip + }) + .select() + .single(); + + if (error) { + console.error('Error creating dosen:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } + + return NextResponse.json( + { + message: `Dosen berhasil ditambahkan`, + id: data.id_dosen + }, + { status: 201 } + ); + } catch (error) { + console.error('Error creating dosen:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } +} + +// PUT - Update data dosen yang sudah ada +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 { + nama_dosen, + nip + } = body; + + // Validate required fields + if (!nama_dosen || !nip) { + return NextResponse.json( + { message: 'Missing required fields: nama_dosen, nip' }, + { status: 400 } + ); + } + + // Validate NIP length (harus 18 karakter) + if (nip.length !== 18) { + return NextResponse.json( + { message: 'NIP harus terdiri dari 18 karakter' }, + { status: 400 } + ); + } + + // Cek apakah dosen ada + const { data: existing, error: checkError } = await supabase + .from('dosen') + .select('*') + .eq('id_dosen', id) + .single(); + + if (checkError || !existing) { + return NextResponse.json({ message: 'Dosen not found' }, { status: 404 }); + } + + // Cek apakah NIP sudah ada untuk dosen lain + const { data: existingNip, error: nipCheckError } = await supabase + .from('dosen') + .select('id_dosen, nip') + .eq('nip', nip) + .neq('id_dosen', id) + .single(); + + if (!nipCheckError && existingNip) { + return NextResponse.json( + { message: 'NIP sudah digunakan oleh dosen lain' }, + { status: 409 } + ); + } + + // Update dosen + const { error } = await supabase + .from('dosen') + .update({ + nama_dosen, + nip + }) + .eq('id_dosen', id); + + if (error) { + console.error('Error updating dosen:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } + + return NextResponse.json({ + message: `Dosen berhasil diperbarui` + }); + } catch (error) { + console.error('Error updating dosen:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } +} + +// DELETE - Hapus data dosen +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 }); + } + + // Cek apakah dosen ada + const { data: existing, error: checkError } = await supabase + .from('dosen') + .select('id_dosen') + .eq('id_dosen', id) + .single(); + + if (checkError || !existing) { + return NextResponse.json({ message: 'Dosen not found' }, { status: 404 }); + } + + // Cek apakah dosen sedang digunakan sebagai pembimbing + const { data: bimbinganCheck, error: bimbinganError } = await supabase + .from('mahasiswa') + .select('id_mahasiswa') + .or(`pembimbing1_id.eq.${id},pembimbing2_id.eq.${id}`) + .limit(1); + + if (!bimbinganError && bimbinganCheck && bimbinganCheck.length > 0) { + return NextResponse.json( + { message: 'Tidak dapat menghapus dosen yang sedang menjadi pembimbing mahasiswa' }, + { status: 409 } + ); + } + + // Hapus dosen + const { error } = await supabase + .from('dosen') + .delete() + .eq('id_dosen', id); + + if (error) { + console.error('Error deleting dosen:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } + + return NextResponse.json({ message: 'Dosen berhasil dihapus' }); + } catch (error) { + console.error('Error deleting dosen:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/app/api/keloladata/data-dosen/upload/route.ts b/app/api/keloladata/data-dosen/upload/route.ts new file mode 100644 index 0000000..f155f5f --- /dev/null +++ b/app/api/keloladata/data-dosen/upload/route.ts @@ -0,0 +1,167 @@ +import { NextRequest, NextResponse } from 'next/server'; +import * as XLSX from 'xlsx'; +import supabase from '@/lib/db'; + +// POST - Upload dan proses file Excel/CSV untuk data dosen +export async function POST(request: NextRequest) { + try { + // Ambil data formulir dari request + const formData = await request.formData(); + const file = formData.get('file') as File; + + if (!file) { + return NextResponse.json( + { message: 'File is required' }, + { status: 400 } + ); + } + + // Validasi tipe file (Excel atau CSV) + const allowedTypes = [ + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx + 'application/vnd.ms-excel', // .xls + 'text/csv' // .csv + ]; + + if (!allowedTypes.includes(file.type)) { + return NextResponse.json( + { message: 'File type not supported. Please upload Excel (.xlsx, .xls) or CSV file.' }, + { status: 400 } + ); + } + + // Baca file sebagai buffer + const buffer = Buffer.from(await file.arrayBuffer()); + + // Parse file menggunakan XLSX + const workbook = XLSX.read(buffer, { type: 'buffer' }); + const sheetName = workbook.SheetNames[0]; + const worksheet = workbook.Sheets[sheetName]; + + // Konversi ke JSON + const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 }); + + if (jsonData.length === 0) { + return NextResponse.json( + { message: 'File is empty' }, + { status: 400 } + ); + } + + // Ambil header dari baris pertama + const headers = jsonData[0] as string[]; + const dataRows = jsonData.slice(1); + + // Validasi header yang diperlukan + const requiredHeaders = ['nama_dosen', 'nip']; + const missingHeaders = requiredHeaders.filter(header => + !headers.some(h => h && h.toString().toLowerCase().includes(header.toLowerCase())) + ); + + if (missingHeaders.length > 0) { + return NextResponse.json( + { message: `Missing required columns: ${missingHeaders.join(', ')}` }, + { status: 400 } + ); + } + + // Mapping index kolom + const getColumnIndex = (headerName: string) => { + return headers.findIndex(h => h && h.toString().toLowerCase().includes(headerName.toLowerCase())); + }; + + const nama_dosenIndex = getColumnIndex('nama_dosen'); + const nipIndex = getColumnIndex('nip'); + + // Proses setiap baris data + const results = { + success: 0, + failed: 0, + errors: [] as string[] + }; + + for (let i = 0; i < dataRows.length; i++) { + const row = dataRows[i] as any[]; + const rowNumber = i + 2; // +2 karena index dimulai dari 0 dan ada header + + try { + // Ekstrak data dari row + const nama_dosen = row[nama_dosenIndex]?.toString().trim(); + const nip = row[nipIndex]?.toString().trim(); + + // Validasi data yang diperlukan + if (!nama_dosen || !nip) { + results.failed++; + results.errors.push(`Row ${rowNumber}: Missing required data (nama_dosen or nip)`); + continue; + } + + // Validasi panjang NIP + if (nip.length !== 18) { + results.failed++; + results.errors.push(`Row ${rowNumber}: NIP harus terdiri dari 18 karakter`); + continue; + } + + // Cek apakah NIP sudah ada + const { data: existingDosen, error: checkError } = await supabase + .from('dosen') + .select('nip') + .eq('nip', nip) + .single(); + + if (!checkError && existingDosen) { + // Update data yang sudah ada + const { error: updateError } = await supabase + .from('dosen') + .update({ + nama_dosen + }) + .eq('nip', nip); + + if (updateError) { + results.failed++; + results.errors.push(`Row ${rowNumber}: Error updating dosen with NIP ${nip}: ${updateError.message}`); + continue; + } + } else { + // Insert data baru + const { error: insertError } = await supabase + .from('dosen') + .insert({ + nama_dosen, + nip + }); + + if (insertError) { + results.failed++; + results.errors.push(`Row ${rowNumber}: Error inserting dosen with NIP ${nip}: ${insertError.message}`); + continue; + } + } + + results.success++; + } catch (error) { + results.failed++; + results.errors.push(`Row ${rowNumber}: Unexpected error: ${error}`); + } + } + + return NextResponse.json({ + message: `Upload completed. Success: ${results.success}, Failed: ${results.failed}`, + details: { + totalRows: dataRows.length, + successCount: results.success, + failedCount: results.failed, + errors: results.errors + } + }); + + } catch (error) { + console.error('Error processing file:', error); + return NextResponse.json( + { message: 'Error processing file' }, + { status: 500 } + ); + } +} diff --git a/app/api/keloladata/data-mahasiswa/route.ts b/app/api/keloladata/data-mahasiswa/route.ts index a8a59fc..69a0ad3 100644 --- a/app/api/keloladata/data-mahasiswa/route.ts +++ b/app/api/keloladata/data-mahasiswa/route.ts @@ -13,7 +13,9 @@ export async function GET(request: NextRequest) { .from('mahasiswa') .select(` *, - kelompok_keahlian!id_kelompok_keahlian(id_kk, nama_kelompok) + kelompok_keahlian!id_kelompok_keahlian(id_kk, nama_kelompok), + dosen_pembimbing_1:dosen!pembimbing_1(id_dosen, nama_dosen), + dosen_pembimbing_2:dosen!pembimbing_2(id_dosen, nama_dosen) `) .eq('nim', nim) .single(); @@ -25,9 +27,13 @@ export async function GET(request: NextRequest) { // Transformasi data untuk meratakan field yang di-join const transformedData = { ...data, - nama_kelompok_keahlian: data.kelompok_keahlian?.nama_kelompok || null + nama_kelompok_keahlian: data.kelompok_keahlian?.nama_kelompok || null, + nama_pembimbing_1: data.dosen_pembimbing_1?.nama_dosen || null, + nama_pembimbing_2: data.dosen_pembimbing_2?.nama_dosen || null }; delete transformedData.kelompok_keahlian; + delete transformedData.dosen_pembimbing_1; + delete transformedData.dosen_pembimbing_2; return NextResponse.json(transformedData); } else { @@ -36,7 +42,9 @@ export async function GET(request: NextRequest) { .from('mahasiswa') .select(` *, - kelompok_keahlian!id_kelompok_keahlian(id_kk, nama_kelompok) + kelompok_keahlian!id_kelompok_keahlian(id_kk, nama_kelompok), + dosen_pembimbing_1:dosen!pembimbing_1(id_dosen, nama_dosen), + dosen_pembimbing_2:dosen!pembimbing_2(id_dosen, nama_dosen) `) .order('nim'); @@ -48,8 +56,10 @@ export async function GET(request: NextRequest) { // Transformasi data untuk meratakan field yang di-join const transformedData = data.map(item => ({ ...item, - nama_kelompok_keahlian: item.kelompok_keahlian?.nama_kelompok || null - })).map(({ kelompok_keahlian, ...rest }) => rest); + nama_kelompok_keahlian: item.kelompok_keahlian?.nama_kelompok || null, + nama_pembimbing_1: item.dosen_pembimbing_1?.nama_dosen || null, + nama_pembimbing_2: item.dosen_pembimbing_2?.nama_dosen || null + })).map(({ kelompok_keahlian, dosen_pembimbing_1, dosen_pembimbing_2, ...rest }) => rest); return NextResponse.json(transformedData); } @@ -75,7 +85,10 @@ export async function POST(request: NextRequest) { ipk, id_kelompok_keahlian, status_kuliah, - semester + semester, + pembimbing_1, + pembimbing_2, + status_bimbingan } = body; // Validasi field yang wajib diisi @@ -115,7 +128,10 @@ export async function POST(request: NextRequest) { ipk: ipk || null, id_kelompok_keahlian: id_kelompok_keahlian || null, status_kuliah: status_kuliah || "Aktif", - semester: semester || 1 + semester: semester || 1, + pembimbing_1: pembimbing_1 || null, + pembimbing_2: pembimbing_2 || null, + status_bimbingan: status_bimbingan || "Belum Selesai" }) .select() .single(); @@ -157,7 +173,10 @@ export async function PUT(request: NextRequest) { ipk, id_kelompok_keahlian, status_kuliah, - semester + semester, + pembimbing_1, + pembimbing_2, + status_bimbingan } = body; // Cek apakah mahasiswa ada @@ -185,7 +204,10 @@ export async function PUT(request: NextRequest) { 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 + semester: semester || existing.semester, + pembimbing_1: pembimbing_1 !== undefined ? pembimbing_1 : existing.pembimbing_1, + pembimbing_2: pembimbing_2 !== undefined ? pembimbing_2 : existing.pembimbing_2, + status_bimbingan: status_bimbingan || existing.status_bimbingan }) .eq('nim', nim); diff --git a/app/api/keloladata/data-mahasiswa/upload/route.ts b/app/api/keloladata/data-mahasiswa/upload/route.ts index c0bcace..bea7b0e 100644 --- a/app/api/keloladata/data-mahasiswa/upload/route.ts +++ b/app/api/keloladata/data-mahasiswa/upload/route.ts @@ -120,7 +120,10 @@ function processData(headers: string[], rows: any[][]) { ipk: ['ipk', 'gpa'], kelompok_keahlian: ['kelompok_keahlian', 'kk', 'keahlian', 'id_kk'], status_kuliah: ['status_kuliah', 'status', 'status_mahasiswa'], - semester: ['semester', 'sem'] + semester: ['semester', 'sem'], + pembimbing_1_nip: ['pembimbing_1_nip', 'nip_pembimbing_1', 'pembimbing1_nip', 'dosen_pembimbing_1'], + pembimbing_2_nip: ['pembimbing_2_nip', 'nip_pembimbing_2', 'pembimbing2_nip', 'dosen_pembimbing_2'], + status_bimbingan: ['status_bimbingan', 'status_guidance', 'bimbingan_status'] }; // Map actual headers to expected headers @@ -205,6 +208,22 @@ function processData(headers: string[], rows: any[][]) { } } + // Handle pembimbing NIP + const pembimbing_1_nip = headerMap.pembimbing_1_nip !== undefined ? String(values[headerMap.pembimbing_1_nip] || '') || null : null; + const pembimbing_2_nip = headerMap.pembimbing_2_nip !== undefined ? String(values[headerMap.pembimbing_2_nip] || '') || null : null; + + // Handle status bimbingan + let status_bimbingan = 'Belum Selesai'; // Default value + if (headerMap.status_bimbingan !== undefined && values[headerMap.status_bimbingan] !== undefined) { + const statusValue = String(values[headerMap.status_bimbingan] || '').trim(); + if (statusValue) { + const mappedStatus = mapStatusBimbingan(statusValue); + if (mappedStatus) { + status_bimbingan = mappedStatus; + } + } + } + // Validate required fields if (!nim || !nama || !jenis_kelamin || !tahun_angkatan) { errors.push(`Row ${i+1}: Missing required fields`); @@ -241,14 +260,17 @@ function processData(headers: string[], rows: any[][]) { nama, jk: jenis_kelamin, agama, - kabupaten, + kabupaten, provinsi, jenis_pendaftaran, tahun_angkatan, ipk, kelompok_keahlian_id, status_kuliah, - semester + semester, + pembimbing_1_nip, + pembimbing_2_nip, + status_bimbingan }); } catch (error) { @@ -301,6 +323,23 @@ function mapStatus(value: string): 'Aktif' | 'Cuti' | 'Lulus' | 'Non-Aktif' | nu return null; } +// Function to map status bimbingan values to standardized format +function mapStatusBimbingan(value: string): 'Selesai' | 'Belum Selesai' | null { + if (!value) return null; + + const lowerValue = value.toLowerCase(); + + if (['selesai', 'completed', 'done', 'finished', 's', '1'].includes(lowerValue)) { + return 'Selesai'; + } + + if (['belum selesai', 'belum_selesai', 'not completed', 'incomplete', 'ongoing', 'in progress', 'b', '0'].includes(lowerValue)) { + return 'Belum Selesai'; + } + + return null; +} + // Fungsi untuk insert data ke database async function insertDataToDatabase(data: any[]) { let insertedCount = 0; @@ -315,6 +354,28 @@ async function insertDataToDatabase(data: any[]) { .eq('nim', item.nim) .single(); + // Lookup pembimbing berdasarkan NIP + let pembimbing_1_id = null; + let pembimbing_2_id = null; + + if (item.pembimbing_1_nip) { + const { data: dosen1 } = await supabase + .from('dosen') + .select('id_dosen') + .eq('nip', item.pembimbing_1_nip) + .single(); + pembimbing_1_id = dosen1?.id_dosen || null; + } + + if (item.pembimbing_2_nip) { + const { data: dosen2 } = await supabase + .from('dosen') + .select('id_dosen') + .eq('nip', item.pembimbing_2_nip) + .single(); + pembimbing_2_id = dosen2?.id_dosen || null; + } + const mahasiswaData = { nama: item.nama, jk: item.jk, @@ -326,7 +387,10 @@ async function insertDataToDatabase(data: any[]) { ipk: item.ipk, id_kelompok_keahlian: item.kelompok_keahlian_id, status_kuliah: item.status_kuliah, - semester: item.semester + semester: item.semester, + pembimbing_1: pembimbing_1_id, + pembimbing_2: pembimbing_2_id, + status_bimbingan: item.status_bimbingan }; if (existingData) { diff --git a/app/api/mahasiswa/bimbingan-dosen/route.ts b/app/api/mahasiswa/bimbingan-dosen/route.ts new file mode 100644 index 0000000..ad74619 --- /dev/null +++ b/app/api/mahasiswa/bimbingan-dosen/route.ts @@ -0,0 +1,101 @@ +import { NextRequest, NextResponse } from 'next/server'; +import supabase from '@/lib/db'; + +// GET - Ambil data bimbingan dosen berdasarkan status bimbingan +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const tahun_angkatan = searchParams.get('tahun_angkatan'); + + let query = supabase + .from('mahasiswa') + .select(` + pembimbing_1, + pembimbing_2, + status_bimbingan, + tahun_angkatan, + dosen_pembimbing_1:dosen!pembimbing_1(id_dosen, nama_dosen), + dosen_pembimbing_2:dosen!pembimbing_2(id_dosen, nama_dosen) + `); + + // Filter berdasarkan tahun angkatan jika diberikan + if (tahun_angkatan && tahun_angkatan !== 'all') { + query = query.eq('tahun_angkatan', tahun_angkatan); + } + + const { data, error } = await query; + + if (error) { + console.error('Error fetching bimbingan data:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } + + // Proses data untuk menggabungkan pembimbing 1 dan 2 + const bimbinganMap = new Map(); + + data.forEach(mahasiswa => { + // Proses pembimbing 1 + if (mahasiswa.pembimbing_1 && mahasiswa.dosen_pembimbing_1) { + const dosenId = (mahasiswa.dosen_pembimbing_1 as any).id_dosen; + const dosenNama = (mahasiswa.dosen_pembimbing_1 as any).nama_dosen; + const status = mahasiswa.status_bimbingan; + + if (!bimbinganMap.has(dosenId)) { + bimbinganMap.set(dosenId, { + id_dosen: dosenId, + nama_dosen: dosenNama, + selesai: 0, + belum_selesai: 0 + }); + } + + const dosenData = bimbinganMap.get(dosenId); + if (dosenData) { + if (status === 'Selesai') { + dosenData.selesai++; + } else { + dosenData.belum_selesai++; + } + } + } + + // Proses pembimbing 2 + if (mahasiswa.pembimbing_2 && mahasiswa.dosen_pembimbing_2) { + const dosenId = (mahasiswa.dosen_pembimbing_2 as any).id_dosen; + const dosenNama = (mahasiswa.dosen_pembimbing_2 as any).nama_dosen; + const status = mahasiswa.status_bimbingan; + + if (!bimbinganMap.has(dosenId)) { + bimbinganMap.set(dosenId, { + id_dosen: dosenId, + nama_dosen: dosenNama, + selesai: 0, + belum_selesai: 0 + }); + } + + const dosenData = bimbinganMap.get(dosenId); + if (dosenData) { + if (status === 'Selesai') { + dosenData.selesai++; + } else { + dosenData.belum_selesai++; + } + } + } + }); + + // Konversi Map ke Array dan urutkan berdasarkan nama dosen A-Z + const result = Array.from(bimbinganMap.values()) + .map(dosen => ({ + ...dosen, + total: dosen.selesai + dosen.belum_selesai + })) + .sort((a, b) => a.nama_dosen.localeCompare(b.nama_dosen)); + + return NextResponse.json(result); + } catch (error) { + console.error('Error fetching bimbingan data:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 5c00552..ec0230b 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -21,6 +21,8 @@ import TingkatPrestasiChart from "@/components/chartsDashboard/TingkatPrestasiDa import ProvinsiMahasiswaChart from "@/components/chartsDashboard/ProvinsiMahasiswaPieChart"; import TingkatPrestasiPieChartDash from "@/components/chartsDashboard/TingkatPrestasiPieChartDash"; import LulusTepatWaktuChart from "@/components/charts/LulusTepatWaktuChart"; +import BimbinganDosenChart from "@/components/charts/BimbinganDosenChart"; +import BimbinganDosenPerAngkatanChart from "@/components/charts/BimbinganDosenPerAngkatanChart"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { @@ -82,7 +84,8 @@ export default function TotalMahasiswaPage() { { id: 'study-duration', label: 'Kelulusan Tepat Waktu & Masa Studi' }, { id: 'expertise', label: 'Kelompok Keahlian' }, { id: 'scholarship', label: 'Beasiswa & Prestasi' }, - { id: 'demographics', label: 'Asal Kabupaten & Provinsi' } + { id: 'demographics', label: 'Asal Kabupaten & Provinsi' }, + { id: 'bimbingan-dosen', label: 'Bimbingan Dosen' } ]; // Navigation menu items for per year data @@ -90,7 +93,8 @@ export default function TotalMahasiswaPage() { { id: 'overview-year', label: 'Jumlah & Status per Angkatan' }, { id: 'status-year', label: 'Jenis Pendaftaran & Kelompok Keahlian' }, { id: 'achievement-year', label: 'Beasiswa & Prestasi per Angkatan' }, - { id: 'demographics-year', label: 'Asal Kabupaten per Angkatan' } + { id: 'demographics-year', label: 'Asal Kabupaten per Angkatan' }, + { id: 'bimbingan-dosen-year', label: 'Bimbingan Dosen' } ]; return ( @@ -177,6 +181,10 @@ export default function TotalMahasiswaPage() { + +
+ +
) : (
@@ -198,9 +206,13 @@ export default function TotalMahasiswaPage() {
{/* Demographics Section */} -
+
+ +
+ +
)} diff --git a/app/detail/bimbingan-dosen/page.tsx b/app/detail/bimbingan-dosen/page.tsx new file mode 100644 index 0000000..01a2919 --- /dev/null +++ b/app/detail/bimbingan-dosen/page.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { useState } from "react"; +import BimbinganDosenChart from "@/components/charts/BimbinganDosenChart"; +import BimbinganDosenPerAngkatanChart from "@/components/charts/BimbinganDosenPerAngkatanChart"; +import FilterTahunAngkatan from "@/components/FilterTahunAngkatan"; + +export default function BimbinganDosenDetailPage() { + const [selectedYear, setSelectedYear] = useState("all"); + + return ( +
+
+ {/* Filter Section */} + + + {/* Chart Section - Enhanced Size */} +
+ {/* Chart untuk semua data atau dual chart ketika tahun tertentu dipilih */} + {selectedYear === "all" ? ( +
+ +
+ ) : ( + <> + + + )} +
+ + {/* Information Section */} +
+

+ Informasi Visualisasi +

+
+
+

+ Grafik Utama (Bimbingan Dosen) +

+
    +
  • • Menampilkan statistik bimbingan mahasiswa per dosen pembimbing
  • +
  • • Data terbagi menjadi dua kategori: "Selesai" dan "Belum Selesai"
  • +
  • • Hijau menunjukkan bimbingan selesai, kuning untuk belum selesai
  • +
+
+ {selectedYear !== "all" && ( +
+

+ Grafik Per Angkatan ({selectedYear}) +

+
    +
  • • Menampilkan statistik bimbingan untuk angkatan {selectedYear}
  • +
  • • Data spesifik beban bimbingan dosen per angkatan
  • +
  • • Insight progress penyelesaian bimbingan per tahun angkatan
  • +
+
+ )} +
+
+
+
+ ); +} diff --git a/app/keloladata/dosen/page.tsx b/app/keloladata/dosen/page.tsx new file mode 100644 index 0000000..dfda1c1 --- /dev/null +++ b/app/keloladata/dosen/page.tsx @@ -0,0 +1,9 @@ +import DataTableDosen from "@/components/datatable/data-table-dosen"; + +export default function DosenPage() { + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx index 7ee2f0c..d349b09 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -105,7 +105,7 @@ export default function DashboardPage() { return (
-

Visualisasi Data Akademik Mahasiswa Informatika

+ {/*

Visualisasi Data Akademik Mahasiswa Informatika

*/} {loading ? (
diff --git a/components/charts/AsalDaerahChart.tsx b/components/charts/AsalDaerahChart.tsx index d86caa5..a4c8a1b 100644 --- a/components/charts/AsalDaerahChart.tsx +++ b/components/charts/AsalDaerahChart.tsx @@ -50,7 +50,7 @@ export default function AsalDaerahChart({ distributed: false, barHeight: '90%', dataLabels: { - position: 'top', + position: 'center', }, }, }, @@ -60,10 +60,9 @@ export default function AsalDaerahChart({ return val.toString(); }, style: { - fontSize: '14px', + fontSize: '12px', colors: [theme === 'dark' ? '#fff' : '#000'] }, - offsetX: 10, }, stroke: { show: true, @@ -268,7 +267,7 @@ export default function AsalDaerahChart({ const calculateHeight = () => { const minHeight = 100; const barHeight = 15; // Tinggi per bar dalam piksel - const padding = 50; // Ruang ekstra untuk judul, legenda, dll + const padding = 100; // Ruang ekstra untuk judul, legenda, dll const dynamicHeight = Math.max(minHeight, (data.length * barHeight) + padding); return `${dynamicHeight}px`; }; @@ -292,7 +291,7 @@ export default function AsalDaerahChart({
{ const minHeight = 100; const barHeight = 15; // Tinggi per bar dalam piksel - const padding = 50; // Ruang ekstra untuk judul, legenda, dll + const padding = 100; // Ruang ekstra untuk judul, legenda, dll const dynamicHeight = Math.max(minHeight, (data.length * barHeight) + padding); return `${dynamicHeight}px`; }; @@ -317,7 +316,7 @@ export default function AsalDaerahPerAngkatanChart({ tahunAngkatan }: Props) {
import('react-apexcharts'), { ssr: false }); + +interface BimbinganDosenData { + id_dosen: number; + nama_dosen: string; + selesai: number; + belum_selesai: number; + total: number; +} + +interface BimbinganDosenChartProps { + height?: string; + showDetailButton?: boolean; +} + +export default function BimbinganDosenChart({ + height = "h-[400px] sm:h-[450px] md:h-[500px] lg:h-[600px]", + showDetailButton = true +}: BimbinganDosenChartProps = {}) { + const { theme } = useTheme(); + const [mounted, setMounted] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [data, setData] = useState([]); + const [series, setSeries] = useState([ + { + name: 'Selesai', + data: [] + }, + { + name: 'Belum Selesai', + data: [] + } + ]); + const [options, setOptions] = useState({ + chart: { + type: 'bar', + stacked: true, + toolbar: { + show: true, + }, + }, + plotOptions: { + bar: { + horizontal: true, + columnWidth: '85%', + distributed: false, + barHeight: '90%', + dataLabels: { + position: 'center', + }, + }, + }, + dataLabels: { + enabled: true, + formatter: function (val: number) { + return val > 0 ? val.toString() : ''; + }, + style: { + fontSize: '12px', + colors: [theme === 'dark' ? '#fff' : '#000'] + }, + }, + stroke: { + show: true, + width: 1, + colors: [theme === 'dark' ? '#374151' : '#E5E7EB'], + }, + xaxis: { + categories: [], + title: { + text: 'Jumlah Bimbingan', + style: { + fontSize: '14px', + fontWeight: 'bold', + color: theme === 'dark' ? '#fff' : '#000' + } + }, + labels: { + style: { + fontSize: '12px', + colors: theme === 'dark' ? '#fff' : '#000' + } + } + }, + yaxis: { + title: { + text: 'Nama Dosen', + style: { + fontSize: '14px', + fontWeight: 'bold', + color: theme === 'dark' ? '#fff' : '#000' + } + }, + labels: { + style: { + fontSize: '14px', + colors: theme === 'dark' ? '#fff' : '#000' + }, + maxWidth: 200, + }, + }, + legend: { + position: 'top', + fontSize: '14px', + markers: { + size: 12, + }, + itemMargin: { + horizontal: 10, + }, + horizontalAlign: 'center', + labels: { + colors: theme === 'dark' ? '#fff' : '#000' + } + }, + fill: { + opacity: 1, + }, + colors: ['#10B981', '#F59E0B'], // Hijau untuk Selesai, Kuning untuk Belum Selesai + tooltip: { + theme: theme === 'dark' ? 'dark' : 'light', + y: { + formatter: function (val: number) { + return val + " mahasiswa"; + } + } + } + }); + + // Perbarui tema saat berubah + useEffect(() => { + setOptions(prev => ({ + ...prev, + chart: { + ...prev.chart, + background: theme === 'dark' ? '#0F172B' : '#fff', + }, + xaxis: { + ...prev.xaxis, + title: { + ...prev.xaxis?.title, + style: { + ...prev.xaxis?.title?.style, + color: theme === 'dark' ? '#fff' : '#000' + } + }, + labels: { + ...prev.xaxis?.labels, + style: { + ...prev.xaxis?.labels?.style, + colors: theme === 'dark' ? '#fff' : '#000' + } + } + }, + yaxis: { + ...prev.yaxis, + title: { + ...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title : prev.yaxis?.title), + style: { + ...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title?.style : prev.yaxis?.title?.style), + color: theme === 'dark' ? '#fff' : '#000' + } + }, + labels: { + ...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels : prev.yaxis?.labels), + style: { + ...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels?.style : prev.yaxis?.labels?.style), + colors: theme === 'dark' ? '#fff' : '#000' + } + } + }, + legend: { + ...prev.legend, + labels: { + ...prev.legend?.labels, + colors: theme === 'dark' ? '#fff' : '#000' + } + }, + tooltip: { + ...prev.tooltip, + theme: theme === 'dark' ? 'dark' : 'light' + } + })); + }, [theme]); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + const fetchData = async () => { + try { + setLoading(true); + setError(null); + + const response = await fetch('/api/mahasiswa/bimbingan-dosen'); + + if (!response.ok) { + throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`); + } + + const result = await response.json(); + + if (!Array.isArray(result)) { + throw new Error('Format data tidak valid diterima dari server'); + } + + // Urutkan data berdasarkan nama dosen A-Z + const sortedResult = result.sort((a, b) => a.nama_dosen.localeCompare(b.nama_dosen)); + setData(sortedResult); + + // Proses data untuk chart + const namaDosen = sortedResult.map(item => item.nama_dosen); + const selesaiData = sortedResult.map(item => item.selesai); + const belumSelesaiData = sortedResult.map(item => item.belum_selesai); + + setSeries([ + { + name: 'Selesai', + data: selesaiData + }, + { + name: 'Belum Selesai', + data: belumSelesaiData + } + ]); + + setOptions(prev => ({ + ...prev, + xaxis: { + ...prev.xaxis, + categories: namaDosen, + }, + })); + } catch (err) { + console.error('Error in fetchData:', err); + setError(err instanceof Error ? err.message : 'Terjadi kesalahan saat mengambil data'); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, []); + + if (!mounted) { + return null; + } + + if (loading) { + return ( + + + + Loading... + + + + ); + } + + if (error) { + return ( + + + + Error: {error} + + + + ); + } + + if (data.length === 0) { + return ( + + + + Tidak ada data bimbingan yang tersedia + + + + ); + } + + // Hitung tinggi dinamis berdasarkan jumlah dosen + const calculateHeight = () => { + const minHeight = 200; + const barHeight = 25; // Tinggi per bar dalam piksel + const padding = 100; // Ruang ekstra untuk judul, legenda, dll + const dynamicHeight = Math.max(minHeight, (data.length * barHeight) + padding); + return `${dynamicHeight}px`; + }; + + return ( + + +
+ + Bimbingan Dosen + + {showDetailButton && ( + + + + )} +
+
+ +
+ +
+
+
+ ); +} diff --git a/components/charts/BimbinganDosenPerAngkatanChart.tsx b/components/charts/BimbinganDosenPerAngkatanChart.tsx new file mode 100644 index 0000000..33f7ecd --- /dev/null +++ b/components/charts/BimbinganDosenPerAngkatanChart.tsx @@ -0,0 +1,358 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import dynamic from 'next/dynamic'; +import { ApexOptions } from 'apexcharts'; +import { useTheme } from 'next-themes'; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +// Import ApexCharts secara dinamis untuk menghindari masalah SSR +const Chart = dynamic(() => import('react-apexcharts'), { ssr: false }); + +interface BimbinganDosenData { + id_dosen: number; + nama_dosen: string; + selesai: number; + belum_selesai: number; + total: number; +} + +interface Props { + tahunAngkatan: string; +} + +export default function BimbinganDosenPerAngkatanChart({ tahunAngkatan }: Props) { + const { theme } = useTheme(); + const [mounted, setMounted] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [data, setData] = useState([]); + const [series, setSeries] = useState([ + { + name: 'Selesai', + data: [] + }, + { + name: 'Belum Selesai', + data: [] + } + ]); + const [options, setOptions] = useState({ + chart: { + type: 'bar', + stacked: true, + toolbar: { + show: true, + tools: { + download: true, + selection: true, + zoom: true, + zoomin: true, + zoomout: true, + pan: true, + reset: true, + customIcons: [] + }, + export: { + csv: { + filename: `bimbingan-dosen-angkatan`, + columnDelimiter: ',', + headerCategory: 'Nama Dosen', + headerValue: 'Jumlah Bimbingan' + } + }, + }, + }, + plotOptions: { + bar: { + horizontal: true, + columnWidth: '85%', + distributed: false, + barHeight: '90%', + dataLabels: { + position: 'center' + } + } + }, + dataLabels: { + enabled: true, + formatter: function (val: number) { + return val > 0 ? val.toString() : ''; + }, + style: { + fontSize: '12px', + colors: [theme === 'dark' ? '#fff' : '#000'] + }, + }, + stroke: { + show: true, + width: 1, + colors: [theme === 'dark' ? '#374151' : '#E5E7EB'], + }, + xaxis: { + categories: [], + title: { + text: 'Jumlah Bimbingan', + style: { + fontSize: '14px', + fontWeight: 'bold', + color: theme === 'dark' ? '#fff' : '#000' + } + }, + labels: { + style: { + fontSize: '12px', + colors: theme === 'dark' ? '#fff' : '#000' + } + } + }, + yaxis: { + title: { + text: 'Nama Dosen', + style: { + fontSize: '14px', + fontWeight: 'bold', + color: theme === 'dark' ? '#fff' : '#000' + } + }, + labels: { + style: { + fontSize: '14px', + colors: theme === 'dark' ? '#fff' : '#000' + }, + maxWidth: 200, + }, + }, + legend: { + position: 'top', + horizontalAlign: 'center', + labels: { + colors: theme === 'dark' ? '#fff' : '#000' + } + }, + grid: { + padding: { + top: 0, + right: 0, + bottom: 0, + left: 0 + } + }, + fill: { + opacity: 1, + }, + colors: ['#10B981', '#F59E0B'], // Hijau untuk Selesai, Kuning untuk Belum Selesai + tooltip: { + theme: theme === 'dark' ? 'dark' : 'light', + y: { + formatter: function (val: number) { + return val + " mahasiswa"; + } + } + } + }); + + // Perbarui tema saat berubah + useEffect(() => { + setOptions(prev => ({ + ...prev, + chart: { + ...prev.chart, + background: theme === 'dark' ? '#0F172B' : '#fff', + }, + xaxis: { + ...prev.xaxis, + title: { + ...prev.xaxis?.title, + style: { + ...prev.xaxis?.title?.style, + color: theme === 'dark' ? '#fff' : '#000' + } + }, + labels: { + ...prev.xaxis?.labels, + style: { + ...prev.xaxis?.labels?.style, + colors: theme === 'dark' ? '#fff' : '#000' + } + } + }, + yaxis: { + ...prev.yaxis, + title: { + ...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title : prev.yaxis?.title), + style: { + ...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title?.style : prev.yaxis?.title?.style), + color: theme === 'dark' ? '#fff' : '#000' + } + }, + labels: { + ...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels : prev.yaxis?.labels), + style: { + ...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels?.style : prev.yaxis?.labels?.style), + colors: theme === 'dark' ? '#fff' : '#000' + } + } + }, + legend: { + ...prev.legend, + labels: { + ...prev.legend?.labels, + colors: theme === 'dark' ? '#fff' : '#000' + } + }, + tooltip: { + ...prev.tooltip, + theme: theme === 'dark' ? 'dark' : 'light' + } + })); + }, [theme]); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + const fetchData = async () => { + try { + setLoading(true); + setError(null); + + const response = await fetch(`/api/mahasiswa/bimbingan-dosen?tahun_angkatan=${tahunAngkatan}`); + + if (!response.ok) { + throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`); + } + + const result = await response.json(); + + if (!Array.isArray(result)) { + throw new Error('Format data tidak valid diterima dari server'); + } + + // Urutkan data berdasarkan nama dosen A-Z + const sortedResult = result.sort((a, b) => a.nama_dosen.localeCompare(b.nama_dosen)); + setData(sortedResult); + + // Proses data untuk chart + const namaDosen = sortedResult.map(item => item.nama_dosen); + const selesaiData = sortedResult.map(item => item.selesai); + const belumSelesaiData = sortedResult.map(item => item.belum_selesai); + + setSeries([ + { + name: 'Selesai', + data: selesaiData + }, + { + name: 'Belum Selesai', + data: belumSelesaiData + } + ]); + + setOptions(prev => ({ + ...prev, + xaxis: { + ...prev.xaxis, + categories: namaDosen, + }, + chart: { + ...prev.chart, + toolbar: { + ...prev.chart?.toolbar, + export: { + ...prev.chart?.toolbar?.export, + csv: { + ...prev.chart?.toolbar?.export?.csv, + filename: `bimbingan-dosen-angkatan-${tahunAngkatan}`, + } + } + } + } + })); + } catch (err) { + console.error('Error in fetchData:', err); + setError(err instanceof Error ? err.message : 'Terjadi kesalahan saat mengambil data'); + } finally { + setLoading(false); + } + }; + + if (tahunAngkatan) { + fetchData(); + } + }, [tahunAngkatan]); + + if (!mounted) { + return null; + } + + if (loading) { + return ( + + + + Loading... + + + + ); + } + + if (error) { + return ( + + + + Error: {error} + + + + ); + } + + if (data.length === 0) { + return ( + + + + Tidak ada data bimbingan yang tersedia untuk angkatan {tahunAngkatan} + + + + ); + } + + // Hitung tinggi dinamis berdasarkan jumlah dosen + const calculateHeight = () => { + const minHeight = 200; + const barHeight = 25; // Tinggi per bar dalam piksel + const padding = 100; // Ruang ekstra untuk judul, legenda, dll + const dynamicHeight = Math.max(minHeight, (data.length * barHeight) + padding); + return `${dynamicHeight}px`; + }; + + return ( + + + + Bimbingan Dosen Angkatan {tahunAngkatan} + + + +
+ +
+
+
+ ); +} diff --git a/components/datatable/data-table-dosen.tsx b/components/datatable/data-table-dosen.tsx new file mode 100644 index 0000000..1c0c53f --- /dev/null +++ b/components/datatable/data-table-dosen.tsx @@ -0,0 +1,617 @@ +"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, + 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 +} from "lucide-react"; +import UploadExcelDosen from "@/components/datatable/upload-file-dosen"; +import { useToast } from "@/components/ui/toast-provider"; + +// Define the Dosen type +interface Dosen { + id_dosen: number; + nama_dosen: string; + nip: string; +} + +export default function DataTableDosen() { + const { showSuccess, showError } = useToast(); + + // State for data + const [dosen, setDosen] = useState([]); + const [filteredData, setFilteredData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // State for filtering + const [searchTerm, setSearchTerm] = 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>({}); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isDialogOpen, setIsDialogOpen] = useState(false); + + // State for delete confirmation + const [deleteId, setDeleteId] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + + // Fetch data on component mount + useEffect(() => { + fetchDosen(); + }, []); + + // Filter data when search term changes + useEffect(() => { + filterData(); + }, [searchTerm, dosen]); + + // Update paginated data when filtered data or pagination settings change + useEffect(() => { + paginateData(); + }, [filteredData, currentPage, pageSize]); + + // Fetch dosen data from API + const fetchDosen = async () => { + try { + setLoading(true); + let url = "/api/keloladata/data-dosen"; + + // Add search to URL if it exists + const params = new URLSearchParams(); + if (searchTerm) { + params.append("search", searchTerm); + } + + if (params.toString()) { + url += `?${params.toString()}`; + } + + const response = await fetch(url); + + if (!response.ok) { + throw new Error("Failed to fetch data"); + } + + const data = await response.json(); + setDosen(data); + setFilteredData(data); + setError(null); + } catch (err) { + setError("Error fetching data. Please try again later."); + console.error("Error fetching data:", err); + } finally { + setLoading(false); + } + }; + + // Filter data based on search term + const filterData = () => { + let filtered = [...dosen]; + + // Filter by search term + if (searchTerm) { + filtered = filtered.filter( + (item) => + (item.nama_dosen?.toLowerCase() || "").includes(searchTerm.toLowerCase()) || + (item.nip?.toLowerCase() || "").includes(searchTerm.toLowerCase()) + ); + } + + 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({}); + }; + + // Handle form input changes + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + // Open form dialog for adding new dosen + const handleAdd = () => { + setFormMode("add"); + resetForm(); + setIsDialogOpen(true); + }; + + // Open form dialog for editing dosen + const handleEdit = (data: Dosen) => { + setFormMode("edit"); + setFormData(data); + setIsDialogOpen(true); + }; + + // Open delete confirmation dialog + const handleDeleteConfirm = (id: number) => { + setDeleteId(id); + setIsDeleteDialogOpen(true); + }; + + // Submit form for add/edit + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + try { + setIsSubmitting(true); + + if (formMode === "add") { + // Add new dosen + const response = await fetch("/api/keloladata/data-dosen", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(formData), + }); + + const responseData = await response.json(); + + if (!response.ok) { + // Handle specific errors + if (response.status === 409) { + showError("Gagal!", responseData.message); + throw new Error(responseData.message); + } + if (response.status === 400) { + showError("Gagal!", responseData.message); + throw new Error(responseData.message); + } + showError("Gagal!", "Gagal menambahkan dosen"); + throw new Error(responseData.message || "Failed to add dosen"); + } + + showSuccess("Berhasil!", "Dosen berhasil ditambahkan"); + } else { + // Edit existing dosen + const response = await fetch(`/api/keloladata/data-dosen?id=${formData.id_dosen}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(formData), + }); + + const responseData = await response.json(); + + if (!response.ok) { + // Handle specific errors + if (response.status === 409) { + showError("Gagal!", responseData.message); + throw new Error(responseData.message); + } + if (response.status === 400) { + showError("Gagal!", responseData.message); + throw new Error(responseData.message); + } + showError("Gagal!", responseData.message || "Failed to update dosen"); + throw new Error(responseData.message || "Failed to update dosen"); + } + + showSuccess("Berhasil!", "Dosen berhasil diperbarui"); + } + + // Refresh data after successful operation + await fetchDosen(); + setIsDialogOpen(false); + resetForm(); + } catch (err) { + console.error("Error submitting form:", err); + } finally { + setIsSubmitting(false); + } + }; + + // Delete dosen + const handleDelete = async () => { + if (!deleteId) return; + + try { + setIsDeleting(true); + + const response = await fetch(`/api/keloladata/data-dosen?id=${deleteId}`, { + method: "DELETE", + }); + + const responseData = await response.json(); + + if (!response.ok) { + if (response.status === 409) { + showError("Gagal!", responseData.message); + throw new Error(responseData.message); + } + showError("Gagal!", responseData.message || "Failed to delete dosen"); + throw new Error(responseData.message || "Failed to delete dosen"); + } + + // Refresh data after successful deletion + await fetchDosen(); + setIsDeleteDialogOpen(false); + setDeleteId(null); + showSuccess("Berhasil!", "Dosen berhasil dihapus"); + } catch (err) { + console.error("Error deleting dosen:", err); + } finally { + setIsDeleting(false); + } + }; + + // 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 Dosen

+
+ +
+
+ + {/* Search */} +
+
+ + setSearchTerm(e.target.value)} + /> + {searchTerm && ( + setSearchTerm("")} + /> + )} +
+
+ + {/* Show entries selector */} +
+ Show + + entries +
+ + {/* Table */} + {loading ? ( +
+ +
+ ) : error ? ( +
+ {error} +
+ ) : ( +
+ + + + {/* ID */} + Nama Dosen + NIP + Aksi + + + + {paginatedData.length === 0 ? ( + + + Tidak ada data yang sesuai dengan pencarian + + + ) : ( + paginatedData.map((dosenItem) => ( + + {/* {dosenItem.id_dosen} */} + {dosenItem.nama_dosen} + {dosenItem.nip} + +
+ + +
+
+
+ )) + )} +
+
+
+ )} + + {/* 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 Dosen" : "Edit Dosen"} + + +
+
+
+ + +
+
+ + +

+ NIP harus terdiri dari 18 karakter +

+
+
+ + + + + + +
+
+
+ + {/* Delete Confirmation Dialog */} + + + + Konfirmasi Hapus + +
+

Apakah Anda yakin ingin menghapus data dosen ini?

+

+ Tindakan ini tidak dapat dibatalkan. +

+
+ + + + + + +
+
+
+ ); +} diff --git a/components/datatable/data-table-mahasiswa.tsx b/components/datatable/data-table-mahasiswa.tsx index 753b7e6..566757e 100644 --- a/components/datatable/data-table-mahasiswa.tsx +++ b/components/datatable/data-table-mahasiswa.tsx @@ -66,6 +66,11 @@ interface Mahasiswa { nama_kelompok_keahlian: string | null; status_kuliah: "Aktif" | "Cuti" | "Lulus" | "Non-Aktif"; semester: number; + pembimbing_1: number | null; + nama_pembimbing_1: string | null; + pembimbing_2: number | null; + nama_pembimbing_2: string | null; + status_bimbingan: "Selesai" | "Belum Selesai"; created_at: string; updated_at: string; } @@ -76,6 +81,13 @@ interface KelompokKeahlian { nama_kelompok: string; } +// Define the Dosen type +interface Dosen { + id_dosen: number; + nama_dosen: string; + nip: string; +} + export default function DataTableMahasiswa() { const { showSuccess, showError } = useToast(); // State for data @@ -125,6 +137,9 @@ export default function DataTableMahasiswa() { const [deleteKelompokKeahlianId, setDeleteKelompokKeahlianId] = useState(null); const [isKelompokKeahlianDeleting, setIsKelompokKeahlianDeleting] = useState(false); const [isKelompokKeahlianDeleteDialogOpen, setIsKelompokKeahlianDeleteDialogOpen] = useState(false); + + // State for dosen data + const [dosenData, setDosenData] = useState([]); // Fetch data on component mount useEffect(() => { @@ -132,6 +147,7 @@ export default function DataTableMahasiswa() { fetchJenisPendaftaranOptions(); fetchKelompokKeahlianOptions(); fetchKelompokKeahlianData(); + fetchDosenData(); }, []); // Filter data when search term or filter changes @@ -267,7 +283,8 @@ export default function DataTableMahasiswa() { setFormData({ jk: "Pria", status_kuliah: "Aktif", - semester: 1 + semester: 1, + status_bimbingan: "Belum Selesai" }); }; @@ -291,6 +308,9 @@ export default function DataTableMahasiswa() { } else if (name === "semester") { const numValue = value === "" ? 1 : parseInt(value); setFormData((prev) => ({ ...prev, [name]: numValue })); + } else if (name === "pembimbing_1" || name === "pembimbing_2") { + const numValue = value === "null" || value === "" ? null : parseInt(value); + setFormData((prev) => ({ ...prev, [name]: numValue })); } else { setFormData((prev) => ({ ...prev, [name]: value })); } @@ -329,6 +349,22 @@ export default function DataTableMahasiswa() { } }; + // Fetch dosen data from API + const fetchDosenData = async () => { + try { + const response = await fetch("/api/keloladata/data-dosen"); + + if (!response.ok) { + throw new Error("Failed to fetch dosen data"); + } + + const data = await response.json(); + setDosenData(data); + } catch (err) { + console.error("Error fetching dosen data:", err); + } + }; + // Open form dialog for adding new mahasiswa const handleAdd = () => { setFormMode("add"); @@ -337,6 +373,7 @@ export default function DataTableMahasiswa() { // Make sure we have the latest options fetchJenisPendaftaranOptions(); fetchKelompokKeahlianOptions(); + fetchDosenData(); }; // Open form dialog for editing mahasiswa @@ -347,6 +384,7 @@ export default function DataTableMahasiswa() { // Make sure we have the latest options fetchJenisPendaftaranOptions(); fetchKelompokKeahlianOptions(); + fetchDosenData(); }; // Open delete confirmation dialog @@ -748,13 +786,16 @@ export default function DataTableMahasiswa() { IPK Status Kuliah Kelompok Keahlian + Pembimbing 1 + Pembimbing 2 + Status Bimbingan Aksi {paginatedData.length === 0 ? ( - + Tidak ada data yang sesuai dengan filter @@ -787,6 +828,19 @@ export default function DataTableMahasiswa() { {mhs.nama_kelompok_keahlian || "-"} + {mhs.nama_pembimbing_1 || "-"} + {mhs.nama_pembimbing_2 || "-"} + + + {mhs.status_bimbingan} + +
@@ -1026,6 +1080,65 @@ export default function DataTableMahasiswa() { required />
+
+ + +
+
+ + +
+
+ + +
diff --git a/components/datatable/upload-file-dosen.tsx b/components/datatable/upload-file-dosen.tsx new file mode 100644 index 0000000..78b0f88 --- /dev/null +++ b/components/datatable/upload-file-dosen.tsx @@ -0,0 +1,225 @@ +"use client"; + +import { useState, useRef } 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 { Upload, FileText, Loader2, X, CheckCircle, AlertCircle } from "lucide-react"; +import { useToast } from "@/components/ui/toast-provider"; + +interface UploadExcelDosenProps { + onUploadSuccess: () => void; +} + +export default function UploadExcelDosen({ onUploadSuccess }: UploadExcelDosenProps) { + const { showSuccess, showError } = useToast(); + const fileInputRef = useRef(null); + + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + const [isUploading, setIsUploading] = useState(false); + const [uploadResult, setUploadResult] = useState(null); + + // Handle file selection + const handleFileSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + // Validate file type + const allowedTypes = [ + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-excel', + 'text/csv' + ]; + + if (allowedTypes.includes(file.type)) { + setSelectedFile(file); + } else { + showError("File tidak valid", "Silakan pilih file Excel (.xlsx, .xls) atau CSV (.csv)"); + e.target.value = ''; + } + } + }; + + // Handle file upload + const handleUpload = async () => { + if (!selectedFile) { + showError("Tidak ada file", "Silakan pilih file terlebih dahulu"); + return; + } + + try { + setIsUploading(true); + + const formData = new FormData(); + formData.append('file', selectedFile); + + const response = await fetch('/api/keloladata/data-dosen/upload', { + method: 'POST', + body: formData, + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.message || 'Upload failed'); + } + + setUploadResult(result); + + if (result.details.successCount > 0) { + showSuccess("Upload berhasil!", result.message); + onUploadSuccess(); // Refresh the main table + } else { + showError("Upload gagal", "Tidak ada data yang berhasil diupload"); + } + + } catch (error) { + console.error('Upload error:', error); + showError("Error", error instanceof Error ? error.message : "Terjadi kesalahan saat upload"); + } finally { + setIsUploading(false); + } + }; + + // Reset dialog + const resetDialog = () => { + setSelectedFile(null); + setUploadResult(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + // Close dialog + const handleDialogClose = () => { + setIsDialogOpen(false); + resetDialog(); + }; + + return ( + <> + + + + + + Upload Data Dosen + + +
+ {/* Instructions */} +
+

Petunjuk Upload:

+
    +
  • • File harus berformat Excel (.xlsx, .xls) atau CSV (.csv)
  • +
  • • Kolom yang diperlukan: nama_dosen, nip
  • +
  • • NIP harus terdiri dari 18 karakter
  • +
  • • Jika NIP sudah ada, data akan diupdate
  • +
  • • Baris pertama harus berisi header kolom
  • +
+
+ + {/* File Input */} +
+ +
+ + {selectedFile && ( + + )} +
+ {selectedFile && ( +
+ + {selectedFile.name} + + ({(selectedFile.size / 1024).toFixed(1)} KB) + +
+ )} +
+ + {/* Upload Result */} + {uploadResult && ( +
+
+

Hasil Upload:

+
+
+ + Berhasil: {uploadResult.details.successCount} +
+
+ + Gagal: {uploadResult.details.failedCount} +
+
+
+ Total baris: {uploadResult.details.totalRows} +
+
+ + {/* Show errors if any */} + {uploadResult.details.errors && uploadResult.details.errors.length > 0 && ( +
+
Error Details:
+
+ {uploadResult.details.errors.slice(0, 10).map((error: string, index: number) => ( +
+ {error} +
+ ))} + {uploadResult.details.errors.length > 10 && ( +
+ ... dan {uploadResult.details.errors.length - 10} error lainnya +
+ )} +
+
+ )} +
+ )} +
+ + + + + + + +
+
+ + ); +} diff --git a/components/ui/Navbar.tsx b/components/ui/Navbar.tsx index 73e4d8e..9a2baac 100644 --- a/components/ui/Navbar.tsx +++ b/components/ui/Navbar.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { ThemeToggle } from '@/components/theme-toggle'; -import { Menu, ChevronDown, BarChart, Database, CircleCheck, School, GraduationCap, Clock, BookOpen, Award, Home, LogOut, User } from 'lucide-react'; +import { Menu, ChevronDown, BarChart, Database, CircleCheck, School, GraduationCap, Clock, BookOpen, Award, Home, LogOut, User, Users } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; import { @@ -194,6 +194,12 @@ const Navbar = () => { Prestasi + + + + Dosen + + )}