Change Alur Aplikasi
This commit is contained in:
@@ -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',
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
26
app/api/auth/user/route.ts
Normal file
26
app/api/auth/user/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
322
app/api/keloladata/data-beasiswa-mahasiswa/route.ts
Normal file
322
app/api/keloladata/data-beasiswa-mahasiswa/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
374
app/api/keloladata/data-beasiswa-mahasiswa/upload/route.ts
Normal file
374
app/api/keloladata/data-beasiswa-mahasiswa/upload/route.ts
Normal 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 };
|
||||
}
|
||||
146
app/api/keloladata/data-kelompok-keahlian/route.ts
Normal file
146
app/api/keloladata/data-kelompok-keahlian/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
241
app/api/keloladata/data-mahasiswa/route.ts
Normal file
241
app/api/keloladata/data-mahasiswa/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
360
app/api/keloladata/data-mahasiswa/upload/route.ts
Normal file
360
app/api/keloladata/data-mahasiswa/upload/route.ts
Normal 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 };
|
||||
}
|
||||
336
app/api/keloladata/data-prestasi-mahasiswa/route.ts
Normal file
336
app/api/keloladata/data-prestasi-mahasiswa/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
470
app/api/keloladata/data-prestasi-mahasiswa/upload/route.ts
Normal file
470
app/api/keloladata/data-prestasi-mahasiswa/upload/route.ts
Normal 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 };
|
||||
}
|
||||
63
app/api/keloladata/setting-jenis-pendaftaran/route.ts
Normal file
63
app/api/keloladata/setting-jenis-pendaftaran/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
94
app/api/keloladata/update-semester/route.ts
Normal file
94
app/api/keloladata/update-semester/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
9
app/keloladata/beasiswa/page.tsx
Normal file
9
app/keloladata/beasiswa/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
app/keloladata/mahasiswa/page.tsx
Normal file
9
app/keloladata/mahasiswa/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
app/keloladata/prestasi/page.tsx
Normal file
9
app/keloladata/prestasi/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
586
app/page.tsx
586
app/page.tsx
@@ -1,390 +1,238 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Loader2, Eye, EyeOff } from "lucide-react";
|
||||
import { ThemeProvider } from '@/components/theme-provider';
|
||||
import { Toaster } from '@/components/ui/toaster';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Users, GraduationCap, Trophy, BookOpen } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import StatusMahasiswaChart from "@/components/charts/StatusMahasiswaChart";
|
||||
import StatistikMahasiswaChart from "@/components/charts/StatistikMahasiswaChart";
|
||||
import JenisPendaftaranChart from "@/components/charts/JenisPendaftaranChart";
|
||||
import AsalDaerahChart from "@/components/charts/AsalDaerahChart";
|
||||
import IPKChart from '@/components/charts/IPKChart';
|
||||
import FilterTahunAngkatan from "@/components/FilterTahunAngkatan";
|
||||
import StatistikPerAngkatanChart from "@/components/charts/StatistikPerAngkatanChart";
|
||||
import JenisPendaftaranPerAngkatanChart from "@/components/charts/JenisPendaftaranPerAngkatanChart";
|
||||
import AsalDaerahPerAngkatanChart from "@/components/charts/AsalDaerahPerAngkatanChart";
|
||||
|
||||
export default function LandingPage() {
|
||||
const router = useRouter();
|
||||
const [isLoginOpen, setIsLoginOpen] = useState(true);
|
||||
const [isRegisterOpen, setIsRegisterOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('dosen');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Admin form state
|
||||
const [adminForm, setAdminForm] = useState({
|
||||
username: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
// Dosen form state
|
||||
const [dosenForm, setDosenForm] = useState({
|
||||
nip: '',
|
||||
password: ''
|
||||
interface MahasiswaTotal {
|
||||
total_mahasiswa: number;
|
||||
mahasiswa_aktif: number;
|
||||
total_lulus: number;
|
||||
pria_lulus: number;
|
||||
wanita_lulus: number;
|
||||
total_berprestasi: number;
|
||||
prestasi_akademik: number;
|
||||
prestasi_non_akademik: number;
|
||||
ipk_rata_rata_aktif: number;
|
||||
ipk_rata_rata_lulus: number;
|
||||
total_mahasiswa_aktif_lulus: number;
|
||||
}
|
||||
|
||||
// Skeleton loading component
|
||||
const CardSkeleton = () => (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
94
app/visualisasi/beasiswa/page.tsx
Normal file
94
app/visualisasi/beasiswa/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
app/visualisasi/berprestasi/page.tsx
Normal file
80
app/visualisasi/berprestasi/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
app/visualisasi/status/page.tsx
Normal file
86
app/visualisasi/status/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
app/visualisasi/tipekelulusan/page.tsx
Normal file
61
app/visualisasi/tipekelulusan/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
app/visualisasi/total/page.tsx
Normal file
59
app/visualisasi/total/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -317,7 +317,7 @@ export default function StatistikMahasiswaChart() {
|
||||
series={chartSeries}
|
||||
type="bar"
|
||||
height="100%"
|
||||
width="90%"
|
||||
width="100%"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -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>
|
||||
|
||||
929
components/datatable/data-table-mahasiswa.tsx
Normal file
929
components/datatable/data-table-mahasiswa.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
224
components/datatable/edit-jenis-pendaftaran.tsx
Normal file
224
components/datatable/edit-jenis-pendaftaran.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
160
components/datatable/upload-excel-mahasiswa.tsx
Normal file
160
components/datatable/upload-excel-mahasiswa.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
167
components/ui/form.tsx
Normal 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,
|
||||
}
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
212
components/ui/login-dialog.tsx
Normal file
212
components/ui/login-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
components/ui/pagination.tsx
Normal file
127
components/ui/pagination.tsx
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
116
components/ui/table.tsx
Normal 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,
|
||||
}
|
||||
@@ -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);
|
||||
84
lib/utils.ts
84
lib/utils.ts
@@ -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'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
313
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
10
package.json
10
package.json
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user