Change Alur Aplikasi

This commit is contained in:
Randa Firman Putra
2025-07-14 15:07:33 +07:00
parent db82b40a6b
commit 6d86e1ca2f
53 changed files with 6109 additions and 964 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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() {

View File

@@ -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<string>("all");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -196,20 +200,37 @@ export default function DashboardPage() {
</Card>
</div>
{/* Diagram Statistik Mahasiswa */}
<StatistikMahasiswaChart />
<Card className="bg-white dark:bg-slate-900 shadow-lg dark:text-white">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium dark:text-white">
Filter Data
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:space-x-4">
<FilterTahunAngkatan
selectedYear={selectedYear}
onYearChange={setSelectedYear}
/>
</div>
</CardContent>
</Card>
{/* Diagram Status Mahasiswa */}
<StatusMahasiswaChart />
{/* Diagram Jenis Pendaftaran */}
<JenisPendaftaranChart />
{/* Diagram Asal Daerah */}
<AsalDaerahChart />
{/* Diagram IPK */}
<IPKChart />
{selectedYear === "all" ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<StatistikMahasiswaChart />
<JenisPendaftaranChart />
<StatusMahasiswaChart />
<IPKChart />
<AsalDaerahChart />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<StatistikPerAngkatanChart tahunAngkatan={selectedYear} />
<JenisPendaftaranPerAngkatanChart tahunAngkatan={selectedYear} />
<AsalDaerahPerAngkatanChart tahunAngkatan={selectedYear} />
</div>
)}
</>
)}
</div>

View File

@@ -0,0 +1,9 @@
import DataTableBeasiswaMahasiswa from "@/components/data-table-beasiswa-mahasiswa";
export default function BeasiswaPage() {
return (
<div className="flex flex-col gap-4 py-4 px-4 md:gap-6 md:py-6">
<DataTableBeasiswaMahasiswa />
</div>
);
}

View File

@@ -0,0 +1,9 @@
import DataTableMahasiswa from "@/components/datatable/data-table-mahasiswa";
export default function DashboardPage() {
return (
<div className="container mx-auto p-4 space-y-6">
<DataTableMahasiswa />
</div>
);
}

View File

@@ -0,0 +1,9 @@
import DataTablePrestasiMahasiswa from "@/components/data-table-prestasi-mahasiswa";
export default function PrestasiPage() {
return (
<div className="flex flex-col gap-4 py-4 px-4 md:gap-6 md:py-6">
<DataTablePrestasiMahasiswa />
</div>
);
}

View File

@@ -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({
<html lang="en" suppressHydrationWarning>
<head>
<link rel="icon" type="image/png" href="/podif-icon.png" />
<meta httpEquiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta httpEquiv="Pragma" content="no-cache" />
<meta httpEquiv="Expires" content="0" />
<meta name="robots" content="noindex, nofollow" />
</head>
<body suppressHydrationWarning className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen`}>
{children}
<ClientLayout>{children}</ClientLayout>
</body>
</html>
);

View File

@@ -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 = () => (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
<div className="h-4 w-4 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div>
</CardHeader>
<CardContent>
<div className="h-8 w-16 bg-gray-200 dark:bg-gray-700 rounded animate-pulse mb-2"></div>
<div className="flex justify-between">
<div className="h-3 w-20 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
<div className="h-3 w-20 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
</div>
</CardContent>
</Card>
);
export default function DashboardPage() {
const { theme } = useTheme();
const [mahasiswaData, setMahasiswaData] = useState<MahasiswaTotal>({
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<string>("all");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800">
{/* Login Dialog */}
<Dialog open={isLoginOpen} onOpenChange={() => {}}>
<DialogContent className="sm:max-w-md" hideClose>
<DialogHeader>
<DialogTitle>Login Portal Data Informatika</DialogTitle>
<DialogDescription>
Silakan login sesuai dengan role Anda
</DialogDescription>
</DialogHeader>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="dosen">Dosen</TabsTrigger>
<TabsTrigger value="admin">Admin</TabsTrigger>
</TabsList>
<TabsContent value="dosen" className="space-y-4">
<form onSubmit={handleDosenLogin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="nip">NIP</Label>
<Input
id="nip"
type="text"
placeholder="Masukkan NIP"
value={dosenForm.nip}
onChange={(e) => setDosenForm({ ...dosenForm, nip: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="dosen-password">Password</Label>
<div className="relative">
<Input
id="dosen-password"
type={showPassword ? "text" : "password"}
placeholder="Masukkan password"
value={dosenForm.password}
onChange={(e) => setDosenForm({ ...dosenForm, password: e.target.value })}
required
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Login
</Button>
</form>
<div className="text-center pt-4 border-t">
<p className="text-sm text-gray-600 dark:text-gray-400 inline">
Belum punya akun?{' '}
</p>
<Button
type="button"
variant="link"
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 p-0 h-auto inline"
onClick={openRegister}
>
Daftar disini
</Button>
</div>
</TabsContent>
<TabsContent value="admin" className="space-y-4">
<form onSubmit={handleAdminLogin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
placeholder="Masukkan username"
value={adminForm.username}
onChange={(e) => setAdminForm({ ...adminForm, username: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="admin-password">Password</Label>
<div className="relative">
<Input
id="admin-password"
type={showPassword ? "text" : "password"}
placeholder="Masukkan password"
value={adminForm.password}
onChange={(e) => setAdminForm({ ...adminForm, password: e.target.value })}
required
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Login
</Button>
</form>
</TabsContent>
</Tabs>
{error && (
<Alert className="mt-4">
<AlertDescription className="text-red-600">
{error}
</AlertDescription>
</Alert>
)}
</DialogContent>
</Dialog>
{/* Register Dialog */}
<Dialog open={isRegisterOpen} onOpenChange={() => {}}>
<DialogContent className="sm:max-w-md" hideClose>
<DialogHeader>
<DialogTitle>Registrasi Dosen</DialogTitle>
<DialogDescription>
Daftar akun baru untuk dosen Portal Data Informatika
</DialogDescription>
</DialogHeader>
<form onSubmit={handleRegister} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="register-nip">NIP</Label>
<Input
id="register-nip"
type="text"
placeholder="Masukkan NIP"
value={registerForm.nip}
onChange={(e) => setRegisterForm({ ...registerForm, nip: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="register-password">Password</Label>
<div className="relative">
<Input
id="register-password"
type={showPassword ? "text" : "password"}
placeholder="Masukkan password (min. 6 karakter)"
value={registerForm.password}
onChange={(e) => setRegisterForm({ ...registerForm, password: e.target.value })}
required
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Konfirmasi Password</Label>
<Input
id="confirm-password"
type="password"
placeholder="Konfirmasi password"
value={registerForm.confirmPassword}
onChange={(e) => setRegisterForm({ ...registerForm, confirmPassword: e.target.value })}
required
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Daftar
</Button>
</form>
<div className="text-center pt-4 border-t">
<p className="text-sm text-gray-600 dark:text-gray-400 inline">
Sudah punya akun?{' '}
</p>
<Button
type="button"
variant="link"
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 p-0 h-auto inline"
onClick={openLogin}
>
Login
</Button>
<div className="container mx-auto p-4 space-y-6">
<h1 className="text-3xl font-bold mb-8">Dashboard Portal Data Informatika</h1>
{loading ? (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
</div>
) : error ? (
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
{error && (
<Alert className="mt-4">
<AlertDescription className="text-red-600">
{error}
</AlertDescription>
</Alert>
)}
</DialogContent>
</Dialog>
<div className="ml-3">
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
) : (
<>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-8">
{/* Kartu Total Mahasiswa */}
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium dark:text-white">
Total Mahasiswa
</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold dark:text-white">{mahasiswaData.total_mahasiswa}</div>
<div className="flex justify-between mt-2 text-xs text-muted-foreground">
<span className="dark:text-white">Aktif: <span className="text-green-500">{mahasiswaData.mahasiswa_aktif}</span></span>
</div>
</CardContent>
</Card>
{/* Kartu Total Kelulusan */}
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium dark:text-white">
Total Kelulusan
</CardTitle>
<GraduationCap className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold dark:text-white">{mahasiswaData.total_lulus}</div>
<div className="flex justify-between mt-2 text-xs text-muted-foreground">
<span className="dark:text-white">Laki-laki: <span className="text-blue-500">{mahasiswaData.pria_lulus}</span></span>
<span className="dark:text-white">Perempuan: <span className="text-pink-500">{mahasiswaData.wanita_lulus}</span></span>
</div>
</CardContent>
</Card>
{/* Kartu Total Prestasi */}
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium dark:text-white">
Mahasiswa Berprestasi
</CardTitle>
<Trophy className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold dark:text-white">{mahasiswaData.total_berprestasi}</div>
<div className="flex justify-between mt-2 text-xs text-muted-foreground">
<span className="dark:text-white">Akademik: <span className="text-yellow-500">{mahasiswaData.prestasi_akademik}</span></span>
<span className="dark:text-white">Non-Akademik: <span className="text-purple-500">{mahasiswaData.prestasi_non_akademik}</span></span>
</div>
</CardContent>
</Card>
{/* Kartu Rata-rata IPK */}
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium dark:text-white">
Rata-rata IPK
</CardTitle>
<BookOpen className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold dark:text-white">{mahasiswaData.mahasiswa_aktif}</div>
<div className="flex justify-between mt-2 text-xs text-muted-foreground">
<span className="dark:text-white">Aktif: <span className="text-green-500">{mahasiswaData.ipk_rata_rata_aktif}</span></span>
</div>
</CardContent>
</Card>
</div>
<Card className="bg-white dark:bg-slate-900 shadow-lg dark:text-white">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium dark:text-white">
Filter Data
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:space-x-4">
<FilterTahunAngkatan
selectedYear={selectedYear}
onYearChange={setSelectedYear}
/>
</div>
</CardContent>
</Card>
{selectedYear === "all" ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<StatistikMahasiswaChart />
<JenisPendaftaranChart />
<StatusMahasiswaChart />
<IPKChart />
<AsalDaerahChart />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<StatistikPerAngkatanChart tahunAngkatan={selectedYear} />
<JenisPendaftaranPerAngkatanChart tahunAngkatan={selectedYear} />
<AsalDaerahPerAngkatanChart tahunAngkatan={selectedYear} />
</div>
)}
</>
)}
</div>
<Toaster />
</ThemeProvider>
);
}

View File

@@ -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<string>("all");
const [selectedJenisBeasiswa, setSelectedJenisBeasiswa] = useState<string>("Pemerintah");
return (
<div className="container mx-auto p-4 space-y-6">
<h1 className="text-3xl font-bold mb-4">Mahasiswa Beasiswa</h1>
<div className="mb-4">
<p className="text-gray-600 dark:text-gray-300">
Mahasiswa yang mendapatkan beasiswa di program studi Informatika Fakultas Teknik Universitas Tanjungpura.
</p>
</div>
<Card className="bg-white dark:bg-slate-900 shadow-lg dark:text-white">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium dark:text-white">
Filter Data
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:space-x-4">
<FilterTahunAngkatan
selectedYear={selectedYear}
onYearChange={setSelectedYear}
/>
<FilterJenisBeasiswa
selectedJenisBeasiswa={selectedJenisBeasiswa}
onJenisBeasiswaChange={setSelectedJenisBeasiswa}
/>
</div>
</CardContent>
</Card>
{selectedYear === "all" ? (
<>
<TotalBeasiswaChart
selectedYear={selectedYear}
selectedJenisBeasiswa={selectedJenisBeasiswa}
/>
<NamaBeasiswaChart
selectedYear={selectedYear}
selectedJenisBeasiswa={selectedJenisBeasiswa}
/>
<JenisPendaftaranBeasiswaChart
selectedYear={selectedYear}
selectedJenisBeasiswa={selectedJenisBeasiswa}
/>
</>
) : (
<>
<TotalBeasiswaPieChart
selectedYear={selectedYear}
selectedJenisBeasiswa={selectedJenisBeasiswa}
/>
<NamaBeasiswaPieChart
selectedYear={selectedYear}
selectedJenisBeasiswa={selectedJenisBeasiswa}
/>
<JenisPendaftaranBeasiswaPieChart
selectedYear={selectedYear}
selectedJenisBeasiswa={selectedJenisBeasiswa}
/>
</>
)}
<AsalDaerahBeasiswaChart
selectedYear={selectedYear}
selectedJenisBeasiswa={selectedJenisBeasiswa}
/>
{selectedYear === "all" && (
<IPKBeasiswaChart
selectedJenisBeasiswa={selectedJenisBeasiswa}
/>
)}
</div>
);
}

View File

@@ -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<string>("all");
const [selectedJenisPrestasi, setSelectedJenisPrestasi] = useState<string>("Akademik");
return (
<div className="container mx-auto p-4 space-y-6">
<h1 className="text-3xl font-bold mb-4">Mahasiswa Berprestasi</h1>
<div className="mb-4">
<p className="text-gray-600 dark:text-gray-300">
Mahasiswa yang mendapatkan prestasi akademik dan non akademik di program studi Informatika Fakultas Teknik Universitas Tanjungpura.
</p>
</div>
<Card className="bg-white dark:bg-slate-900 shadow-lg dark:text-white">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium dark:text-white">
Filter Data
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:space-x-4">
<FilterTahunAngkatan
selectedYear={selectedYear}
onYearChange={setSelectedYear}
/>
<FilterJenisPrestasi
selectedJenisPrestasi={selectedJenisPrestasi}
onJenisPrestasiChange={setSelectedJenisPrestasi}
/>
</div>
</CardContent>
</Card>
{selectedYear === "all" ? (
<>
<TotalPrestasiChart selectedJenisPrestasi={selectedJenisPrestasi} />
<TingkatPrestasiChart selectedJenisPrestasi={selectedJenisPrestasi} />
<JenisPendaftaranPrestasiChart selectedJenisPrestasi={selectedJenisPrestasi} />
</>
) : (
<>
<TotalPrestasiPieChart
selectedYear={selectedYear}
selectedJenisPrestasi={selectedJenisPrestasi}
/>
<TingkatPrestasiPieChart
selectedYear={selectedYear}
selectedJenisPrestasi={selectedJenisPrestasi}
/>
<JenisPendaftaranPrestasiPieChart
selectedYear={selectedYear}
selectedJenisPrestasi={selectedJenisPrestasi}
/>
</>
)}
<AsalDaerahPrestasiChart selectedYear={selectedYear} selectedJenisPrestasi={selectedJenisPrestasi} />
{selectedYear === "all" && (
<IPKPrestasiChart selectedJenisPrestasi={selectedJenisPrestasi} />
)}
</div>
);
}

View File

@@ -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<string>("all");
const [selectedStatus, setSelectedStatus] = useState<string>("Aktif");
return (
<div className="container mx-auto p-4 space-y-6">
<h1 className="text-3xl font-bold mb-4">Status Mahasiswa</h1>
<div className="mb-4">
<p className="text-gray-600 dark:text-gray-300">
Mahasiswa status adalah status kuliah mahasiswa program studi Informatika Fakultas Teknik Universitas Tanjungpura.
</p>
</div>
<Card className="bg-white dark:bg-slate-900 shadow-lg dark:text-white">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium dark:text-white">
Filter Data
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:space-x-4">
<FilterTahunAngkatan
selectedYear={selectedYear}
onYearChange={setSelectedYear}
/>
<FilterStatusKuliah
selectedStatus={selectedStatus}
onStatusChange={setSelectedStatus}
/>
</div>
</CardContent>
</Card>
{selectedYear === "all" ? (
<>
<StatusMahasiswaFilterChart
selectedYear={selectedYear}
selectedStatus={selectedStatus}
/>
<JenisPendaftaranStatusChart
selectedYear={selectedYear}
selectedStatus={selectedStatus}
/>
</>
) : (
<>
<StatusMahasiswaFilterPieChart
selectedYear={selectedYear}
selectedStatus={selectedStatus}
/>
<JenisPendaftaranStatusPieChart
selectedYear={selectedYear}
selectedStatus={selectedStatus}
/>
</>
)}
<AsalDaerahStatusChart
selectedYear={selectedYear}
selectedStatus={selectedStatus}
/>
{selectedYear === "all" && (
<IpkStatusChart
selectedYear={selectedYear}
selectedStatus={selectedStatus}
/>
)}
</div>
);
}

View File

@@ -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<string>("all");
return (
<div className="container mx-auto p-4 space-y-6">
<h1 className="text-3xl font-bold mb-4">Mahasiswa Lulus Tepat Waktu</h1>
<div className="mb-4">
<p className="text-gray-600 dark:text-gray-300">
Mahasiswa yang lulus tepat waktu sesuai dengan masa studi 4 tahun program studi Informatika Fakultas Teknik Universitas Tanjungpura.
</p>
</div>
<Card className="bg-white dark:bg-slate-900 shadow-lg dark:text-white">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium dark:text-white">
Filter Data
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:space-x-4">
<FilterTahunAngkatan
selectedYear={selectedYear}
onYearChange={setSelectedYear}
/>
</div>
</CardContent>
</Card>
{selectedYear === "all" ? (
<>
<LulusTepatWaktuChart selectedYear={selectedYear} />
<JenisPendaftaranLulusChart selectedYear={selectedYear} />
</>
) : (
<>
<LulusTepatWaktuPieChart selectedYear={selectedYear} />
<JenisPendaftaranLulusPieChart selectedYear={selectedYear} />
</>
)}
<AsalDaerahLulusChart selectedYear={selectedYear} />
{selectedYear === "all" && (
<IPKLulusTepatChart selectedYear={selectedYear} />
)}
</div>
);
}

View File

@@ -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<string>("all");
return (
<div className="container mx-auto p-4 space-y-6">
<h1 className="text-3xl font-bold mb-4">Total Mahasiswa</h1>
<div className="mb-4">
<p className="text-gray-600 dark:text-gray-300">
Mahasiswa total adalah jumlah mahasiswa program studi Informatika Fakultas Teknik Universitas Tanjungpura.
</p>
</div>
<Card className="bg-white dark:bg-slate-900 shadow-lg dark:text-white">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium dark:text-white">
Filter Data
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:space-x-4">
<FilterTahunAngkatan
selectedYear={selectedYear}
onYearChange={setSelectedYear}
/>
</div>
</CardContent>
</Card>
{selectedYear === "all" ? (
<>
<StatistikMahasiswaChart />
<JenisPendaftaranChart />
<AsalDaerahChart />
<IPKChart />
</>
) : (
<>
<StatistikPerAngkatanChart tahunAngkatan={selectedYear} />
<JenisPendaftaranPerAngkatanChart tahunAngkatan={selectedYear} />
<AsalDaerahPerAngkatanChart tahunAngkatan={selectedYear} />
</>
)}
</div>
);
}

View File

@@ -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 (
<ThemeProvider
attribute="class"
@@ -33,15 +17,10 @@ export default function ClientLayout({ children }: ClientLayoutProps) {
disableTransitionOnChange
>
<div className="min-h-screen">
<Sidebar isCollapsed={isSidebarCollapsed} />
<div className={`flex flex-col min-h-screen transition-all duration-300 ease-in-out ${
isSidebarCollapsed ? 'md:ml-0' : 'md:ml-64'
}`}>
<Navbar onSidebarToggle={() => setIsSidebarCollapsed(!isSidebarCollapsed)} isSidebarCollapsed={isSidebarCollapsed} />
<main className="flex-1 p-2">
{children}
</main>
</div>
<Navbar />
<main className="flex-1">
{children}
</main>
</div>
<Toaster />
</ThemeProvider>

View File

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

View File

@@ -280,13 +280,13 @@ export default function JenisPendaftaranChart() {
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[400px] w-full">
<div className="h-[300px] sm:h-[350px] md:h-[400px] w-full max-w-5xl mx-auto">
<Chart
options={options}
series={series}
type="bar"
height="100%"
width="90%"
width="100%"
/>
</div>
</CardContent>

View File

@@ -317,7 +317,7 @@ export default function StatistikMahasiswaChart() {
series={chartSeries}
type="bar"
height="100%"
width="90%"
width="100%"
/>
</div>
</CardContent>

View File

@@ -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%"
/>
)}
</div>

View File

@@ -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<Mahasiswa[]>([]);
const [filteredData, setFilteredData] = useState<Mahasiswa[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// State for filtering
const [searchTerm, setSearchTerm] = useState("");
const [filterAngkatan, setFilterAngkatan] = useState<string>("");
const [filterStatus, setFilterStatus] = useState<string>("");
// State for pagination
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [paginatedData, setPaginatedData] = useState<Mahasiswa[]>([]);
// State for form
const [formMode, setFormMode] = useState<"add" | "edit">("add");
const [formData, setFormData] = useState<Partial<Mahasiswa>>({
jk: "Pria"
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
// State for delete confirmation
const [deleteNim, setDeleteNim] = useState<string | null>(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<string[]>([]);
// State for kelompok keahlian options
const [kelompokKeahlianOptions, setKelompokKeahlianOptions] = useState<Array<{id_kk: number, nama_kelompok: string}>>([]);
// 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<HTMLInputElement>) => {
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<string>();
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(
<PaginationItem key="first">
<PaginationLink
isActive={currentPage === 1}
onClick={() => handlePageChange(1)}
>
1
</PaginationLink>
</PaginationItem>
);
// Show ellipsis if needed
if (currentPage > 3) {
items.push(
<PaginationItem key="ellipsis-start">
<PaginationEllipsis />
</PaginationItem>
);
}
// 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(
<PaginationItem key={i}>
<PaginationLink
isActive={currentPage === i}
onClick={() => handlePageChange(i)}
>
{i}
</PaginationLink>
</PaginationItem>
);
}
// Show ellipsis if needed
if (currentPage < totalPages - 2) {
items.push(
<PaginationItem key="ellipsis-end">
<PaginationEllipsis />
</PaginationItem>
);
}
// Always show last page if there's more than one page
if (totalPages > 1) {
items.push(
<PaginationItem key="last">
<PaginationLink
isActive={currentPage === totalPages}
onClick={() => handlePageChange(totalPages)}
>
{totalPages}
</PaginationLink>
</PaginationItem>
);
}
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 (
<div className="space-y-4">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<h2 className="text-2xl font-bold">Data Mahasiswa</h2>
<div className="flex flex-col sm:flex-row gap-2">
<Button
variant="outline"
onClick={handleUpdateSemester}
disabled={isUpdatingSemester}
>
{isUpdatingSemester ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
Update Semester Mahasiswa Aktif
</Button>
<Button onClick={handleAdd}>
<PlusCircle className="mr-2 h-4 w-4" />
Tambah Mahasiswa
</Button>
<EditJenisPendaftaran onUpdateSuccess={fetchMahasiswa} />
<UploadExcelMahasiswa onUploadSuccess={fetchMahasiswa} />
</div>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Cari berdasarkan NIM atau nama..."
className="pl-8"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
{searchTerm && (
<X
className="absolute right-2.5 top-2.5 h-4 w-4 text-muted-foreground cursor-pointer"
onClick={() => setSearchTerm("")}
/>
)}
</div>
<Select
value={filterAngkatan}
onValueChange={(value) => setFilterAngkatan(value)}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Tahun Angkatan" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Angkatan</SelectItem>
{getUniqueAngkatan().map((year) => (
<SelectItem key={year} value={year}>
{year}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filterStatus}
onValueChange={(value) => setFilterStatus(value)}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Status Kuliah" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Status</SelectItem>
<SelectItem value="Aktif">Aktif</SelectItem>
<SelectItem value="Cuti">Cuti</SelectItem>
<SelectItem value="Lulus">Lulus</SelectItem>
<SelectItem value="Non-Aktif">Non-Aktif</SelectItem>
</SelectContent>
</Select>
</div>
{/* Show entries selector */}
<div className="flex items-center gap-2">
<span className="text-sm">Show</span>
<Select
value={pageSize.toString()}
onValueChange={handlePageSizeChange}
>
<SelectTrigger className="w-[80px]">
<SelectValue placeholder={pageSize.toString()} />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
<span className="text-sm">entries</span>
</div>
{/* Table */}
{loading ? (
<div className="flex justify-center items-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : error ? (
<div className="bg-destructive/10 p-4 rounded-md text-destructive text-center">
{error}
</div>
) : (
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">NIM</TableHead>
<TableHead>Nama</TableHead>
<TableHead>Jenis Kelamin</TableHead>
<TableHead>Agama</TableHead>
<TableHead>Kabupaten</TableHead>
<TableHead>Provinsi</TableHead>
<TableHead>Jenis Pendaftaran</TableHead>
<TableHead>Tahun Angkatan</TableHead>
<TableHead>Semester</TableHead>
<TableHead>IPK</TableHead>
<TableHead>Status Kuliah</TableHead>
<TableHead>Kelompok Keahlian</TableHead>
<TableHead className="text-right">Aksi</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedData.length === 0 ? (
<TableRow>
<TableCell colSpan={13} className="text-center py-8">
Tidak ada data yang sesuai dengan filter
</TableCell>
</TableRow>
) : (
paginatedData.map((mhs) => (
<TableRow key={mhs.nim}>
<TableCell className="font-medium">{mhs.nim}</TableCell>
<TableCell>{mhs.nama}</TableCell>
<TableCell>{mhs.jk}</TableCell>
<TableCell>{mhs.agama}</TableCell>
<TableCell>{mhs.kabupaten}</TableCell>
<TableCell>{mhs.provinsi}</TableCell>
<TableCell>{mhs.jenis_pendaftaran}</TableCell>
<TableCell>{mhs.tahun_angkatan}</TableCell>
<TableCell>{mhs.semester}</TableCell>
<TableCell>{mhs.ipk ? Number(mhs.ipk).toFixed(2) : "-"}</TableCell>
<TableCell>
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${
mhs.status_kuliah === "Aktif"
? "bg-green-100 text-green-800"
: mhs.status_kuliah === "Cuti"
? "bg-yellow-100 text-yellow-800"
: mhs.status_kuliah === "Lulus"
? "bg-blue-100 text-blue-800"
: "bg-red-100 text-red-800"
}`}
>
{mhs.status_kuliah}
</span>
</TableCell>
<TableCell>{mhs.nama_kelompok_keahlian || "-"}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleEdit(mhs)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
className="text-destructive hover:bg-destructive/10"
onClick={() => handleDeleteConfirm(mhs.nim)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)}
{/* Pagination info and controls */}
{!loading && !error && filteredData.length > 0 && (
<div className="flex flex-col sm:flex-row justify-between items-center gap-4">
<div className="text-sm text-muted-foreground">
Showing {getDisplayRange().start} to {getDisplayRange().end} of {filteredData.length} entries
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => handlePageChange(Math.max(1, currentPage - 1))}
className={currentPage === 1 ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
{renderPaginationItems()}
<PaginationItem>
<PaginationNext
onClick={() => handlePageChange(Math.min(getTotalPages(), currentPage + 1))}
className={currentPage === getTotalPages() ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
{/* Add/Edit Dialog */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{formMode === "add" ? "Tambah Mahasiswa" : "Edit Mahasiswa"}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-6 py-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="space-y-2">
<label htmlFor="nim" className="text-sm font-medium">
NIM <span className="text-destructive">*</span>
</label>
<Input
id="nim"
name="nim"
value={formData.nim || ""}
onChange={handleInputChange}
disabled={formMode === "edit"}
required
maxLength={11}
/>
</div>
<div className="space-y-2">
<label htmlFor="nama" className="text-sm font-medium">
Nama <span className="text-destructive">*</span>
</label>
<Input
id="nama"
name="nama"
value={formData.nama || ""}
onChange={handleInputChange}
required
/>
</div>
<div className="space-y-2">
<label htmlFor="jk" className="text-sm font-medium">
Jenis Kelamin <span className="text-destructive">*</span>
</label>
<Select
value={formData.jk || "Pria"}
onValueChange={(value) => handleSelectChange("jk", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Pria">Pria</SelectItem>
<SelectItem value="Wanita">Wanita</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label htmlFor="agama" className="text-sm font-medium">
Agama
</label>
<Input
id="agama"
name="agama"
value={formData.agama || ""}
onChange={handleInputChange}
/>
</div>
<div className="space-y-2">
<label htmlFor="kabupaten" className="text-sm font-medium">
Kabupaten
</label>
<Input
id="kabupaten"
name="kabupaten"
value={formData.kabupaten || ""}
onChange={handleInputChange}
/>
</div>
<div className="space-y-2">
<label htmlFor="provinsi" className="text-sm font-medium">
Provinsi
</label>
<Input
id="provinsi"
name="provinsi"
value={formData.provinsi || ""}
onChange={handleInputChange}
/>
</div>
<div className="space-y-2">
<label htmlFor="jenis_pendaftaran" className="text-sm font-medium">
Jenis Pendaftaran
</label>
<Input
id="jenis_pendaftaran"
name="jenis_pendaftaran"
value={formData.jenis_pendaftaran || ""}
onChange={handleInputChange}
/>
</div>
<div className="space-y-2">
<label htmlFor="tahun_angkatan" className="text-sm font-medium">
Tahun Angkatan <span className="text-destructive">*</span>
</label>
<Input
id="tahun_angkatan"
name="tahun_angkatan"
value={formData.tahun_angkatan || ""}
onChange={handleInputChange}
required
maxLength={4}
pattern="[0-9]{4}"
placeholder="contoh: 2021"
/>
</div>
<div className="space-y-2">
<label htmlFor="ipk" className="text-sm font-medium">
IPK
</label>
<Input
id="ipk"
name="ipk"
type="number"
step="0.01"
min="0"
max="4.00"
value={formData.ipk || ""}
onChange={handleInputChange}
placeholder="contoh: 3.50"
/>
</div>
<div className="space-y-2">
<label htmlFor="id_kelompok_keahlian" className="text-sm font-medium">
Kelompok Keahlian
</label>
<Select
value={formData.id_kelompok_keahlian?.toString() || ""}
onValueChange={(value) => handleSelectChange("id_kelompok_keahlian", value)}
>
<SelectTrigger>
<SelectValue placeholder="Pilih Kelompok Keahlian" />
</SelectTrigger>
<SelectContent>
{kelompokKeahlianOptions.map((kelompok) => (
<SelectItem key={kelompok.id_kk} value={kelompok.id_kk.toString()}>
{kelompok.nama_kelompok}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label htmlFor="status_kuliah" className="text-sm font-medium">
Status Kuliah <span className="text-destructive">*</span>
</label>
<Select
value={formData.status_kuliah || "Aktif"}
onValueChange={(value) => handleSelectChange("status_kuliah", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Aktif">Aktif</SelectItem>
<SelectItem value="Cuti">Cuti</SelectItem>
<SelectItem value="Lulus">Lulus</SelectItem>
<SelectItem value="DO">DO</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label htmlFor="semester" className="text-sm font-medium">
Semester <span className="text-destructive">*</span>
</label>
<Input
id="semester"
name="semester"
type="number"
min="1"
max="14"
value={formData.semester || ""}
onChange={handleInputChange}
required
/>
</div>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Batal
</Button>
</DialogClose>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{formMode === "add" ? "Tambah" : "Simpan"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Konfirmasi Hapus</DialogTitle>
</DialogHeader>
<div className="py-4">
<p>Apakah Anda yakin ingin menghapus data mahasiswa ini?</p>
<p className="text-sm text-muted-foreground mt-1">
Tindakan ini tidak dapat dibatalkan.
</p>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Batal
</Button>
</DialogClose>
<Button
variant="destructive"
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Hapus
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -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<JenisPendaftaran[]>([]);
const [loading, setLoading] = useState(false);
const [updating, setUpdating] = useState(false);
const [error, setError] = useState<string | null>(null);
// State for selected jenis pendaftaran and new value
const [selectedJenisPendaftaran, setSelectedJenisPendaftaran] = useState<string>("");
const [newValue, setNewValue] = useState<string>("");
// 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 (
<>
<Button
variant="outline"
onClick={() => setIsDialogOpen(true)}
className="flex items-center gap-2"
>
<Settings className="h-4 w-4" />
Edit Jenis Pendaftaran
</Button>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="sm:max-w-[450px]">
<DialogHeader>
<DialogTitle>Edit Jenis Pendaftaran</DialogTitle>
</DialogHeader>
{loading ? (
<div className="flex justify-center items-center py-6">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
) : error ? (
<div className="bg-destructive/10 p-3 rounded-md text-destructive text-center text-sm mb-3">
{error}
</div>
) : (
<div className="py-2 space-y-4">
<div className="space-y-2">
<label htmlFor="jenis_lama" className="text-sm font-medium">
Jenis Lama:
</label>
<Select
value={selectedJenisPendaftaran}
onValueChange={setSelectedJenisPendaftaran}
>
<SelectTrigger>
<SelectValue placeholder="Pilih jenis pendaftaran" />
</SelectTrigger>
<SelectContent>
{jenisPendaftaranList.map((item, index) => (
<SelectItem key={index} value={item.jenis_pendaftaran}>
{item.jenis_pendaftaran}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label htmlFor="jenis_baru" className="text-sm font-medium">
Jenis Baru:
</label>
<Input
id="jenis_baru"
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
placeholder="Masukkan jenis pendaftaran baru"
disabled={!selectedJenisPendaftaran}
/>
</div>
</div>
)}
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button type="button" variant="outline">
Batal
</Button>
</DialogClose>
<Button
onClick={handleSaveChanges}
disabled={loading || updating || !selectedJenisPendaftaran || !newValue}
>
{updating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Simpan
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -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<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<FileUp className="mr-2 h-4 w-4" />
Upload File
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Upload Data Mahasiswa</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="text-sm text-muted-foreground">
<p>Upload file Excel (.xlsx, .xls) atau CSV (.csv)</p>
</div>
<div className="grid w-full max-w-sm items-center gap-1.5">
<label htmlFor="file-upload" className="text-sm font-medium">
Pilih File
</label>
<input
id="file-upload"
type="file"
className="file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-primary file:text-primary-foreground hover:file:bg-primary/90 text-sm text-muted-foreground"
accept=".xlsx,.xls,.csv"
onChange={handleFileChange}
/>
{file && (
<p className="text-xs text-muted-foreground mt-1">
File terpilih: {file.name}
</p>
)}
</div>
{error && (
<div className="bg-destructive/10 text-destructive text-sm p-2 rounded-md flex items-start">
<AlertCircle className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" />
<span>{error}</span>
</div>
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Batal
</Button>
</DialogClose>
<Button onClick={handleUpload} disabled={!file || isUploading}>
{isUploading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Upload
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -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<UserData | null>(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 (
<div className="bg-background/95 border-b py-2 sticky top-0 z-30">
<div className="container mx-auto px-4 flex justify-between items-center">
<div className="flex items-center">
<Link href="/" className="flex items-center text-lg font-semibold hover:text-primary transition-colors">
<img src="/podif-icon.png" alt="PODIF Logo" className="h-6 w-auto mr-2" />
PODIF
</Link>
</div>
<div className="flex items-center gap-4">
<ThemeToggle />
</div>
</div>
</div>
);
}
return (
<div className="bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b py-2 px-5 flex justify-between items-center z-30 sticky top-0">
<div className="flex items-center gap-2">
<div className="bg-background/95 border-b py-2 sticky top-0 z-30">
<div className="container mx-auto px-4 flex justify-between items-center">
{/* Logo */}
<div className="flex items-center">
<Link href="/" className="flex items-center text-lg font-semibold hover:text-primary transition-colors">
<img src="/podif-icon.png" alt="PODIF Logo" className="h-6 w-auto mr-2" />
PODIF
</Link>
</div>
{/* Desktop Navigation - Centered */}
<div className="hidden md:flex items-center gap-4">
{/* Beranda - Always visible */}
<Link href="/" className="flex items-center gap-2 px-3 py-2 text-sm font-medium hover:text-primary transition-colors">
<Home className="h-4 w-4" />
Beranda
</Link>
{/* Visualisasi Dropdown - Only when logged in */}
{user && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center gap-2 px-3 py-2 text-sm font-medium">
<School className="h-4 w-4" />
Visualisasi
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-48">
<DropdownMenuItem asChild>
<Link href="/visualisasi/mahasiswa" className="flex items-center gap-2 w-full">
<GraduationCap className="h-4 w-4" />
Mahasiswa
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/visualisasi/status" className="flex items-center gap-2 w-full">
<GraduationCap className="h-4 w-4" />
Status Kuliah
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/visualisasi/tipekelulusan" className="flex items-center gap-2 w-full">
<Clock className="h-4 w-4" />
Tipe Kelulusan
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/visualisasi/beasiswa" className="flex items-center gap-2 w-full">
<BookOpen className="h-4 w-4" />
Beasiswa
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/visualisasi/prestasi" className="flex items-center gap-2 w-full">
<Award className="h-4 w-4" />
Prestasi
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Kelola Data Dropdown - Only for Admin */}
{user && user.role_user === 'admin' && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center gap-2 px-3 py-2 text-sm font-medium">
<School className="h-4 w-4" />
Kelola Data
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-48">
<DropdownMenuItem asChild>
<Link href="/keloladata/mahasiswa" className="flex items-center gap-2 w-full">
<GraduationCap className="h-4 w-4" />
Mahasiswa
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/keloladata/beasiswa" className="flex items-center gap-2 w-full">
<BookOpen className="h-4 w-4" />
Beasiswa
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/keloladata/prestasi" className="flex items-center gap-2 w-full">
<Award className="h-4 w-4" />
Prestasi
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/keloladata/kelompokkeahlian" className="flex items-center gap-2 w-full">
<Award className="h-4 w-4" />
Kelompok Keahlian
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{/* Right Side - Theme Toggle, Login/User Menu, and Mobile Menu */}
<div className="flex items-center gap-4">
<ThemeToggle />
{user ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center gap-2">
<User className="h-4 w-4" />
{user.role_user === 'ketuajurusan' ? 'Ketua Jurusan' : 'Admin'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled>
<User className="h-4 w-4 mr-2" />
{user.role_user === 'ketuajurusan' ? user.nip : user.username}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="h-4 w-4 mr-2" />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<LoginDialog onLoginSuccess={handleLoginSuccess} />
)}
{/* Mobile Menu Button */}
<div className="md:hidden">
<Sheet>
@@ -43,48 +238,108 @@ const Navbar = ({ onSidebarToggle, isSidebarCollapsed }: NavbarProps) => {
</Button>
</SheetTrigger>
<SheetContent side="left" className="p-0 w-[250px] overflow-y-auto">
<SidebarContent />
<MobileNavContent user={user} onLogout={handleLogout} />
</SheetContent>
</Sheet>
</div>
{/* Desktop Sidebar Toggle Button */}
<div className="hidden md:block">
<Button
variant="outline"
size="icon"
onClick={onSidebarToggle}
title={isSidebarCollapsed ? "Tampilkan Sidebar" : "Sembunyikan Sidebar"}
>
{isSidebarCollapsed ? (
<PanelLeft className="h-5 w-5" />
) : (
<PanelLeftClose className="h-5 w-5" />
)}
<span className="sr-only">Toggle sidebar</span>
</Button>
</div>
<Link href="/dashboard" className="flex items-center text-lg font-semibold hover:text-primary transition-colors">
<img src="/podif-icon.png" alt="PODIF Logo" className="h-6 w-auto mr-2" />
PODIF
</Link>
</div>
<div className="flex items-center gap-4">
<ThemeToggle />
<Button
variant="outline"
size="icon"
onClick={handleLogout}
title="Logout"
>
<LogOut className="h-5 w-5" />
<span className="sr-only">Logout</span>
</Button>
</div>
</div>
);
};
// Mobile Navigation Content Component
interface MobileNavContentProps {
user: UserData | null;
onLogout: () => void;
}
const MobileNavContent = ({ user, onLogout }: MobileNavContentProps) => {
return (
<div className="p-4 space-y-4">
<div className="space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground">Dashboard PODIF</h3>
<Link href="/" className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground rounded-md transition-colors">
<Home className="h-4 w-4" />
Beranda
</Link>
</div>
{user ? (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground">Menu Utama</h3>
<div className="space-y-1">
<h4 className="text-xs font-medium text-muted-foreground px-3">Visualisasi</h4>
<Link href="/visualisasi/mahasiswa" className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground rounded-md transition-colors">
<GraduationCap className="h-4 w-4" />
Mahasiswa
</Link>
<Link href="/visualisasi/status" className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground rounded-md transition-colors">
<GraduationCap className="h-4 w-4" />
Status Kuliah
</Link>
<Link href="/visualisasi/tipekelulusan" className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground rounded-md transition-colors">
<Clock className="h-4 w-4" />
Tipe Kelulusan
</Link>
<Link href="/visualisasi/beasiswa" className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground rounded-md transition-colors">
<BookOpen className="h-4 w-4" />
Beasiswa
</Link>
<Link href="/visualisasi/prestasi" className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground rounded-md transition-colors">
<Award className="h-4 w-4" />
Prestasi
</Link>
</div>
{/* Kelola Data - Only for Admin */}
{user.role_user === 'admin' && (
<div className="space-y-1">
<h4 className="text-xs font-medium text-muted-foreground px-3">Kelola Data</h4>
<Link href="/keloladata/mahasiswa" className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground rounded-md transition-colors">
<GraduationCap className="h-4 w-4" />
Mahasiswa
</Link>
<Link href="/keloladata/beasiswa" className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground rounded-md transition-colors">
<BookOpen className="h-4 w-4" />
Beasiswa
</Link>
<Link href="/keloladata/prestasi" className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground rounded-md transition-colors">
<Award className="h-4 w-4" />
Prestasi
</Link>
<Link href="/keloladata/kelompokkeahlian" className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground rounded-md transition-colors">
<Award className="h-4 w-4" />
Kelompok Keahlian
</Link>
</div>
)}
<div className="pt-4 border-t">
<div className="flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground">
<User className="h-4 w-4" />
{user.role_user === 'ketuajurusan' ? 'Ketua Jurusan' : 'Admin'}
</div>
<button
onClick={onLogout}
className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground rounded-md transition-colors w-full text-left"
>
<LogOut className="h-4 w-4" />
Logout
</button>
</div>
</div>
) : (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground">Login</h3>
<p className="text-sm text-muted-foreground px-3">
Silakan login untuk mengakses menu Visualisasi dan Kelola Data
</p>
</div>
)}
</div>
);
};
export default Navbar;

View File

@@ -27,44 +27,76 @@ const SidebarContent = () => {
<Command className="bg-background h-full">
<CommandList className="overflow-visible">
<CommandGroup heading="Dashboard PODIF" className="mt-2">
<Link href="/dashboard" className="w-full no-underline cursor-pointer">
<Link href="/" className="w-full no-underline cursor-pointer">
<CommandItem className="py-2 px-3 hover:bg-accent hover:text-accent-foreground rounded-md flex items-center transition-colors cursor-pointer">
<Home className="h-4 w-4" />
<span>Dashboard</span>
<span>Beranda</span>
</CommandItem>
</Link>
</CommandGroup>
<CommandGroup heading="Menu Utama">
<CommandItem className="p-0">
<Accordion type="single" collapsible defaultValue="data-mahasiswa" className="w-full">
<AccordionItem value="data-mahasiswa" className="border-none">
<Accordion type="single" collapsible defaultValue="visualisasi" className="w-full">
<AccordionItem value="visualisasi" className="border-none">
<AccordionTrigger className="py-2 px-3 hover:bg-accent hover:text-accent-foreground rounded-md transition-colors">
<div className="flex items-center">
<School className="mr-2 h-4 w-4" />
<span>Data Mahasiswa</span>
<span>Visualisasi</span>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="pl-6 flex flex-col space-y-1">
<Link href="/dashboard/mahasiswa/total" className="py-2 px-3 hover:bg-accent hover:text-accent-foreground rounded-md flex items-center transition-colors">
<Users className="mr-2 h-4 w-4" />
<span>Mahasiswa Total</span>
</Link>
<Link href="/dashboard/mahasiswa/status" className="py-2 px-3 hover:bg-accent hover:text-accent-foreground rounded-md flex items-center transition-colors">
<Link href="visualisasi/mahasiswa" className="py-2 px-3 hover:bg-accent hover:text-accent-foreground rounded-md flex items-center transition-colors">
<GraduationCap className="mr-2 h-4 w-4" />
<span>Mahasiswa Status</span>
<span>Mahasiswa</span>
</Link>
<Link href="/dashboard/mahasiswa/lulustepatwaktu" className="py-2 px-3 hover:bg-accent hover:text-accent-foreground rounded-md flex items-center transition-colors">
<Link href="visualisasi/status" className="py-2 px-3 hover:bg-accent hover:text-accent-foreground rounded-md flex items-center transition-colors">
<GraduationCap className="mr-2 h-4 w-4" />
<span>Status Kuliah</span>
</Link>
<Link href="visualisasi/tipekelulusan" className="py-2 px-3 hover:bg-accent hover:text-accent-foreground rounded-md flex items-center transition-colors">
<Clock className="mr-2 h-4 w-4" />
<span>Mahasiswa Lulus Tepat Waktu</span>
<span>Tipe Kelulusan</span>
</Link>
<Link href="/dashboard/mahasiswa/beasiswa" className="py-2 px-3 hover:bg-accent hover:text-accent-foreground rounded-md flex items-center transition-colors">
<Link href="visualisasi/beasiswa" className="py-2 px-3 hover:bg-accent hover:text-accent-foreground rounded-md flex items-center transition-colors">
<BookOpen className="mr-2 h-4 w-4" />
<span>Mahasiswa Beasiswa</span>
<span>Beasiswa</span>
</Link>
<Link href="/dashboard/mahasiswa/berprestasi" className="py-2 px-3 hover:bg-accent hover:text-accent-foreground rounded-md flex items-center transition-colors">
<Link href="visualisasi/prestasi" className="py-2 px-3 hover:bg-accent hover:text-accent-foreground rounded-md flex items-center transition-colors">
<Award className="mr-2 h-4 w-4" />
<span>Mahasiswa Berprestasi</span>
<span>Prestasi</span>
</Link>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</CommandItem>
<CommandItem className="p-0 mt-2">
<Accordion type="single" collapsible defaultValue="keloladata" className="w-full">
<AccordionItem value="keloladata" className="border-none">
<AccordionTrigger className="py-2 px-3 hover:bg-accent hover:text-accent-foreground rounded-md transition-colors">
<div className="flex items-center">
<School className="mr-2 h-4 w-4" />
<span>Kelola Data</span>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="pl-6 flex flex-col space-y-1">
<Link href="keloladata/mahasiswa" className="py-2 px-3 hover:bg-accent hover:text-accent-foreground rounded-md flex items-center transition-colors">
<GraduationCap className="mr-2 h-4 w-4" />
<span>Mahasiswa</span>
</Link>
<Link href="keloladata/beasiswa" className="py-2 px-3 hover:bg-accent hover:text-accent-foreground rounded-md flex items-center transition-colors">
<BookOpen className="mr-2 h-4 w-4" />
<span>Beasiswa</span>
</Link>
<Link href="keloladata/prestasi" className="py-2 px-3 hover:bg-accent hover:text-accent-foreground rounded-md flex items-center transition-colors">
<Award className="mr-2 h-4 w-4" />
<span>Prestasi</span>
</Link>
<Link href="keloladata/kelompokkeahlian" className="py-2 px-3 hover:bg-accent hover:text-accent-foreground rounded-md flex items-center transition-colors">
<Award className="mr-2 h-4 w-4" />
<span>Kelompok Keahlian</span>
</Link>
</div>
</AccordionContent>
@@ -73,14 +105,6 @@ const SidebarContent = () => {
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Data Diri">
<Link href="/dashboard/mahasiswa/profile" className="w-full no-underline cursor-pointer" style={{ cursor: 'pointer' }}>
<CommandItem className="py-2 px-3 hover:bg-accent hover:text-accent-foreground rounded-md flex items-center transition-colors cursor-pointer">
<User className="h-4 w-4" />
<span>Profile</span>
</CommandItem>
</Link>
</CommandGroup>
</CommandList>
</Command>
);

View File

@@ -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<typeof buttonVariants> & {
asChild?: boolean;
asChild?: boolean
}) {
const Comp = asChild ? Slot : 'button';
const Comp = asChild ? Slot : "button"
return (
<Comp
@@ -50,7 +53,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
)
}
export { Button, buttonVariants };
export { Button, buttonVariants }

View File

@@ -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<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
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}
/>
</DropdownMenuPrimitive.Portal>
);
)
}
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: 'default' | 'destructive';
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
@@ -66,7 +79,7 @@ function DropdownMenuItem({
)}
{...props}
/>
);
)
}
function DropdownMenuCheckboxItem({
@@ -92,13 +105,18 @@ function DropdownMenuCheckboxItem({
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
@@ -122,7 +140,7 @@ function DropdownMenuRadioItem({
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
)
}
function DropdownMenuLabel({
@@ -130,16 +148,19 @@ function DropdownMenuLabel({
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
);
)
}
function DropdownMenuSeparator({
@@ -149,24 +170,32 @@ function DropdownMenuSeparator({
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
)
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
);
)
}
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
@@ -175,14 +204,14 @@ function DropdownMenuSubTrigger({
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
@@ -190,7 +219,7 @@ function DropdownMenuSubTrigger({
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
)
}
function DropdownMenuSubContent({
@@ -201,12 +230,12 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
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 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
"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 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
);
)
}
export {
@@ -225,4 +254,4 @@ export {
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};
}

167
components/ui/form.tsx Normal file
View File

@@ -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<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
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 <FormField>")
}
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<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[1px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}

View File

@@ -0,0 +1,212 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useToast } from "@/components/ui/use-toast";
import { LogIn, User, Key } from "lucide-react";
interface LoginDialogProps {
onLoginSuccess: (userData: any) => void;
}
export default function LoginDialog({ onLoginSuccess }: LoginDialogProps) {
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast();
// Ketua Jurusan form state
const [ketuaForm, setKetuaForm] = useState({
nip: "",
password: "",
});
// Admin form state
const [adminForm, setAdminForm] = useState({
username: "",
password: "",
});
const handleKetuaLogin = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
nip: ketuaForm.nip,
password: ketuaForm.password,
role: "ketuajurusan",
}),
});
const data = await response.json();
if (response.ok) {
toast({
title: "Login Berhasil",
description: "Selamat datang, Ketua Jurusan!",
});
onLoginSuccess(data);
setIsOpen(false);
setKetuaForm({ nip: "", password: "" });
} else {
toast({
variant: "destructive",
title: "Login Gagal",
description: data.message || "NIP atau password salah",
});
}
} catch (error) {
toast({
variant: "destructive",
title: "Error",
description: "Terjadi kesalahan saat login",
});
} finally {
setIsLoading(false);
}
};
const handleAdminLogin = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
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",
}),
});
const data = await response.json();
if (response.ok) {
toast({
title: "Login Berhasil",
description: "Selamat datang, Admin!",
});
onLoginSuccess(data);
setIsOpen(false);
setAdminForm({ username: "", password: "" });
} else {
toast({
variant: "destructive",
title: "Login Gagal",
description: data.message || "Username atau password salah",
});
}
} catch (error) {
toast({
variant: "destructive",
title: "Error",
description: "Terjadi kesalahan saat login",
});
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="flex items-center gap-2">
<LogIn className="h-4 w-4" />
Login
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
Login ke PODIF
</DialogTitle>
</DialogHeader>
<Tabs defaultValue="ketua" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="ketua">Ketua Jurusan</TabsTrigger>
<TabsTrigger value="admin">Admin</TabsTrigger>
</TabsList>
<TabsContent value="ketua" className="space-y-4">
<form onSubmit={handleKetuaLogin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="nip">NIP</Label>
<Input
id="nip"
type="text"
placeholder="Masukkan NIP"
value={ketuaForm.nip}
onChange={(e) => setKetuaForm({ ...ketuaForm, nip: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="ketua-password">Password</Label>
<Input
id="ketua-password"
type="password"
placeholder="Masukkan password"
value={ketuaForm.password}
onChange={(e) => setKetuaForm({ ...ketuaForm, password: e.target.value })}
required
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Loading..." : "Login sebagai Ketua Jurusan"}
</Button>
</form>
</TabsContent>
<TabsContent value="admin" className="space-y-4">
<form onSubmit={handleAdminLogin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
placeholder="Masukkan username"
value={adminForm.username}
onChange={(e) => setAdminForm({ ...adminForm, username: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="admin-password">Password</Label>
<Input
id="admin-password"
type="password"
placeholder="Masukkan password"
value={adminForm.password}
onChange={(e) => setAdminForm({ ...adminForm, password: e.target.value })}
required
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Loading..." : "Login sebagai Admin"}
</Button>
</form>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,127 @@
import * as React from "react"
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}

View File

@@ -2,120 +2,184 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown } from "lucide-react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
const SelectGroup = SelectPrimitive.Group
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
const SelectValue = SelectPrimitive.Value
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
"border-input [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"p-1",
"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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
}
SelectTrigger,
SelectValue,
}

View File

@@ -4,8 +4,8 @@ import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
const Sheet = SheetPrimitive.Root
@@ -51,12 +51,15 @@ const sheetVariants = cva(
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
VariantProps<typeof sheetVariants> {
title?: string;
hideTitleVisually?: boolean;
}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
>(({ side = "right", className, children, title, hideTitleVisually, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
@@ -64,6 +67,14 @@ const SheetContent = React.forwardRef<
className={cn(sheetVariants({ side }), className)}
{...props}
>
{title &&
(hideTitleVisually ? (
<VisuallyHidden>
<SheetTitle>{title}</SheetTitle>
</VisuallyHidden>
) : (
<SheetTitle>{title}</SheetTitle>
))}
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />

116
components/ui/table.tsx Normal file
View File

@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -4,14 +4,19 @@ const fs = require('fs');
const supabaseUrl = 'https://avfoewfplaplaejjhiiv.supabase.co';
const supabaseKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImF2Zm9ld2ZwbGFwbGFlampoaWl2Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTAyNjM2NDUsImV4cCI6MjA2NTgzOTY0NX0._46bKpp95xLN6tkCPRCVNeVBSO-QyvOBPw-jTb74_0o';
// Generate a secure JWT secret (you should change this in production)
const jwtSecret = 'your-super-secret-jwt-key-change-this-in-production-' + Math.random().toString(36).substring(2, 15);
// Create .env.local content
const envContent = `NEXT_PUBLIC_SUPABASE_URL=${supabaseUrl}
NEXT_PUBLIC_SUPABASE_ANON_KEY=${supabaseKey}
JWT_SECRET=${jwtSecret}
`;
// Write to file
fs.writeFileSync('.env.local', envContent);
console.log('✅ .env.local file created successfully!');
console.log('✅ Environment file created successfully!');
console.log('📝 Please review and update the JWT_SECRET in production.');
console.log('Content:');
console.log(envContent);

View File

@@ -1,6 +1,84 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
return twMerge(clsx(inputs))
}
// Session management utilities
export interface User {
id: number;
nim?: string;
username?: string;
nip?: string;
role: string;
}
export interface Session {
isAuthenticated: boolean;
user?: User;
expiresAt?: string;
issuedAt?: string;
}
// Check if user is authenticated
export async function checkAuth(): Promise<Session> {
try {
const response = await fetch('/api/auth/check', {
method: 'GET',
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
return data;
} else {
return { isAuthenticated: false };
}
} catch (error) {
console.error('Auth check error:', error);
return { isAuthenticated: false };
}
}
// Logout user
export async function logout(): Promise<boolean> {
try {
const response = await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
});
if (response.ok) {
// Clear any client-side state
localStorage.removeItem('sidebarCollapsed');
return true;
}
return false;
} catch (error) {
console.error('Logout error:', error);
return false;
}
}
// Check if session is expired
export function isSessionExpired(expiresAt?: string): boolean {
if (!expiresAt) return true;
const expirationTime = new Date(expiresAt).getTime();
const currentTime = Date.now();
return currentTime >= expirationTime;
}
// Format date for display
export function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('id-ID', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}

View File

@@ -1,78 +1,55 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
export async function middleware(request: NextRequest) {
const token = request.cookies.get('token')?.value;
// Routes that require authentication
const protectedRoutes = [
'/visualisasi',
'/keloladata',
];
// Routes that are always accessible
const publicRoutes = [
'/',
'/api/auth/login',
];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Define public paths that don't require authentication
const publicPaths = ['/'];
const isPublicPath = publicPaths.includes(pathname);
// Check if the path is an API route or static file
const isApiRoute = pathname.startsWith('/api/');
const isStaticFile = pathname.match(/\.(jpg|jpeg|png|gif|ico|css|js)$/);
// Skip middleware for API routes and static files
if (isApiRoute || isStaticFile) {
// Check if the route is public
if (publicRoutes.some(route => pathname.startsWith(route))) {
return NextResponse.next();
}
// If trying to access public route with valid token, redirect to dashboard
if (token && isPublicPath) {
try {
await jwtVerify(
token,
new TextEncoder().encode(process.env.JWT_SECRET || 'your-secret-key')
);
return NextResponse.redirect(new URL('/dashboard', request.url));
} catch (error) {
// If token is invalid, clear it and stay on public page
const response = NextResponse.next();
response.cookies.set('token', '', {
expires: new Date(0),
path: '/',
httpOnly: true,
secure: false,
sameSite: 'lax'
});
return response;
// Check if the route requires authentication
if (protectedRoutes.some(route => pathname.startsWith(route))) {
// Get user session from cookies
const userSession = request.cookies.get('user_session');
if (!userSession) {
// Redirect to home page if not authenticated
return NextResponse.redirect(new URL('/', request.url));
}
}
// If the path is protected (dashboard routes) and user is not logged in, redirect to home
if (pathname.startsWith('/dashboard') && !token) {
return NextResponse.redirect(new URL('/', request.url));
}
// If the path is protected and user is logged in, verify token
if (pathname.startsWith('/dashboard') && token) {
try {
// Verify the token
await jwtVerify(
token,
new TextEncoder().encode(process.env.JWT_SECRET || 'your-secret-key')
);
const userData = JSON.parse(userSession.value);
// Check if user has access to keloladata routes (admin only)
if (pathname.startsWith('/keloladata') && userData.role_user !== 'admin') {
// Redirect to home page if not admin
return NextResponse.redirect(new URL('/', request.url));
}
return NextResponse.next();
} catch (error) {
// If token is invalid, redirect to home
const response = NextResponse.redirect(new URL('/', request.url));
response.cookies.set('token', '', {
expires: new Date(0),
path: '/',
httpOnly: true,
secure: false,
sameSite: 'lax'
});
return response;
// Invalid session, redirect to home
return NextResponse.redirect(new URL('/', request.url));
}
}
return NextResponse.next();
}
// Configure which paths the middleware should run on
export const config = {
matcher: [
/*

313
package-lock.json generated
View File

@@ -8,12 +8,13 @@
"name": "podif",
"version": "0.1.0",
"dependencies": {
"@hookform/resolvers": "^5.1.1",
"@radix-ui/react-accordion": "^1.2.4",
"@radix-ui/react-avatar": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.7",
"@radix-ui/react-label": "^2.1.4",
"@radix-ui/react-select": "^2.2.2",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.7",
@@ -36,10 +37,13 @@
"react": "^19.0.0",
"react-apexcharts": "^1.7.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.60.0",
"recharts": "^2.15.2",
"tailwind-merge": "^3.2.0",
"tw-animate-css": "^1.2.5",
"vaul": "^1.1.2"
"vaul": "^1.1.2",
"xlsx": "^0.18.5",
"zod": "^4.0.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -124,6 +128,18 @@
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT"
},
"node_modules/@hookform/resolvers": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.1.1.tgz",
"integrity": "sha512-J/NVING3LMAEvexJkyTLjruSm7aOFx7QX21pzkiJfMoNG0wl5aFEjLTl7ay7IQb9EWY6AkrBy7tHL2Alijpdcg==",
"license": "MIT",
"dependencies": {
"@standard-schema/utils": "^0.3.0"
},
"peerDependencies": {
"react-hook-form": "^7.55.0"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.1",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.1.tgz",
@@ -1148,12 +1164,12 @@
}
},
"node_modules/@radix-ui/react-label": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.4.tgz",
"integrity": "sha512-wy3dqizZnZVV4ja0FNnUhIWNwWdoldXrneEyUcVtLYDAt8ovGS4ridtMAOGgXBBIfggL4BOveVWsjXDORdGEQg==",
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz",
"integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.0"
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
@@ -1171,12 +1187,12 @@
}
},
"node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.0.tgz",
"integrity": "sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw==",
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.0"
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
@@ -1193,24 +1209,6 @@
}
}
},
"node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-slot": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz",
"integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.7.tgz",
@@ -1422,30 +1420,30 @@
}
},
"node_modules/@radix-ui/react-select": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.2.tgz",
"integrity": "sha512-HjkVHtBkuq+r3zUAZ/CvNWUGKPfuicGDbgtZgiQuFmNcV5F+Tgy24ep2nsAW2nFgvhGPJVqeBZa6KyVN0EyrBA==",
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz",
"integrity": "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-collection": "1.1.4",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.7",
"@radix-ui/react-dismissable-layer": "1.1.10",
"@radix-ui/react-focus-guards": "1.1.2",
"@radix-ui/react-focus-scope": "1.1.4",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.4",
"@radix-ui/react-portal": "1.1.6",
"@radix-ui/react-primitive": "2.1.0",
"@radix-ui/react-slot": "1.2.0",
"@radix-ui/react-popper": "1.2.7",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-visually-hidden": "1.2.0",
"@radix-ui/react-visually-hidden": "1.2.3",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
@@ -1465,12 +1463,12 @@
}
},
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-arrow": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.4.tgz",
"integrity": "sha512-qz+fxrqgNxG0dYew5l7qR3c7wdgRu1XVUHGnGYX7rg5HM4p9SWaRmJwfgR3J0SgyUKayLmzQIun+N6rWRgiRKw==",
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.0"
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
@@ -1488,15 +1486,15 @@
}
},
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-collection": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.4.tgz",
"integrity": "sha512-cv4vSf7HttqXilDnAnvINd53OTl1/bjUYVZrkFnA7nwmY9Ob2POUy0WY0sfqBAe1s5FyKsyceQlqiEGPYNTadg==",
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.0",
"@radix-ui/react-slot": "1.2.0"
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
@@ -1514,14 +1512,14 @@
}
},
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.7.tgz",
"integrity": "sha512-j5+WBUdhccJsmH5/H0K6RncjDtoALSEr6jbkaZu+bjw6hOPOhHycr6vEUujl+HBK8kjUfWcoCJXxP6e4lUlMZw==",
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz",
"integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.0",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "1.1.1"
},
@@ -1541,13 +1539,13 @@
}
},
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.4.tgz",
"integrity": "sha512-r2annK27lIW5w9Ho5NyQgqs0MmgZSTIKXWpVCJaLC1q2kZrZkcqnmHkCHMEmv8XLvsLlurKMPT+kbKkRkm/xVA==",
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.0",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
@@ -1566,16 +1564,16 @@
}
},
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-popper": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.4.tgz",
"integrity": "sha512-3p2Rgm/a1cK0r/UVkx5F/K9v/EplfjAeIFCGOPYPO4lZ0jtg4iSQXt/YGTSLWaf4x7NG6Z4+uKFcylcTZjeqDA==",
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz",
"integrity": "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.4",
"@radix-ui/react-arrow": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.0",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-rect": "1.1.1",
@@ -1598,12 +1596,12 @@
}
},
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-portal": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.6.tgz",
"integrity": "sha512-XmsIl2z1n/TsYFLIdYam2rmFwf9OC/Sh2avkbmVMDuBZIe7hSpM0cYnWPAo7nHOVx8zTuwDZGByfcqLdnzp3Vw==",
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.0",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
@@ -1622,12 +1620,12 @@
}
},
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-primitive": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.0.tgz",
"integrity": "sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw==",
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.0"
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
@@ -1644,24 +1642,6 @@
}
}
},
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz",
"integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
@@ -1681,6 +1661,29 @@
}
}
},
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-visually-hidden": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
@@ -2497,6 +2500,12 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@supabase/auth-js": {
"version": "2.70.0",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.70.0.tgz",
@@ -3045,6 +3054,15 @@
"integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==",
"license": "MIT"
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/apexcharts": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-4.5.0.tgz",
@@ -3117,6 +3135,19 @@
],
"license": "CC-BY-4.0"
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
@@ -3160,6 +3191,15 @@
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@@ -3214,6 +3254,18 @@
"node": ">= 0.6"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@@ -3411,6 +3463,15 @@
"node": ">=6.0.0"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
@@ -4175,6 +4236,22 @@
"react": "^19.1.0"
}
},
"node_modules/react-hook-form": {
"version": "7.60.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.60.0.tgz",
"integrity": "sha512-SBrYOvMbDB7cV8ZfNpaiLcgjH/a1c7aK0lK+aNigpf4xWLO8q+o4tcvVurv3c4EOyzn/3dCsYt4GKD42VvJ/+A==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@@ -4417,6 +4494,18 @@
"node": ">=0.10.0"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
@@ -4625,6 +4714,24 @@
"webidl-conversions": "^3.0.0"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/ws": {
"version": "8.18.2",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz",
@@ -4646,11 +4753,41 @@
}
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/zod": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.0.5.tgz",
"integrity": "sha512-/5UuuRPStvHXu7RS+gmvRf4NXrNxpSllGwDnCBcJZtQsKrviYXm54yDGV2KYNLT5kq0lHGcl7lqWJLgSaG+tgA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -10,12 +10,13 @@
"lint": "next lint"
},
"dependencies": {
"@hookform/resolvers": "^5.1.1",
"@radix-ui/react-accordion": "^1.2.4",
"@radix-ui/react-avatar": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.7",
"@radix-ui/react-label": "^2.1.4",
"@radix-ui/react-select": "^2.2.2",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.7",
@@ -38,10 +39,13 @@
"react": "^19.0.0",
"react-apexcharts": "^1.7.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.60.0",
"recharts": "^2.15.2",
"tailwind-merge": "^3.2.0",
"tw-animate-css": "^1.2.5",
"vaul": "^1.1.2"
"vaul": "^1.1.2",
"xlsx": "^0.18.5",
"zod": "^4.0.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",