First commit
This commit is contained in:
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"css.lint.unknownAtRules": "ignore"
|
||||||
|
}
|
||||||
63
app/api/auth/check/route.ts
Normal file
63
app/api/auth/check/route.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { jwtVerify } from 'jose';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
let connection;
|
||||||
|
try {
|
||||||
|
const token = (await (await cookies()).get('token'))?.value;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify JWT token
|
||||||
|
const { payload } = await jwtVerify(
|
||||||
|
token,
|
||||||
|
new TextEncoder().encode(process.env.JWT_SECRET || 'your-secret-key')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get connection from pool
|
||||||
|
connection = await pool.getConnection();
|
||||||
|
|
||||||
|
// Get user data
|
||||||
|
const [users]: any = await connection.execute(
|
||||||
|
'SELECT id_user, nim, username, role FROM user WHERE id_user = ?',
|
||||||
|
[payload.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
connection.release();
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'User not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = users[0];
|
||||||
|
connection.release();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
user: {
|
||||||
|
id: user.id_user,
|
||||||
|
nim: user.nim,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (connection) {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Auth check error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
174
app/api/auth/login/route.ts
Normal file
174
app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { SignJWT } from 'jose';
|
||||||
|
import { RowDataPacket } from 'mysql2';
|
||||||
|
|
||||||
|
interface User extends RowDataPacket {
|
||||||
|
id_user: number;
|
||||||
|
nim: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
let connection;
|
||||||
|
try {
|
||||||
|
console.log('Login request received');
|
||||||
|
|
||||||
|
// Test database connection first
|
||||||
|
try {
|
||||||
|
connection = await pool.getConnection();
|
||||||
|
console.log('Database connection successful');
|
||||||
|
} catch (dbError) {
|
||||||
|
console.error('Database connection error:', dbError);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Tidak dapat terhubung ke database' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
console.log('Request body:', body);
|
||||||
|
|
||||||
|
const { nim, password } = body;
|
||||||
|
console.log('Extracted credentials:', { nim, password: '***' });
|
||||||
|
|
||||||
|
// Validate input
|
||||||
|
if (!nim || !password) {
|
||||||
|
console.log('Missing credentials:', { nim: !!nim, password: !!password });
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'NIM dan password harus diisi' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user by NIM
|
||||||
|
console.log('Querying user with NIM:', nim);
|
||||||
|
let users: User[];
|
||||||
|
try {
|
||||||
|
const [rows] = await connection.execute<User[]>(
|
||||||
|
'SELECT * FROM user WHERE nim = ?',
|
||||||
|
[nim]
|
||||||
|
);
|
||||||
|
users = rows;
|
||||||
|
console.log('Query result:', users.length > 0 ? 'User found' : 'User not found');
|
||||||
|
} catch (queryError) {
|
||||||
|
console.error('Database query error:', queryError);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Terjadi kesalahan saat memeriksa data pengguna' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
console.log('No user found with NIM:', nim);
|
||||||
|
connection.release();
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'NIM atau password salah' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = users[0];
|
||||||
|
console.log('User found:', {
|
||||||
|
id: user.id_user,
|
||||||
|
nim: user.nim,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
console.log('Verifying password...');
|
||||||
|
let isPasswordValid;
|
||||||
|
try {
|
||||||
|
isPasswordValid = await bcrypt.compare(password, user.password);
|
||||||
|
console.log('Password verification result:', isPasswordValid ? 'Valid' : 'Invalid');
|
||||||
|
} catch (bcryptError) {
|
||||||
|
console.error('Password verification error:', bcryptError);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Terjadi kesalahan saat memverifikasi password' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
console.log('Invalid password for user:', nim);
|
||||||
|
connection.release();
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'NIM atau password salah' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create JWT token
|
||||||
|
console.log('Creating JWT token...');
|
||||||
|
let token;
|
||||||
|
try {
|
||||||
|
token = await new SignJWT({
|
||||||
|
id: user.id_user,
|
||||||
|
nim: user.nim,
|
||||||
|
role: user.role
|
||||||
|
})
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setExpirationTime('24h')
|
||||||
|
.sign(new TextEncoder().encode(process.env.JWT_SECRET || 'your-secret-key'));
|
||||||
|
console.log('JWT token created');
|
||||||
|
} catch (jwtError) {
|
||||||
|
console.error('JWT creation error:', jwtError);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Terjadi kesalahan saat membuat token' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set cookie
|
||||||
|
console.log('Setting response...');
|
||||||
|
const response = NextResponse.json({
|
||||||
|
user: {
|
||||||
|
id: user.id_user,
|
||||||
|
nim: user.nim,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
response.cookies.set('token', token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: false,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 60 * 60 * 24 // 24 hours
|
||||||
|
});
|
||||||
|
console.log('Cookie set');
|
||||||
|
|
||||||
|
connection.release();
|
||||||
|
console.log('Login process completed successfully');
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
if (connection) {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Login error details:', error);
|
||||||
|
if (error instanceof Error) {
|
||||||
|
console.error('Error message:', error.message);
|
||||||
|
console.error('Error stack:', error.stack);
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Terjadi kesalahan saat login' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle OPTIONS request for CORS
|
||||||
|
export async function OPTIONS() {
|
||||||
|
return NextResponse.json({}, {
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
28
app/api/auth/logout/route.ts
Normal file
28
app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
const response = NextResponse.json(
|
||||||
|
{ message: 'Logout berhasil' },
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear the token cookie with additional security options
|
||||||
|
response.cookies.set('token', '', {
|
||||||
|
expires: new Date(0),
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
secure: false,
|
||||||
|
sameSite: 'lax'
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Terjadi kesalahan saat logout' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
83
app/api/auth/register/route.ts
Normal file
83
app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
let connection;
|
||||||
|
try {
|
||||||
|
const { username, nim, password } = await request.json();
|
||||||
|
|
||||||
|
// Validate input
|
||||||
|
if (!username || !nim || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Semua field harus diisi' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate NIM format (11 characters)
|
||||||
|
if (nim.length !== 11) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'NIM harus 11 karakter' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get connection from pool
|
||||||
|
connection = await pool.getConnection();
|
||||||
|
|
||||||
|
// Check if NIM exists in mahasiswa table
|
||||||
|
const [mahasiswa]: any = await connection.execute(
|
||||||
|
'SELECT * FROM mahasiswa WHERE nim = ?',
|
||||||
|
[nim]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mahasiswa.length === 0) {
|
||||||
|
connection.release();
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'NIM tidak terdaftar sebagai mahasiswa' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if NIM already exists in user table
|
||||||
|
const [existingUsers]: any = await connection.execute(
|
||||||
|
'SELECT * FROM user WHERE nim = ?',
|
||||||
|
[nim]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingUsers.length > 0) {
|
||||||
|
connection.release();
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'NIM sudah terdaftar sebagai pengguna' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
// Insert new user
|
||||||
|
await connection.execute(
|
||||||
|
'INSERT INTO user (nim, username, password, role, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW())',
|
||||||
|
[nim, username, hashedPassword, 'mahasiswa']
|
||||||
|
);
|
||||||
|
|
||||||
|
connection.release();
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Registrasi berhasil' },
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (connection) {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Registration error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Terjadi kesalahan saat registrasi' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/api/mahasiswa/asal-daerah-angkatan/route.ts
Normal file
58
app/api/mahasiswa/asal-daerah-angkatan/route.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
import { RowDataPacket } from 'mysql2';
|
||||||
|
|
||||||
|
interface AsalDaerah extends RowDataPacket {
|
||||||
|
kabupaten: string;
|
||||||
|
jumlah: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tahunAngkatan = searchParams.get('tahun_angkatan');
|
||||||
|
|
||||||
|
if (!tahunAngkatan) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Tahun angkatan diperlukan' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const query = `
|
||||||
|
SELECT kabupaten, COUNT(*) AS jumlah
|
||||||
|
FROM mahasiswa
|
||||||
|
WHERE tahun_angkatan = ?
|
||||||
|
GROUP BY kabupaten
|
||||||
|
ORDER BY jumlah DESC, kabupaten ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [results] = await connection.query<AsalDaerah[]>(query, [tahunAngkatan]);
|
||||||
|
|
||||||
|
return NextResponse.json(results, {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'public, max-age=60, stale-while-revalidate=30',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching asal daerah per angkatan:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch asal daerah data' },
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/api/mahasiswa/asal-daerah-beasiswa/route.ts
Normal file
48
app/api/mahasiswa/asal-daerah-beasiswa/route.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tahunAngkatan = searchParams.get('tahunAngkatan');
|
||||||
|
const jenisBeasiswa = searchParams.get('jenisBeasiswa');
|
||||||
|
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
m.tahun_angkatan,
|
||||||
|
m.kabupaten,
|
||||||
|
COUNT(m.nim) AS jumlah_mahasiswa
|
||||||
|
FROM
|
||||||
|
mahasiswa m
|
||||||
|
JOIN
|
||||||
|
beasiswa_mahasiswa s ON m.nim = s.nim
|
||||||
|
WHERE
|
||||||
|
s.jenis_beasiswa = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (tahunAngkatan && tahunAngkatan !== 'all') {
|
||||||
|
query += ` AND m.tahun_angkatan = ?`;
|
||||||
|
}
|
||||||
|
|
||||||
|
query += `
|
||||||
|
GROUP BY
|
||||||
|
m.kabupaten, m.tahun_angkatan
|
||||||
|
ORDER BY
|
||||||
|
m.tahun_angkatan ASC, m.kabupaten
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params = [jenisBeasiswa];
|
||||||
|
if (tahunAngkatan && tahunAngkatan !== 'all') {
|
||||||
|
params.push(tahunAngkatan);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows] = await pool.query(query, params);
|
||||||
|
return NextResponse.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching data:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app/api/mahasiswa/asal-daerah-lulus/route.ts
Normal file
43
app/api/mahasiswa/asal-daerah-lulus/route.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tahunAngkatan = searchParams.get('tahunAngkatan');
|
||||||
|
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
m.tahun_angkatan,
|
||||||
|
m.kabupaten,
|
||||||
|
COUNT(m.nim) AS jumlah_lulus_tepat_waktu
|
||||||
|
FROM
|
||||||
|
mahasiswa m
|
||||||
|
JOIN
|
||||||
|
status_mahasiswa s ON m.nim = s.nim
|
||||||
|
WHERE
|
||||||
|
s.status_kuliah = 'Lulus'
|
||||||
|
AND s.semester <= 8
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (tahunAngkatan && tahunAngkatan !== 'all') {
|
||||||
|
query += ` AND m.tahun_angkatan = '${tahunAngkatan}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
query += `
|
||||||
|
GROUP BY
|
||||||
|
m.tahun_angkatan, m.kabupaten
|
||||||
|
ORDER BY
|
||||||
|
m.tahun_angkatan DESC, jumlah_lulus_tepat_waktu DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [rows] = await pool.query(query);
|
||||||
|
return NextResponse.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching data:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/api/mahasiswa/asal-daerah-prestasi/route.ts
Normal file
48
app/api/mahasiswa/asal-daerah-prestasi/route.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tahunAngkatan = searchParams.get('tahunAngkatan');
|
||||||
|
const jenisPrestasi = searchParams.get('jenisPrestasi');
|
||||||
|
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
m.tahun_angkatan,
|
||||||
|
m.kabupaten,
|
||||||
|
COUNT(m.nim) AS asal_daerah_mahasiswa_prestasi
|
||||||
|
FROM
|
||||||
|
mahasiswa m
|
||||||
|
JOIN
|
||||||
|
prestasi_mahasiswa s ON m.nim = s.nim
|
||||||
|
WHERE
|
||||||
|
s.jenis_prestasi = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (tahunAngkatan && tahunAngkatan !== 'all') {
|
||||||
|
query += ` AND m.tahun_angkatan = ?`;
|
||||||
|
}
|
||||||
|
|
||||||
|
query += `
|
||||||
|
GROUP BY
|
||||||
|
m.tahun_angkatan, m.kabupaten
|
||||||
|
ORDER BY
|
||||||
|
m.tahun_angkatan DESC, m.kabupaten
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params = [jenisPrestasi];
|
||||||
|
if (tahunAngkatan && tahunAngkatan !== 'all') {
|
||||||
|
params.push(tahunAngkatan);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows] = await pool.query(query, params);
|
||||||
|
return NextResponse.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching data:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
app/api/mahasiswa/asal-daerah-status/route.ts
Normal file
74
app/api/mahasiswa/asal-daerah-status/route.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
import { RowDataPacket } from 'mysql2';
|
||||||
|
|
||||||
|
interface AsalDaerahStatus extends RowDataPacket {
|
||||||
|
kabupaten: string;
|
||||||
|
tahun_angkatan?: number;
|
||||||
|
status_kuliah: string;
|
||||||
|
total_mahasiswa: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tahunAngkatan = searchParams.get('tahun_angkatan');
|
||||||
|
const statusKuliah = searchParams.get('status_kuliah');
|
||||||
|
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
m.kabupaten,
|
||||||
|
${tahunAngkatan && tahunAngkatan !== 'all' ? 'm.tahun_angkatan,' : ''}
|
||||||
|
s.status_kuliah,
|
||||||
|
COUNT(m.nim) AS total_mahasiswa
|
||||||
|
FROM
|
||||||
|
mahasiswa m
|
||||||
|
JOIN
|
||||||
|
status_mahasiswa s ON m.nim = s.nim
|
||||||
|
WHERE
|
||||||
|
s.status_kuliah = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: any[] = [statusKuliah];
|
||||||
|
|
||||||
|
if (tahunAngkatan && tahunAngkatan !== 'all') {
|
||||||
|
query += ` AND m.tahun_angkatan = ?`;
|
||||||
|
params.push(tahunAngkatan);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += `
|
||||||
|
GROUP BY
|
||||||
|
m.kabupaten${tahunAngkatan && tahunAngkatan !== 'all' ? ', m.tahun_angkatan' : ''}, s.status_kuliah
|
||||||
|
ORDER BY
|
||||||
|
${tahunAngkatan && tahunAngkatan !== 'all' ? 'm.tahun_angkatan ASC,' : ''} m.kabupaten, s.status_kuliah
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [results] = await connection.query<AsalDaerahStatus[]>(query, params);
|
||||||
|
|
||||||
|
return NextResponse.json(results, {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'public, max-age=60, stale-while-revalidate=30',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching asal daerah status:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch asal daerah status data' },
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/api/mahasiswa/asal-daerah/route.ts
Normal file
45
app/api/mahasiswa/asal-daerah/route.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
import { RowDataPacket } from 'mysql2';
|
||||||
|
|
||||||
|
interface AsalDaerah extends RowDataPacket {
|
||||||
|
kabupaten: string;
|
||||||
|
jumlah: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [results] = await connection.query<AsalDaerah[]>(`
|
||||||
|
SELECT kabupaten, COUNT(*) AS jumlah
|
||||||
|
FROM mahasiswa
|
||||||
|
GROUP BY kabupaten
|
||||||
|
ORDER BY kabupaten ASC
|
||||||
|
`);
|
||||||
|
|
||||||
|
return NextResponse.json(results, {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'public, max-age=60, stale-while-revalidate=30',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching asal daerah:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch asal daerah data' },
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/api/mahasiswa/gender-per-angkatan/route.ts
Normal file
49
app/api/mahasiswa/gender-per-angkatan/route.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tahun = searchParams.get('tahun');
|
||||||
|
|
||||||
|
if (!tahun) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Tahun angkatan is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [results] = await connection.query(`
|
||||||
|
SELECT tahun_angkatan, jk, COUNT(*) AS jumlah
|
||||||
|
FROM mahasiswa
|
||||||
|
WHERE tahun_angkatan = ?
|
||||||
|
GROUP BY jk
|
||||||
|
`, [tahun]);
|
||||||
|
|
||||||
|
return NextResponse.json(results, {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'public, max-age=60, stale-while-revalidate=30',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching gender per angkatan:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch gender per angkatan data' },
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/api/mahasiswa/ipk-beasiswa/route.ts
Normal file
35
app/api/mahasiswa/ipk-beasiswa/route.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const jenisBeasiswa = searchParams.get('jenisBeasiswa');
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
m.tahun_angkatan,
|
||||||
|
COUNT(m.nim) AS total_mahasiswa_beasiswa,
|
||||||
|
ROUND(AVG(m.ipk), 2) AS rata_rata_ipk
|
||||||
|
FROM
|
||||||
|
mahasiswa m
|
||||||
|
JOIN
|
||||||
|
beasiswa_mahasiswa s ON m.nim = s.nim
|
||||||
|
WHERE
|
||||||
|
s.jenis_beasiswa = ?
|
||||||
|
GROUP BY
|
||||||
|
m.tahun_angkatan
|
||||||
|
ORDER BY
|
||||||
|
m.tahun_angkatan ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [rows] = await pool.query(query, [jenisBeasiswa]);
|
||||||
|
return NextResponse.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching data:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/api/mahasiswa/ipk-jenis-kelamin/route.ts
Normal file
55
app/api/mahasiswa/ipk-jenis-kelamin/route.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
import { RowDataPacket } from 'mysql2';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tahunAngkatan = searchParams.get('tahun_angkatan');
|
||||||
|
|
||||||
|
if (!tahunAngkatan) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Tahun angkatan diperlukan' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
jk,
|
||||||
|
ROUND(AVG(ipk), 2) as rata_rata_ipk
|
||||||
|
FROM mahasiswa
|
||||||
|
WHERE tahun_angkatan = ?
|
||||||
|
GROUP BY jk
|
||||||
|
ORDER BY jk ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [results] = await connection.query<RowDataPacket[]>(query, [tahunAngkatan]);
|
||||||
|
|
||||||
|
return NextResponse.json(results, {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'public, max-age=60, stale-while-revalidate=30',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching IPK data:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch IPK data' },
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/api/mahasiswa/ipk-lulus-tepat/route.ts
Normal file
42
app/api/mahasiswa/ipk-lulus-tepat/route.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tahunAngkatan = searchParams.get('tahunAngkatan');
|
||||||
|
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
m.tahun_angkatan,
|
||||||
|
ROUND(AVG(m.ipk), 2) AS rata_rata_ipk
|
||||||
|
FROM
|
||||||
|
mahasiswa m
|
||||||
|
JOIN
|
||||||
|
status_mahasiswa s ON m.nim = s.nim
|
||||||
|
WHERE
|
||||||
|
s.status_kuliah = 'Lulus'
|
||||||
|
AND s.semester <= 8
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (tahunAngkatan && tahunAngkatan !== 'all') {
|
||||||
|
query += ` AND m.tahun_angkatan = '${tahunAngkatan}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
query += `
|
||||||
|
GROUP BY
|
||||||
|
m.tahun_angkatan
|
||||||
|
ORDER BY
|
||||||
|
m.tahun_angkatan ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [rows] = await pool.query(query);
|
||||||
|
return NextResponse.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching data:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/api/mahasiswa/ipk-prestasi/route.ts
Normal file
35
app/api/mahasiswa/ipk-prestasi/route.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const jenisPrestasi = searchParams.get('jenisPrestasi');
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
m.tahun_angkatan,
|
||||||
|
COUNT(m.nim) AS total_mahasiswa_prestasi,
|
||||||
|
ROUND(AVG(m.ipk), 2) AS rata_rata_ipk
|
||||||
|
FROM
|
||||||
|
mahasiswa m
|
||||||
|
JOIN
|
||||||
|
prestasi_mahasiswa s ON m.nim = s.nim
|
||||||
|
WHERE
|
||||||
|
s.jenis_prestasi = ?
|
||||||
|
GROUP BY
|
||||||
|
m.tahun_angkatan
|
||||||
|
ORDER BY
|
||||||
|
m.tahun_angkatan ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [rows] = await pool.query(query, [jenisPrestasi]);
|
||||||
|
return NextResponse.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching data:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/api/mahasiswa/ipk-status/route.ts
Normal file
63
app/api/mahasiswa/ipk-status/route.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
|
||||||
|
interface IpkStatus {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
status_kuliah: string;
|
||||||
|
total_mahasiswa: number;
|
||||||
|
rata_rata_ipk: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tahunAngkatan = searchParams.get('tahun_angkatan');
|
||||||
|
const statusKuliah = searchParams.get('status_kuliah');
|
||||||
|
|
||||||
|
if (!statusKuliah) {
|
||||||
|
console.error('Missing required parameter: status_kuliah');
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Missing required parameter: status_kuliah' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
m.tahun_angkatan,
|
||||||
|
s.status_kuliah,
|
||||||
|
COUNT(m.nim) AS total_mahasiswa,
|
||||||
|
ROUND(AVG(m.ipk), 2) AS rata_rata_ipk
|
||||||
|
FROM
|
||||||
|
mahasiswa m
|
||||||
|
JOIN
|
||||||
|
status_mahasiswa s ON m.nim = s.nim
|
||||||
|
WHERE
|
||||||
|
s.status_kuliah = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: any[] = [statusKuliah];
|
||||||
|
|
||||||
|
if (tahunAngkatan && tahunAngkatan !== 'all') {
|
||||||
|
query += ' AND m.tahun_angkatan = ?';
|
||||||
|
params.push(tahunAngkatan);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += `
|
||||||
|
GROUP BY
|
||||||
|
m.tahun_angkatan, s.status_kuliah
|
||||||
|
ORDER BY
|
||||||
|
m.tahun_angkatan DESC, s.status_kuliah
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [rows] = await pool.query(query, params);
|
||||||
|
|
||||||
|
return NextResponse.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in ipk-status route:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error', details: error instanceof Error ? error.message : 'Unknown error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/api/mahasiswa/ipk/route.ts
Normal file
44
app/api/mahasiswa/ipk/route.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
import { RowDataPacket } from 'mysql2';
|
||||||
|
|
||||||
|
interface IPKData extends RowDataPacket {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
rata_rata_ipk: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [results] = await connection.query<IPKData[]>(`
|
||||||
|
SELECT tahun_angkatan, ROUND(AVG(ipk), 2) AS rata_rata_ipk
|
||||||
|
FROM mahasiswa
|
||||||
|
GROUP BY tahun_angkatan
|
||||||
|
`);
|
||||||
|
|
||||||
|
return NextResponse.json(results, {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'public, max-age=60, stale-while-revalidate=30',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching IPK data:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch IPK data' },
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/api/mahasiswa/jenis-beasiswa/route.ts
Normal file
20
app/api/mahasiswa/jenis-beasiswa/route.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query(`
|
||||||
|
SELECT DISTINCT jenis_beasiswa
|
||||||
|
FROM beasiswa_mahasiswa
|
||||||
|
ORDER BY jenis_beasiswa ASC
|
||||||
|
`);
|
||||||
|
|
||||||
|
return NextResponse.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching data:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/api/mahasiswa/jenis-pendaftaran-beasiswa/route.ts
Normal file
48
app/api/mahasiswa/jenis-pendaftaran-beasiswa/route.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tahunAngkatan = searchParams.get('tahunAngkatan');
|
||||||
|
const jenisBeasiswa = searchParams.get('jenisBeasiswa');
|
||||||
|
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
m.tahun_angkatan,
|
||||||
|
m.jenis_pendaftaran,
|
||||||
|
COUNT(m.nim) AS jumlah_mahasiswa_beasiswa
|
||||||
|
FROM
|
||||||
|
mahasiswa m
|
||||||
|
JOIN
|
||||||
|
beasiswa_mahasiswa s ON m.nim = s.nim
|
||||||
|
WHERE
|
||||||
|
s.jenis_beasiswa = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (tahunAngkatan && tahunAngkatan !== 'all') {
|
||||||
|
query += ` AND m.tahun_angkatan = ?`;
|
||||||
|
}
|
||||||
|
|
||||||
|
query += `
|
||||||
|
GROUP BY
|
||||||
|
m.tahun_angkatan, m.jenis_pendaftaran
|
||||||
|
ORDER BY
|
||||||
|
m.tahun_angkatan DESC, m.jenis_pendaftaran
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params = [jenisBeasiswa];
|
||||||
|
if (tahunAngkatan && tahunAngkatan !== 'all') {
|
||||||
|
params.push(tahunAngkatan);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows] = await pool.query(query, params);
|
||||||
|
return NextResponse.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching data:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/api/mahasiswa/jenis-pendaftaran-lulus/route.ts
Normal file
53
app/api/mahasiswa/jenis-pendaftaran-lulus/route.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
|
||||||
|
interface JenisPendaftaranLulus {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
jenis_pendaftaran: string;
|
||||||
|
jumlah_lulus_tepat_waktu: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tahunAngkatan = searchParams.get('tahun_angkatan');
|
||||||
|
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
m.tahun_angkatan,
|
||||||
|
m.jenis_pendaftaran,
|
||||||
|
COUNT(m.nim) AS jumlah_lulus_tepat_waktu
|
||||||
|
FROM
|
||||||
|
mahasiswa m
|
||||||
|
JOIN
|
||||||
|
status_mahasiswa s ON m.nim = s.nim
|
||||||
|
WHERE
|
||||||
|
s.status_kuliah = 'Lulus'
|
||||||
|
AND s.semester <= 8
|
||||||
|
`;
|
||||||
|
|
||||||
|
const queryParams: any[] = [];
|
||||||
|
|
||||||
|
if (tahunAngkatan && tahunAngkatan !== 'all') {
|
||||||
|
query += ` AND m.tahun_angkatan = ?`;
|
||||||
|
queryParams.push(parseInt(tahunAngkatan));
|
||||||
|
}
|
||||||
|
|
||||||
|
query += `
|
||||||
|
GROUP BY
|
||||||
|
m.tahun_angkatan, m.jenis_pendaftaran
|
||||||
|
ORDER BY
|
||||||
|
m.tahun_angkatan DESC, m.jenis_pendaftaran
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [rows] = await pool.query(query, queryParams);
|
||||||
|
|
||||||
|
return NextResponse.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Detailed error in GET /api/mahasiswa/jenis-pendaftaran-lulus:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error', details: error instanceof Error ? error.message : 'Unknown error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/api/mahasiswa/jenis-pendaftaran-prestasi/route.ts
Normal file
35
app/api/mahasiswa/jenis-pendaftaran-prestasi/route.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const jenisPrestasi = searchParams.get('jenisPrestasi');
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
m.tahun_angkatan,
|
||||||
|
m.jenis_pendaftaran,
|
||||||
|
COUNT(m.nim) AS jenis_pendaftaran_mahasiswa_prestasi
|
||||||
|
FROM
|
||||||
|
mahasiswa m
|
||||||
|
JOIN
|
||||||
|
prestasi_mahasiswa s ON m.nim = s.nim
|
||||||
|
WHERE
|
||||||
|
s.jenis_prestasi = ?
|
||||||
|
GROUP BY
|
||||||
|
m.tahun_angkatan, m.jenis_pendaftaran
|
||||||
|
ORDER BY
|
||||||
|
m.tahun_angkatan DESC, m.jenis_pendaftaran
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [rows] = await pool.query(query, [jenisPrestasi]);
|
||||||
|
return NextResponse.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching data:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
app/api/mahasiswa/jenis-pendaftaran-status/route.ts
Normal file
74
app/api/mahasiswa/jenis-pendaftaran-status/route.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
import { RowDataPacket } from 'mysql2';
|
||||||
|
|
||||||
|
interface JenisPendaftaranStatus extends RowDataPacket {
|
||||||
|
jenis_pendaftaran: string;
|
||||||
|
tahun_angkatan: number;
|
||||||
|
status_kuliah: string;
|
||||||
|
total_mahasiswa: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tahunAngkatan = searchParams.get('tahun_angkatan');
|
||||||
|
const statusKuliah = searchParams.get('status_kuliah');
|
||||||
|
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
m.jenis_pendaftaran,
|
||||||
|
m.tahun_angkatan,
|
||||||
|
s.status_kuliah,
|
||||||
|
COUNT(m.nim) AS total_mahasiswa
|
||||||
|
FROM
|
||||||
|
mahasiswa m
|
||||||
|
JOIN
|
||||||
|
status_mahasiswa s ON m.nim = s.nim
|
||||||
|
WHERE
|
||||||
|
s.status_kuliah = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: any[] = [statusKuliah];
|
||||||
|
|
||||||
|
if (tahunAngkatan && tahunAngkatan !== 'all') {
|
||||||
|
query += ` AND m.tahun_angkatan = ?`;
|
||||||
|
params.push(tahunAngkatan);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += `
|
||||||
|
GROUP BY
|
||||||
|
m.jenis_pendaftaran, m.tahun_angkatan, s.status_kuliah
|
||||||
|
ORDER BY
|
||||||
|
m.tahun_angkatan DESC, m.jenis_pendaftaran, s.status_kuliah
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [results] = await connection.query<JenisPendaftaranStatus[]>(query, params);
|
||||||
|
|
||||||
|
return NextResponse.json(results, {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'public, max-age=60, stale-while-revalidate=30',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching jenis pendaftaran status:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch jenis pendaftaran status data' },
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/api/mahasiswa/jenis-pendaftaran/route.ts
Normal file
61
app/api/mahasiswa/jenis-pendaftaran/route.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
import { RowDataPacket } from 'mysql2';
|
||||||
|
|
||||||
|
interface JenisPendaftaran extends RowDataPacket {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
jenis_pendaftaran: string;
|
||||||
|
jumlah: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tahunAngkatan = searchParams.get('tahun_angkatan');
|
||||||
|
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
let query = `
|
||||||
|
SELECT tahun_angkatan, jenis_pendaftaran, COUNT(*) AS jumlah
|
||||||
|
FROM mahasiswa
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: any[] = [];
|
||||||
|
|
||||||
|
if (tahunAngkatan && tahunAngkatan !== 'all') {
|
||||||
|
query += ` WHERE tahun_angkatan = ?`;
|
||||||
|
params.push(tahunAngkatan);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += `
|
||||||
|
GROUP BY tahun_angkatan, jenis_pendaftaran
|
||||||
|
ORDER BY tahun_angkatan DESC, jenis_pendaftaran
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [results] = await connection.query<JenisPendaftaran[]>(query, params);
|
||||||
|
|
||||||
|
return NextResponse.json(results, {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'public, max-age=60, stale-while-revalidate=30',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching jenis pendaftaran:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch jenis pendaftaran data' },
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/api/mahasiswa/jenis-prestasi/route.ts
Normal file
21
app/api/mahasiswa/jenis-prestasi/route.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query(`
|
||||||
|
SELECT jenis_prestasi
|
||||||
|
FROM prestasi_mahasiswa
|
||||||
|
WHERE jenis_prestasi = 'Akademik' OR jenis_prestasi = 'Non-Akademik'
|
||||||
|
GROUP BY jenis_prestasi
|
||||||
|
ORDER BY jenis_prestasi ASC
|
||||||
|
`);
|
||||||
|
|
||||||
|
return NextResponse.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/api/mahasiswa/lulus-tepat-waktu/route.ts
Normal file
51
app/api/mahasiswa/lulus-tepat-waktu/route.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
|
||||||
|
interface LulusTepatWaktu {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
jk: string;
|
||||||
|
jumlah_lulus_tepat_waktu: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tahunAngkatan = searchParams.get('tahun_angkatan');
|
||||||
|
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
m.tahun_angkatan,
|
||||||
|
m.jk,
|
||||||
|
COUNT(m.nim) AS jumlah_lulus_tepat_waktu
|
||||||
|
FROM
|
||||||
|
mahasiswa m
|
||||||
|
JOIN
|
||||||
|
status_mahasiswa s ON m.nim = s.nim
|
||||||
|
WHERE
|
||||||
|
s.status_kuliah = 'Lulus'
|
||||||
|
AND s.semester <= 8
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: any[] = [];
|
||||||
|
|
||||||
|
if (tahunAngkatan && tahunAngkatan !== 'all') {
|
||||||
|
query += ' AND m.tahun_angkatan = ?';
|
||||||
|
params.push(tahunAngkatan);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += `
|
||||||
|
GROUP BY
|
||||||
|
m.tahun_angkatan, m.jk
|
||||||
|
ORDER BY
|
||||||
|
m.tahun_angkatan DESC, m.jk
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [rows] = await pool.query(query, params);
|
||||||
|
return NextResponse.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/api/mahasiswa/nama-beasiswa/route.ts
Normal file
48
app/api/mahasiswa/nama-beasiswa/route.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tahunAngkatan = searchParams.get('tahunAngkatan');
|
||||||
|
const jenisBeasiswa = searchParams.get('jenisBeasiswa');
|
||||||
|
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
m.tahun_angkatan,
|
||||||
|
s.nama_beasiswa,
|
||||||
|
COUNT(m.nim) AS jumlah_nama_beasiswa
|
||||||
|
FROM
|
||||||
|
mahasiswa m
|
||||||
|
JOIN
|
||||||
|
beasiswa_mahasiswa s ON m.nim = s.nim
|
||||||
|
WHERE
|
||||||
|
s.jenis_beasiswa = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (tahunAngkatan && tahunAngkatan !== 'all') {
|
||||||
|
query += ` AND m.tahun_angkatan = ?`;
|
||||||
|
}
|
||||||
|
|
||||||
|
query += `
|
||||||
|
GROUP BY
|
||||||
|
m.tahun_angkatan, s.nama_beasiswa, s.jenis_beasiswa
|
||||||
|
ORDER BY
|
||||||
|
m.tahun_angkatan DESC, s.nama_beasiswa, s.jenis_beasiswa
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params = [jenisBeasiswa];
|
||||||
|
if (tahunAngkatan && tahunAngkatan !== 'all') {
|
||||||
|
params.push(tahunAngkatan);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows] = await pool.query(query, params);
|
||||||
|
return NextResponse.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching data:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
app/api/mahasiswa/profile/route.ts
Normal file
91
app/api/mahasiswa/profile/route.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
import { RowDataPacket } from 'mysql2';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { jwtVerify } from 'jose';
|
||||||
|
|
||||||
|
interface MahasiswaProfile extends RowDataPacket {
|
||||||
|
nim: string;
|
||||||
|
nama: string;
|
||||||
|
jk: 'Pria' | 'Wanita';
|
||||||
|
agama: string;
|
||||||
|
kabupaten: string;
|
||||||
|
provinsi: string;
|
||||||
|
jenis_pendaftaran: string;
|
||||||
|
status_beasiswa: 'YA' | 'TIDAK';
|
||||||
|
tahun_angkatan: string;
|
||||||
|
ipk: number | null;
|
||||||
|
prestasi: 'YA' | 'TIDAK';
|
||||||
|
status_kuliah: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
let connection;
|
||||||
|
try {
|
||||||
|
// Get token from cookies
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get('token')?.value;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify JWT token
|
||||||
|
const { payload } = await jwtVerify(
|
||||||
|
token,
|
||||||
|
new TextEncoder().encode(process.env.JWT_SECRET || 'your-secret-key')
|
||||||
|
);
|
||||||
|
|
||||||
|
const nim = payload.nim as string;
|
||||||
|
|
||||||
|
// Get connection from pool
|
||||||
|
connection = await pool.getConnection();
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
m.nim,
|
||||||
|
m.nama,
|
||||||
|
m.jk,
|
||||||
|
m.agama,
|
||||||
|
m.kabupaten,
|
||||||
|
m.provinsi,
|
||||||
|
m.jenis_pendaftaran,
|
||||||
|
m.status_beasiswa,
|
||||||
|
m.tahun_angkatan,
|
||||||
|
m.ipk,
|
||||||
|
m.prestasi,
|
||||||
|
s.status_kuliah
|
||||||
|
FROM
|
||||||
|
mahasiswa m
|
||||||
|
LEFT JOIN
|
||||||
|
status_mahasiswa s ON m.nim = s.nim
|
||||||
|
WHERE
|
||||||
|
m.nim = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [rows] = await connection.query<MahasiswaProfile[]>(query, [nim]);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
connection.release();
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Data mahasiswa tidak ditemukan' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.release();
|
||||||
|
return NextResponse.json(rows[0]);
|
||||||
|
} catch (error) {
|
||||||
|
if (connection) {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
console.error('Error fetching profile data:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
app/api/mahasiswa/statistik/route.ts
Normal file
65
app/api/mahasiswa/statistik/route.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
import { RowDataPacket } from 'mysql2';
|
||||||
|
|
||||||
|
interface MahasiswaStatistik extends RowDataPacket {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
total_mahasiswa: number;
|
||||||
|
pria: number;
|
||||||
|
wanita: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fungsi untuk menangani preflight request (OPTIONS)
|
||||||
|
export async function OPTIONS() {
|
||||||
|
return new NextResponse(null, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
'Access-Control-Max-Age': '86400', // 24 jam
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Query untuk mendapatkan statistik mahasiswa per tahun angkatan
|
||||||
|
const [results] = await connection.query<MahasiswaStatistik[]>(`
|
||||||
|
SELECT
|
||||||
|
tahun_angkatan,
|
||||||
|
COUNT(*) as total_mahasiswa,
|
||||||
|
SUM(CASE WHEN jk = 'Pria' THEN 1 ELSE 0 END) as pria,
|
||||||
|
SUM(CASE WHEN jk = 'Wanita' THEN 1 ELSE 0 END) as wanita
|
||||||
|
FROM mahasiswa
|
||||||
|
GROUP BY tahun_angkatan
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Menambahkan header cache dan CORS
|
||||||
|
return NextResponse.json(results, {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'public, max-age=60, stale-while-revalidate=30',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching mahasiswa statistik:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch mahasiswa statistik' },
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/api/mahasiswa/status-kuliah/route.ts
Normal file
63
app/api/mahasiswa/status-kuliah/route.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
import { RowDataPacket } from 'mysql2';
|
||||||
|
|
||||||
|
interface StatusKuliah extends RowDataPacket {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
status_kuliah: string;
|
||||||
|
jumlah: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tahunAngkatan = searchParams.get('tahun_angkatan');
|
||||||
|
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
let query = `
|
||||||
|
SELECT m.tahun_angkatan, s.status_kuliah, COUNT(*) AS jumlah
|
||||||
|
FROM mahasiswa m
|
||||||
|
JOIN status_mahasiswa s ON m.nim = s.nim
|
||||||
|
WHERE s.status_kuliah IN ('Lulus', 'Cuti', 'Aktif', 'DO')
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: any[] = [];
|
||||||
|
|
||||||
|
if (tahunAngkatan && tahunAngkatan !== 'all') {
|
||||||
|
query += ` AND m.tahun_angkatan = ?`;
|
||||||
|
params.push(tahunAngkatan);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += `
|
||||||
|
GROUP BY m.tahun_angkatan, s.status_kuliah
|
||||||
|
ORDER BY m.tahun_angkatan, s.status_kuliah
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [results] = await connection.query<StatusKuliah[]>(query, params);
|
||||||
|
|
||||||
|
return NextResponse.json(results, {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'public, max-age=60, stale-while-revalidate=30',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching status kuliah:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch status kuliah data' },
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
76
app/api/mahasiswa/status-mahasiswa/route.ts
Normal file
76
app/api/mahasiswa/status-mahasiswa/route.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
import { RowDataPacket } from 'mysql2';
|
||||||
|
|
||||||
|
interface StatusMahasiswa extends RowDataPacket {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
jk: string;
|
||||||
|
total_mahasiswa: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tahunAngkatan = searchParams.get('tahun_angkatan');
|
||||||
|
const statusKuliah = searchParams.get('status_kuliah');
|
||||||
|
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
m.tahun_angkatan,
|
||||||
|
CASE
|
||||||
|
WHEN m.jk = 'Pria' THEN 'L'
|
||||||
|
WHEN m.jk = 'Wanita' THEN 'P'
|
||||||
|
ELSE m.jk
|
||||||
|
END as jk,
|
||||||
|
COUNT(m.nim) AS total_mahasiswa
|
||||||
|
FROM
|
||||||
|
mahasiswa m
|
||||||
|
JOIN
|
||||||
|
status_mahasiswa s ON m.nim = s.nim
|
||||||
|
WHERE
|
||||||
|
s.status_kuliah = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: any[] = [statusKuliah];
|
||||||
|
|
||||||
|
if (tahunAngkatan && tahunAngkatan !== 'all') {
|
||||||
|
query += ` AND m.tahun_angkatan = ?`;
|
||||||
|
params.push(tahunAngkatan);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += `
|
||||||
|
GROUP BY
|
||||||
|
m.tahun_angkatan, m.jk
|
||||||
|
ORDER BY
|
||||||
|
m.tahun_angkatan DESC, m.jk
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [results] = await connection.query<StatusMahasiswa[]>(query, params);
|
||||||
|
|
||||||
|
return NextResponse.json(results, {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'public, max-age=60, stale-while-revalidate=30',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching status mahasiswa:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch status mahasiswa data' },
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/api/mahasiswa/status/route.ts
Normal file
23
app/api/mahasiswa/status/route.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import db from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const query = `
|
||||||
|
SELECT m.tahun_angkatan, s.status_kuliah, COUNT(*) AS jumlah
|
||||||
|
FROM mahasiswa m
|
||||||
|
JOIN status_mahasiswa s ON m.nim = s.nim
|
||||||
|
WHERE s.status_kuliah IN ('Lulus', 'Cuti', 'Aktif', 'DO')
|
||||||
|
GROUP BY m.tahun_angkatan, s.status_kuliah;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [rows] = await db.query(query);
|
||||||
|
return NextResponse.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching status data:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch status data' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/api/mahasiswa/tahun-angkatan/route.ts
Normal file
41
app/api/mahasiswa/tahun-angkatan/route.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const [results] = await connection.query(`
|
||||||
|
SELECT DISTINCT tahun_angkatan
|
||||||
|
FROM mahasiswa
|
||||||
|
WHERE tahun_angkatan >= ?
|
||||||
|
ORDER BY tahun_angkatan DESC
|
||||||
|
LIMIT 7
|
||||||
|
`, [currentYear - 6]);
|
||||||
|
|
||||||
|
return NextResponse.json(results, {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'public, max-age=60, stale-while-revalidate=30',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching tahun angkatan:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch tahun angkatan data' },
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/api/mahasiswa/tingkat-prestasi/route.ts
Normal file
35
app/api/mahasiswa/tingkat-prestasi/route.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const jenisPrestasi = searchParams.get('jenisPrestasi');
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
m.tahun_angkatan,
|
||||||
|
s.tingkat,
|
||||||
|
COUNT(m.nim) AS tingkat_mahasiswa_prestasi
|
||||||
|
FROM
|
||||||
|
mahasiswa m
|
||||||
|
JOIN
|
||||||
|
prestasi_mahasiswa s ON m.nim = s.nim
|
||||||
|
WHERE
|
||||||
|
s.jenis_prestasi = ?
|
||||||
|
GROUP BY
|
||||||
|
m.tahun_angkatan, s.tingkat
|
||||||
|
ORDER BY
|
||||||
|
m.tahun_angkatan DESC, s.tingkat
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [rows] = await pool.query(query, [jenisPrestasi]);
|
||||||
|
return NextResponse.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching data:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/api/mahasiswa/total-beasiswa/route.ts
Normal file
48
app/api/mahasiswa/total-beasiswa/route.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tahunAngkatan = searchParams.get('tahunAngkatan');
|
||||||
|
const jenisBeasiswa = searchParams.get('jenisBeasiswa');
|
||||||
|
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
m.tahun_angkatan,
|
||||||
|
m.jk,
|
||||||
|
COUNT(m.nim) AS jumlah_mahasiswa_beasiswa
|
||||||
|
FROM
|
||||||
|
mahasiswa m
|
||||||
|
JOIN
|
||||||
|
beasiswa_mahasiswa s ON m.nim = s.nim
|
||||||
|
WHERE
|
||||||
|
s.jenis_beasiswa = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (tahunAngkatan && tahunAngkatan !== 'all') {
|
||||||
|
query += ` AND m.tahun_angkatan = ?`;
|
||||||
|
}
|
||||||
|
|
||||||
|
query += `
|
||||||
|
GROUP BY
|
||||||
|
m.tahun_angkatan, m.jk
|
||||||
|
ORDER BY
|
||||||
|
m.tahun_angkatan DESC, m.jk
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params = [jenisBeasiswa];
|
||||||
|
if (tahunAngkatan && tahunAngkatan !== 'all') {
|
||||||
|
params.push(tahunAngkatan);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows] = await pool.query(query, params);
|
||||||
|
return NextResponse.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching data:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/api/mahasiswa/total-prestasi/route.ts
Normal file
35
app/api/mahasiswa/total-prestasi/route.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const jenisPrestasi = searchParams.get('jenisPrestasi');
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
m.tahun_angkatan,
|
||||||
|
m.jk,
|
||||||
|
COUNT(m.nim) AS jumlah_mahasiswa_prestasi
|
||||||
|
FROM
|
||||||
|
mahasiswa m
|
||||||
|
JOIN
|
||||||
|
prestasi_mahasiswa s ON m.nim = s.nim
|
||||||
|
WHERE
|
||||||
|
s.jenis_prestasi = ?
|
||||||
|
GROUP BY
|
||||||
|
m.tahun_angkatan, m.jk
|
||||||
|
ORDER BY
|
||||||
|
m.tahun_angkatan DESC, m.jk
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [rows] = await pool.query(query, [jenisPrestasi]);
|
||||||
|
return NextResponse.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching data:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
app/api/mahasiswa/total/route.ts
Normal file
91
app/api/mahasiswa/total/route.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import pool from '@/lib/db';
|
||||||
|
import { RowDataPacket } from 'mysql2';
|
||||||
|
|
||||||
|
interface MahasiswaTotal extends RowDataPacket {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fungsi untuk menangani preflight request (OPTIONS)
|
||||||
|
export async function OPTIONS() {
|
||||||
|
return new NextResponse(null, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
'Access-Control-Max-Age': '86400', // 24 jam
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Query gabungan untuk semua data
|
||||||
|
const [results] = await connection.query<MahasiswaTotal[]>(`
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM mahasiswa) AS total_mahasiswa,
|
||||||
|
(SELECT COUNT(*) FROM mahasiswa m
|
||||||
|
JOIN status_mahasiswa s ON m.nim = s.nim
|
||||||
|
WHERE s.status_kuliah = 'Aktif') AS mahasiswa_aktif,
|
||||||
|
(SELECT COUNT(*) FROM mahasiswa m
|
||||||
|
JOIN status_mahasiswa s ON m.nim = s.nim
|
||||||
|
WHERE s.status_kuliah = 'Lulus') AS total_lulus,
|
||||||
|
(SELECT COUNT(*) FROM mahasiswa m
|
||||||
|
JOIN status_mahasiswa s ON m.nim = s.nim
|
||||||
|
WHERE s.status_kuliah = 'Lulus' AND m.jk = 'Pria') AS pria_lulus,
|
||||||
|
(SELECT COUNT(*) FROM mahasiswa m
|
||||||
|
JOIN status_mahasiswa s ON m.nim = s.nim
|
||||||
|
WHERE s.status_kuliah = 'Lulus' AND m.jk = 'Wanita') AS wanita_lulus,
|
||||||
|
(SELECT COUNT(*) FROM prestasi_mahasiswa) AS total_berprestasi,
|
||||||
|
(SELECT COUNT(*) FROM prestasi_mahasiswa WHERE jenis_prestasi = 'Akademik') AS prestasi_akademik,
|
||||||
|
(SELECT COUNT(*) FROM prestasi_mahasiswa WHERE jenis_prestasi = 'Non-Akademik') AS prestasi_non_akademik,
|
||||||
|
(SELECT COUNT(*) FROM mahasiswa m
|
||||||
|
JOIN status_mahasiswa s ON m.nim = s.nim
|
||||||
|
WHERE s.status_kuliah IN ('Aktif', 'Lulus')) AS total_mahasiswa_aktif_lulus,
|
||||||
|
(SELECT ROUND(AVG(m.ipk), 2) FROM mahasiswa m
|
||||||
|
JOIN status_mahasiswa s ON m.nim = s.nim
|
||||||
|
WHERE s.status_kuliah = 'Aktif') AS ipk_rata_rata_aktif,
|
||||||
|
(SELECT ROUND(AVG(m.ipk), 2) FROM mahasiswa m
|
||||||
|
JOIN status_mahasiswa s ON m.nim = s.nim
|
||||||
|
WHERE s.status_kuliah = 'Lulus') AS ipk_rata_rata_lulus
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Menambahkan header cache dan CORS
|
||||||
|
return NextResponse.json(results[0], {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'public, max-age=60, stale-while-revalidate=30',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching total mahasiswa:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch total mahasiswa' },
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
126
app/globals.css
126
app/globals.css
@@ -1,26 +1,122 @@
|
|||||||
@import "tailwindcss";
|
@import 'tailwindcss';
|
||||||
|
@import 'tw-animate-css';
|
||||||
|
|
||||||
:root {
|
@custom-variant dark (&:is(.dark *));
|
||||||
--background: #ffffff;
|
|
||||||
--foreground: #171717;
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
:root {
|
||||||
:root {
|
--radius: 0.625rem;
|
||||||
--background: #0a0a0a;
|
--background: oklch(1 0 0);
|
||||||
--foreground: #ededed;
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: rgb(0, 0, 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: rgb(16, 39, 73);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.205 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(1 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: rgb(12, 28, 52);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: rgb(22, 50, 91);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
|
||||||
background: var(--background);
|
|
||||||
color: var(--foreground);
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,33 +1,60 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from 'next';
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from 'next/font/google';
|
||||||
import "./globals.css";
|
import './globals.css';
|
||||||
|
import Navbar from '@/components/ui/Navbar';
|
||||||
|
import Sidebar from '@/components/ui/Sidebar';
|
||||||
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
|
import { Toaster } from '@/components/ui/toaster';
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: '--font-geist-sans',
|
||||||
subsets: ["latin"],
|
subsets: ['latin'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
const geistMono = Geist_Mono({
|
||||||
variable: "--font-geist-mono",
|
variable: '--font-geist-mono',
|
||||||
subsets: ["latin"],
|
subsets: ['latin'],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: 'Portal Data Informatika',
|
||||||
description: "Generated by create next app",
|
description: 'Admin Dashboard',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function ClientLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
<div className="relative flex min-h-screen flex-col">
|
||||||
|
<Navbar />
|
||||||
|
<div className="flex flex-1">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex-1 md:ml-[250px] p-2">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Toaster />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body
|
<head>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
<link rel="icon" type="image/png" href="/podif-icon.png" />
|
||||||
>
|
</head>
|
||||||
{children}
|
<body suppressHydrationWarning className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen`}>
|
||||||
|
<ClientLayout>{children}</ClientLayout>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
94
app/mahasiswa/beasiswa/page.tsx
Normal file
94
app/mahasiswa/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/TotalBeasiswaChart";
|
||||||
|
import TotalBeasiswaPieChart from "@/components/TotalBeasiswaPieChart";
|
||||||
|
import NamaBeasiswaChart from "@/components/NamaBeasiswaChart";
|
||||||
|
import NamaBeasiswaPieChart from "@/components/NamaBeasiswaPieChart";
|
||||||
|
import JenisPendaftaranBeasiswaChart from "@/components/JenisPendaftaranBeasiswaChart";
|
||||||
|
import JenisPendaftaranBeasiswaPieChart from "@/components/JenisPendaftaranBeasiswaPieChart";
|
||||||
|
import AsalDaerahBeasiswaChart from "@/components/AsalDaerahBeasiswaChart";
|
||||||
|
import IPKBeasiswaChart from "@/components/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/mahasiswa/berprestasi/page.tsx
Normal file
80
app/mahasiswa/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/TotalPrestasiChart";
|
||||||
|
import TotalPrestasiPieChart from "@/components/TotalPrestasiPieChart";
|
||||||
|
import TingkatPrestasiChart from "@/components/TingkatPrestasiChart";
|
||||||
|
import TingkatPrestasiPieChart from "@/components/TingkatPrestasiPieChart";
|
||||||
|
import JenisPendaftaranPrestasiChart from "@/components/JenisPendaftaranPrestasiChart";
|
||||||
|
import JenisPendaftaranPrestasiPieChart from "@/components/JenisPendaftaranPrestasiPieChart";
|
||||||
|
import AsalDaerahPrestasiChart from "@/components/AsalDaerahPrestasiChart";
|
||||||
|
import IPKPrestasiChart from "@/components/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>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
app/mahasiswa/lulustepatwaktu/page.tsx
Normal file
61
app/mahasiswa/lulustepatwaktu/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/LulusTepatWaktuChart";
|
||||||
|
import LulusTepatWaktuPieChart from "@/components/LulusTepatWaktuPieChart";
|
||||||
|
import JenisPendaftaranLulusChart from "@/components/JenisPendaftaranLulusChart";
|
||||||
|
import JenisPendaftaranLulusPieChart from "@/components/JenisPendaftaranLulusPieChart";
|
||||||
|
import AsalDaerahLulusChart from "@/components/AsalDaerahLulusChart";
|
||||||
|
import IPKLulusTepatChart from "@/components/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>
|
||||||
|
);
|
||||||
|
}
|
||||||
171
app/mahasiswa/profile/page.tsx
Normal file
171
app/mahasiswa/profile/page.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { User } from 'lucide-react';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
|
||||||
|
interface MahasiswaProfile {
|
||||||
|
nim: string;
|
||||||
|
nama: string;
|
||||||
|
jk: 'Pria' | 'Wanita';
|
||||||
|
agama: string;
|
||||||
|
kabupaten: string;
|
||||||
|
provinsi: string;
|
||||||
|
jenis_pendaftaran: string;
|
||||||
|
status_beasiswa: 'YA' | 'TIDAK';
|
||||||
|
tahun_angkatan: string;
|
||||||
|
ipk: number | null;
|
||||||
|
prestasi: 'YA' | 'TIDAK';
|
||||||
|
status_kuliah: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const [profile, setProfile] = useState<MahasiswaProfile | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const { toast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchProfile = async () => {
|
||||||
|
try {
|
||||||
|
console.log('Fetching profile data...');
|
||||||
|
const response = await fetch('/api/mahasiswa/profile');
|
||||||
|
console.log('Profile response status:', response.status);
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Akses Ditolak",
|
||||||
|
description: "Silakan login terlebih dahulu untuk mengakses halaman ini.",
|
||||||
|
});
|
||||||
|
router.push('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || 'Failed to fetch profile data');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('Profile data received:', data);
|
||||||
|
setProfile(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching profile:', error);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error",
|
||||||
|
description: error instanceof Error ? error.message : "Gagal memuat data profil. Silakan coba lagi nanti.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchProfile();
|
||||||
|
}, [toast, router]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-4 max-w-2xl">
|
||||||
|
<Card className="border shadow-sm bg-white dark:bg-slate-900">
|
||||||
|
<CardHeader className="border-b py-3 bg-white dark:bg-slate-900 text-black dark:text-white">
|
||||||
|
<CardTitle className="text-lg">Profil Mahasiswa</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[...Array(8)].map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<Skeleton className="h-3 w-[80px]" />
|
||||||
|
<Skeleton className="h-3 w-[140px]" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-4 max-w-2xl">
|
||||||
|
<Card className="border shadow-sm">
|
||||||
|
<CardHeader className="border-b bg-muted/30 py-3">
|
||||||
|
<CardTitle className="text-lg">Profil Mahasiswa</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="text-center text-muted-foreground text-sm py-4">
|
||||||
|
Data profil tidak tersedia
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format IPK value
|
||||||
|
const formatIPK = (ipk: number | null): string => {
|
||||||
|
if (ipk === null || ipk === undefined) return '-';
|
||||||
|
return Number(ipk).toFixed(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-4 w-full">
|
||||||
|
<Card className="gap-0 bg-white dark:bg-slate-900 border shadow-sm">
|
||||||
|
<CardHeader className="border-b py-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||||
|
<User className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base font-medium text-black dark:text-white">{profile.nama}</CardTitle>
|
||||||
|
<p className="text-xs text-muted-foreground">{profile.nim}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="py-2 px-4">
|
||||||
|
<div className="space-y-1 text-sm text-black dark:text-white">
|
||||||
|
<div className="py-1.5 border-b">
|
||||||
|
<div className="text-muted-foreground">Jenis Kelamin</div>
|
||||||
|
<div>{profile.jk}</div>
|
||||||
|
</div>
|
||||||
|
<div className="py-1.5 border-b">
|
||||||
|
<div className="text-muted-foreground">Agama</div>
|
||||||
|
<div>{profile.agama}</div>
|
||||||
|
</div>
|
||||||
|
<div className="py-1.5 border-b">
|
||||||
|
<div className="text-muted-foreground">Kabupaten</div>
|
||||||
|
<div>{profile.kabupaten}</div>
|
||||||
|
</div>
|
||||||
|
<div className="py-1.5 border-b">
|
||||||
|
<div className="text-muted-foreground">Provinsi</div>
|
||||||
|
<div>{profile.provinsi}</div>
|
||||||
|
</div>
|
||||||
|
<div className="py-1.5 border-b">
|
||||||
|
<div className="text-muted-foreground">Jenis Pendaftaran</div>
|
||||||
|
<div>{profile.jenis_pendaftaran}</div>
|
||||||
|
</div>
|
||||||
|
<div className="py-1.5 border-b">
|
||||||
|
<div className="text-muted-foreground">Tahun Angkatan</div>
|
||||||
|
<div>{profile.tahun_angkatan}</div>
|
||||||
|
</div>
|
||||||
|
<div className="py-1.5 border-b">
|
||||||
|
<div className="text-muted-foreground">IPK</div>
|
||||||
|
<div>{formatIPK(profile.ipk)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="py-1.5">
|
||||||
|
<div className="text-muted-foreground">Status Kuliah</div>
|
||||||
|
<div>{profile.status_kuliah || '-'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
app/mahasiswa/status/page.tsx
Normal file
86
app/mahasiswa/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/StatusMahasiswaFilterChart";
|
||||||
|
import StatusMahasiswaFilterPieChart from "@/components/StatusMahasiswaFilterPieChart";
|
||||||
|
import JenisPendaftaranStatusChart from "@/components/JenisPendaftaranStatusChart";
|
||||||
|
import JenisPendaftaranStatusPieChart from "@/components/JenisPendaftaranStatusPieChart";
|
||||||
|
import AsalDaerahStatusChart from '@/components/AsalDaerahStatusChart';
|
||||||
|
import IpkStatusChart from '@/components/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>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
app/mahasiswa/total/page.tsx
Normal file
60
app/mahasiswa/total/page.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import StatistikMahasiswaChart from "@/components/StatistikMahasiswaChart";
|
||||||
|
import StatistikPerAngkatanChart from "@/components/StatistikPerAngkatanChart";
|
||||||
|
import JenisPendaftaranChart from "@/components/JenisPendaftaranChart";
|
||||||
|
import AsalDaerahChart from "@/components/AsalDaerahChart";
|
||||||
|
import IPKChart from "@/components/IPKChart";
|
||||||
|
import FilterTahunAngkatan from "@/components/FilterTahunAngkatan";
|
||||||
|
import JenisPendaftaranPerAngkatanChart from "@/components/JenisPendaftaranPerAngkatanChart";
|
||||||
|
import AsalDaerahPerAngkatanChart from "@/components/AsalDaerahPerAngkatanChart";
|
||||||
|
import IPKJenisKelaminChart from "@/components/IPKJenisKelaminChart";
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
309
app/page.tsx
309
app/page.tsx
@@ -1,103 +1,218 @@
|
|||||||
import Image from "next/image";
|
'use client';
|
||||||
|
|
||||||
|
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/StatusMahasiswaChart";
|
||||||
|
import StatistikMahasiswaChart from "@/components/StatistikMahasiswaChart";
|
||||||
|
import JenisPendaftaranChart from "@/components/JenisPendaftaranChart";
|
||||||
|
import AsalDaerahChart from "@/components/AsalDaerahChart";
|
||||||
|
import IPKChart from '@/components/IPKChart';
|
||||||
|
|
||||||
|
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 HomePage() {
|
||||||
|
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 [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (cachedData && isCacheValid) {
|
||||||
|
setMahasiswaData(JSON.parse(cachedData));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch data total mahasiswa
|
||||||
|
const totalResponse = await fetch('/api/mahasiswa/total', {
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
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(err instanceof Error ? err.message : 'An error occurred');
|
||||||
|
console.error('Error fetching data:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
<div className="container mx-auto p-4 space-y-6">
|
||||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
<h1 className="text-3xl font-bold mb-8">Dashboard Portal Data Informatika</h1>
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
{loading ? (
|
||||||
src="/next.svg"
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||||
alt="Next.js logo"
|
<CardSkeleton />
|
||||||
width={180}
|
<CardSkeleton />
|
||||||
height={38}
|
<CardSkeleton />
|
||||||
priority
|
<CardSkeleton />
|
||||||
/>
|
|
||||||
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
|
||||||
<li className="mb-2 tracking-[-.01em]">
|
|
||||||
Get started by editing{" "}
|
|
||||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
|
|
||||||
app/page.tsx
|
|
||||||
</code>
|
|
||||||
.
|
|
||||||
</li>
|
|
||||||
<li className="tracking-[-.01em]">
|
|
||||||
Save and see your changes instantly.
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
/>
|
|
||||||
Deploy now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Read our docs
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
) : error ? (
|
||||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-4">
|
||||||
<a
|
<div className="flex">
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
<div className="flex-shrink-0">
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<svg className="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
|
||||||
target="_blank"
|
<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" />
|
||||||
rel="noopener noreferrer"
|
</svg>
|
||||||
>
|
</div>
|
||||||
<Image
|
<div className="ml-3">
|
||||||
aria-hidden
|
<p className="text-sm text-red-700">{error}</p>
|
||||||
src="/file.svg"
|
</div>
|
||||||
alt="File icon"
|
</div>
|
||||||
width={16}
|
</div>
|
||||||
height={16}
|
) : (
|
||||||
/>
|
<>
|
||||||
Learn
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4 mb-8">
|
||||||
</a>
|
{/* Kartu Total Mahasiswa */}
|
||||||
<a
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<CardTitle className="text-sm font-medium dark:text-white">
|
||||||
target="_blank"
|
Total Mahasiswa
|
||||||
rel="noopener noreferrer"
|
</CardTitle>
|
||||||
>
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
<Image
|
</CardHeader>
|
||||||
aria-hidden
|
<CardContent>
|
||||||
src="/window.svg"
|
<div className="text-2xl font-bold dark:text-white">{mahasiswaData.total_mahasiswa}</div>
|
||||||
alt="Window icon"
|
<div className="flex justify-between mt-2 text-xs text-muted-foreground">
|
||||||
width={16}
|
<span className="dark:text-white">Aktif: <span className="text-green-500">{mahasiswaData.mahasiswa_aktif}</span></span>
|
||||||
height={16}
|
</div>
|
||||||
/>
|
</CardContent>
|
||||||
Examples
|
</Card>
|
||||||
</a>
|
|
||||||
<a
|
{/* Kartu Total Kelulusan */}
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
target="_blank"
|
<CardTitle className="text-sm font-medium dark:text-white">
|
||||||
rel="noopener noreferrer"
|
Total Kelulusan
|
||||||
>
|
</CardTitle>
|
||||||
<Image
|
<GraduationCap className="h-4 w-4 text-muted-foreground" />
|
||||||
aria-hidden
|
</CardHeader>
|
||||||
src="/globe.svg"
|
<CardContent>
|
||||||
alt="Globe icon"
|
<div className="text-2xl font-bold dark:text-white">{mahasiswaData.total_lulus}</div>
|
||||||
width={16}
|
<div className="flex justify-between mt-2 text-xs text-muted-foreground">
|
||||||
height={16}
|
<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>
|
||||||
Go to nextjs.org →
|
</div>
|
||||||
</a>
|
</CardContent>
|
||||||
</footer>
|
</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.total_mahasiswa_aktif_lulus}</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>
|
||||||
|
<span className="dark:text-white">Lulus: <span className="text-blue-500">{mahasiswaData.ipk_rata_rata_lulus}</span></span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Diagram Statistik Mahasiswa */}
|
||||||
|
<StatistikMahasiswaChart />
|
||||||
|
|
||||||
|
{/* Diagram Status Mahasiswa */}
|
||||||
|
<StatusMahasiswaChart />
|
||||||
|
|
||||||
|
{/* Diagram Jenis Pendaftaran */}
|
||||||
|
<JenisPendaftaranChart />
|
||||||
|
|
||||||
|
{/* Diagram Asal Daerah */}
|
||||||
|
<AsalDaerahChart />
|
||||||
|
|
||||||
|
{/* Diagram IPK */}
|
||||||
|
<IPKChart />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
21
components.json
Normal file
21
components.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
237
components/AsalDaerahBeasiswaChart.tsx
Normal file
237
components/AsalDaerahBeasiswaChart.tsx
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface AsalDaerahBeasiswaData {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
kabupaten: string;
|
||||||
|
jumlah_mahasiswa: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedYear: string;
|
||||||
|
selectedJenisBeasiswa: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AsalDaerahBeasiswaChart({ selectedYear, selectedJenisBeasiswa }: Props) {
|
||||||
|
const { theme, systemTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [data, setData] = useState<AsalDaerahBeasiswaData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const url = `/api/mahasiswa/asal-daerah-beasiswa?tahunAngkatan=${selectedYear}&jenisBeasiswa=${selectedJenisBeasiswa}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(result)) {
|
||||||
|
throw new Error('Invalid data format received from server');
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in fetchData:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [selectedYear, selectedJenisBeasiswa]);
|
||||||
|
|
||||||
|
const chartOptions: ApexOptions = {
|
||||||
|
chart: {
|
||||||
|
type: 'bar',
|
||||||
|
stacked: false,
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
tools: {
|
||||||
|
download: true,
|
||||||
|
selection: true,
|
||||||
|
zoom: true,
|
||||||
|
zoomin: true,
|
||||||
|
zoomout: true,
|
||||||
|
pan: true,
|
||||||
|
reset: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
background: theme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
},
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
horizontal: true,
|
||||||
|
columnWidth: '85%',
|
||||||
|
distributed: false,
|
||||||
|
barHeight: '90%',
|
||||||
|
dataLabels: {
|
||||||
|
position: 'top'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toString();
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
colors: [theme === 'dark' ? '#fff' : '#000']
|
||||||
|
},
|
||||||
|
offsetX: 10,
|
||||||
|
},
|
||||||
|
stroke: {
|
||||||
|
show: true,
|
||||||
|
width: 1,
|
||||||
|
colors: [theme === 'dark' ? '#374151' : '#E5E7EB'],
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: [...new Set(data.map(item => item.kabupaten))].sort(),
|
||||||
|
title: {
|
||||||
|
text: 'Jumlah Mahasiswa',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
title: {
|
||||||
|
text: 'Kabupaten',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
},
|
||||||
|
maxWidth: 200,
|
||||||
|
},
|
||||||
|
tickAmount: undefined,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
padding: {
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
colors: ['#3B82F6'],
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const series = [{
|
||||||
|
name: 'Jumlah Mahasiswa',
|
||||||
|
data: [...new Set(data.map(item => item.kabupaten))].sort().map(kabupaten => {
|
||||||
|
const item = data.find(d => d.kabupaten === kabupaten);
|
||||||
|
return item ? item.jumlah_mahasiswa : 0;
|
||||||
|
})
|
||||||
|
}];
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Asal Daerah Mahasiswa Beasiswa {selectedJenisBeasiswa}
|
||||||
|
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[600px] w-full">
|
||||||
|
<Chart
|
||||||
|
options={chartOptions}
|
||||||
|
series={series}
|
||||||
|
type="bar"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
286
components/AsalDaerahChart.tsx
Normal file
286
components/AsalDaerahChart.tsx
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface AsalDaerahData {
|
||||||
|
kabupaten: string;
|
||||||
|
jumlah: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AsalDaerahChart() {
|
||||||
|
const { theme, systemTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [data, setData] = useState<AsalDaerahData[]>([]);
|
||||||
|
const [series, setSeries] = useState<ApexAxisChartSeries>([{
|
||||||
|
name: 'Jumlah Mahasiswa',
|
||||||
|
data: []
|
||||||
|
}]);
|
||||||
|
const [options, setOptions] = useState<ApexOptions>({
|
||||||
|
chart: {
|
||||||
|
type: 'bar',
|
||||||
|
stacked: false,
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
horizontal: true,
|
||||||
|
columnWidth: '85%',
|
||||||
|
distributed: false,
|
||||||
|
barHeight: '90%',
|
||||||
|
dataLabels: {
|
||||||
|
position: 'top',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toString();
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
colors: [theme === 'dark' ? '#fff' : '#000']
|
||||||
|
},
|
||||||
|
offsetX: 10,
|
||||||
|
},
|
||||||
|
stroke: {
|
||||||
|
show: true,
|
||||||
|
width: 1,
|
||||||
|
colors: [theme === 'dark' ? '#374151' : '#E5E7EB'],
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: [],
|
||||||
|
title: {
|
||||||
|
text: 'Jumlah Mahasiswa',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
title: {
|
||||||
|
text: 'Kabupaten',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
},
|
||||||
|
maxWidth: 200,
|
||||||
|
},
|
||||||
|
tickAmount: undefined,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
padding: {
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
colors: ['#3B82F6'], // Blue color for bars
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update theme when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
const currentTheme = theme === 'system' ? systemTheme : theme;
|
||||||
|
setOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
chart: {
|
||||||
|
...prev.chart,
|
||||||
|
background: currentTheme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
...prev.dataLabels,
|
||||||
|
style: {
|
||||||
|
...prev.dataLabels?.style,
|
||||||
|
colors: [currentTheme === 'dark' ? '#fff' : '#000']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
...prev.xaxis,
|
||||||
|
title: {
|
||||||
|
...prev.xaxis?.title,
|
||||||
|
style: {
|
||||||
|
...prev.xaxis?.title?.style,
|
||||||
|
color: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
...prev.xaxis?.labels,
|
||||||
|
style: {
|
||||||
|
...prev.xaxis?.labels?.style,
|
||||||
|
colors: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
...prev.yaxis,
|
||||||
|
title: {
|
||||||
|
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title : prev.yaxis?.title),
|
||||||
|
style: {
|
||||||
|
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title?.style : prev.yaxis?.title?.style),
|
||||||
|
color: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels : prev.yaxis?.labels),
|
||||||
|
style: {
|
||||||
|
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels?.style : prev.yaxis?.labels?.style),
|
||||||
|
colors: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
...prev.tooltip,
|
||||||
|
theme: currentTheme === 'dark' ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}, [theme, systemTheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await fetch('/api/mahasiswa/asal-daerah');
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(result)) {
|
||||||
|
throw new Error('Invalid data format received from server');
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(result);
|
||||||
|
|
||||||
|
// Process data for chart
|
||||||
|
const kabupaten = result.map(item => item.kabupaten);
|
||||||
|
const jumlah = result.map(item => item.jumlah);
|
||||||
|
|
||||||
|
setSeries([{
|
||||||
|
name: 'Jumlah Mahasiswa',
|
||||||
|
data: jumlah
|
||||||
|
}]);
|
||||||
|
setOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
xaxis: {
|
||||||
|
...prev.xaxis,
|
||||||
|
categories: kabupaten,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in fetchData:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500 dark:text-red-400">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Asal Daerah Mahasiswa
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[600px] w-full">
|
||||||
|
<Chart
|
||||||
|
options={options}
|
||||||
|
series={series}
|
||||||
|
type="bar"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
279
components/AsalDaerahLulusChart.tsx
Normal file
279
components/AsalDaerahLulusChart.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface AsalDaerahLulusChartProps {
|
||||||
|
selectedYear: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChartData {
|
||||||
|
tahun_angkatan: string;
|
||||||
|
kabupaten: string;
|
||||||
|
jumlah_lulus_tepat_waktu: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AsalDaerahLulusChart({ selectedYear }: AsalDaerahLulusChartProps) {
|
||||||
|
const { theme, systemTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [chartData, setChartData] = useState<ChartData[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [series, setSeries] = useState<ApexAxisChartSeries>([{
|
||||||
|
name: 'Jumlah Lulus Tepat Waktu',
|
||||||
|
data: []
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const [options, setOptions] = useState<ApexOptions>({
|
||||||
|
chart: {
|
||||||
|
type: 'bar',
|
||||||
|
stacked: false,
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
},
|
||||||
|
background: theme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
},
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
horizontal: true,
|
||||||
|
columnWidth: '55%',
|
||||||
|
dataLabels: {
|
||||||
|
position: 'top'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toString();
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
colors: [theme === 'dark' ? '#fff' : '#000']
|
||||||
|
},
|
||||||
|
offsetX: 10,
|
||||||
|
},
|
||||||
|
stroke: {
|
||||||
|
show: true,
|
||||||
|
width: 1,
|
||||||
|
colors: [theme === 'dark' ? '#374151' : '#E5E7EB'],
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: [],
|
||||||
|
title: {
|
||||||
|
text: 'Jumlah Mahasiswa',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
title: {
|
||||||
|
text: 'Kabupaten',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
},
|
||||||
|
maxWidth: 200,
|
||||||
|
},
|
||||||
|
tickAmount: undefined,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
padding: {
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
colors: ['#3B82F6'], // Blue color for bars
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update theme when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
const currentTheme = theme === 'system' ? systemTheme : theme;
|
||||||
|
setOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
dataLabels: {
|
||||||
|
...prev.dataLabels,
|
||||||
|
style: {
|
||||||
|
...prev.dataLabels?.style,
|
||||||
|
colors: [currentTheme === 'dark' ? '#fff' : '#000']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
...prev.xaxis,
|
||||||
|
title: {
|
||||||
|
...prev.xaxis?.title,
|
||||||
|
style: {
|
||||||
|
...prev.xaxis?.title?.style,
|
||||||
|
color: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
...prev.xaxis?.labels,
|
||||||
|
style: {
|
||||||
|
...prev.xaxis?.labels?.style,
|
||||||
|
colors: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
...prev.yaxis,
|
||||||
|
title: {
|
||||||
|
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title : prev.yaxis?.title),
|
||||||
|
style: {
|
||||||
|
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title?.style : prev.yaxis?.title?.style),
|
||||||
|
color: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels : prev.yaxis?.labels),
|
||||||
|
style: {
|
||||||
|
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels?.style : prev.yaxis?.labels?.style),
|
||||||
|
colors: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
...prev.tooltip,
|
||||||
|
theme: currentTheme === 'dark' ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}, [theme, systemTheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const response = await fetch(`/api/mahasiswa/asal-daerah-lulus?tahunAngkatan=${selectedYear}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch data');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setChartData(data);
|
||||||
|
|
||||||
|
// Process data for chart
|
||||||
|
const kabupaten = data.map((item: ChartData) => item.kabupaten);
|
||||||
|
const jumlah = data.map((item: ChartData) => item.jumlah_lulus_tepat_waktu);
|
||||||
|
|
||||||
|
setSeries([{
|
||||||
|
name: 'Jumlah Lulus Tepat Waktu',
|
||||||
|
data: jumlah
|
||||||
|
}]);
|
||||||
|
setOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
xaxis: {
|
||||||
|
...prev.xaxis,
|
||||||
|
categories: kabupaten,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
|
console.error('Error fetching data:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [selectedYear]);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">Loading...</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500 dark:text-red-400">Error: {error}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chartData.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Asal Daerah Mahasiswa Lulus Tepat Waktu
|
||||||
|
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[600px] w-full">
|
||||||
|
<Chart
|
||||||
|
options={options}
|
||||||
|
series={series}
|
||||||
|
type="bar"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
323
components/AsalDaerahPerAngkatanChart.tsx
Normal file
323
components/AsalDaerahPerAngkatanChart.tsx
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface AsalDaerahData {
|
||||||
|
kabupaten: string;
|
||||||
|
jumlah: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tahunAngkatan: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AsalDaerahPerAngkatanChart({ tahunAngkatan }: Props) {
|
||||||
|
const { theme, systemTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [data, setData] = useState<AsalDaerahData[]>([]);
|
||||||
|
const [series, setSeries] = useState<ApexAxisChartSeries>([{
|
||||||
|
name: 'Jumlah Mahasiswa',
|
||||||
|
data: []
|
||||||
|
}]);
|
||||||
|
const [options, setOptions] = useState<ApexOptions>({
|
||||||
|
chart: {
|
||||||
|
type: 'bar',
|
||||||
|
stacked: false,
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
tools: {
|
||||||
|
download: true,
|
||||||
|
selection: true,
|
||||||
|
zoom: true,
|
||||||
|
zoomin: true,
|
||||||
|
zoomout: true,
|
||||||
|
pan: true,
|
||||||
|
reset: true,
|
||||||
|
customIcons: []
|
||||||
|
},
|
||||||
|
export: {
|
||||||
|
csv: {
|
||||||
|
filename: `asal-daerah-angkatan`,
|
||||||
|
columnDelimiter: ',',
|
||||||
|
headerCategory: 'Asal Daerah',
|
||||||
|
headerValue: 'Jumlah Mahasiswa'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
horizontal: true,
|
||||||
|
columnWidth: '85%',
|
||||||
|
distributed: false,
|
||||||
|
barHeight: '90%',
|
||||||
|
dataLabels: {
|
||||||
|
position: 'top'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toString();
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
colors: [theme === 'dark' ? '#fff' : '#000']
|
||||||
|
},
|
||||||
|
offsetX: 10,
|
||||||
|
},
|
||||||
|
stroke: {
|
||||||
|
show: true,
|
||||||
|
width: 1,
|
||||||
|
colors: [theme === 'dark' ? '#374151' : '#E5E7EB'],
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: [],
|
||||||
|
title: {
|
||||||
|
text: 'Jumlah Mahasiswa',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
title: {
|
||||||
|
text: 'Kabupaten',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
},
|
||||||
|
maxWidth: 200,
|
||||||
|
},
|
||||||
|
tickAmount: undefined,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
padding: {
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
colors: ['#3B82F6'], // Blue color for bars
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update theme when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
const currentTheme = theme === 'system' ? systemTheme : theme;
|
||||||
|
setOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
chart: {
|
||||||
|
...prev.chart,
|
||||||
|
background: currentTheme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
...prev.dataLabels,
|
||||||
|
style: {
|
||||||
|
...prev.dataLabels?.style,
|
||||||
|
colors: [currentTheme === 'dark' ? '#fff' : '#000']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
...prev.xaxis,
|
||||||
|
title: {
|
||||||
|
...prev.xaxis?.title,
|
||||||
|
style: {
|
||||||
|
...prev.xaxis?.title?.style,
|
||||||
|
color: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
...prev.xaxis?.labels,
|
||||||
|
style: {
|
||||||
|
...prev.xaxis?.labels?.style,
|
||||||
|
colors: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
...prev.yaxis,
|
||||||
|
title: {
|
||||||
|
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title : prev.yaxis?.title),
|
||||||
|
style: {
|
||||||
|
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title?.style : prev.yaxis?.title?.style),
|
||||||
|
color: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels : prev.yaxis?.labels),
|
||||||
|
style: {
|
||||||
|
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels?.style : prev.yaxis?.labels?.style),
|
||||||
|
colors: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
...prev.tooltip,
|
||||||
|
theme: currentTheme === 'dark' ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}, [theme, systemTheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/mahasiswa/asal-daerah-angkatan?tahun_angkatan=${tahunAngkatan}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(result)) {
|
||||||
|
throw new Error('Invalid data format received from server');
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(result);
|
||||||
|
|
||||||
|
// Process data for chart
|
||||||
|
const kabupaten = result.map(item => item.kabupaten);
|
||||||
|
const jumlah = result.map(item => item.jumlah);
|
||||||
|
|
||||||
|
setSeries([{
|
||||||
|
name: 'Jumlah Mahasiswa',
|
||||||
|
data: jumlah
|
||||||
|
}]);
|
||||||
|
setOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
xaxis: {
|
||||||
|
...prev.xaxis,
|
||||||
|
categories: kabupaten,
|
||||||
|
},
|
||||||
|
chart: {
|
||||||
|
...prev.chart,
|
||||||
|
toolbar: {
|
||||||
|
...prev.chart?.toolbar,
|
||||||
|
export: {
|
||||||
|
...prev.chart?.toolbar?.export,
|
||||||
|
csv: {
|
||||||
|
...prev.chart?.toolbar?.export?.csv,
|
||||||
|
filename: `asal-daerah-angkatan-${tahunAngkatan}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in fetchData:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tahunAngkatan) {
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
}, [tahunAngkatan]);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500 dark:text-red-400">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Asal Daerah Mahasiswa Angkatan {tahunAngkatan}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[600px] w-full">
|
||||||
|
<Chart
|
||||||
|
options={options}
|
||||||
|
series={series}
|
||||||
|
type="bar"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
237
components/AsalDaerahPrestasiChart.tsx
Normal file
237
components/AsalDaerahPrestasiChart.tsx
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface AsalDaerahPrestasiData {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
kabupaten: string;
|
||||||
|
asal_daerah_mahasiswa_prestasi: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedYear: string;
|
||||||
|
selectedJenisPrestasi: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AsalDaerahPrestasiChart({ selectedYear, selectedJenisPrestasi }: Props) {
|
||||||
|
const { theme, systemTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [data, setData] = useState<AsalDaerahPrestasiData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const url = `/api/mahasiswa/asal-daerah-prestasi?tahunAngkatan=${selectedYear}&jenisPrestasi=${selectedJenisPrestasi}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(result)) {
|
||||||
|
throw new Error('Invalid data format received from server');
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in fetchData:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [selectedYear, selectedJenisPrestasi]);
|
||||||
|
|
||||||
|
const chartOptions: ApexOptions = {
|
||||||
|
chart: {
|
||||||
|
type: 'bar',
|
||||||
|
stacked: false,
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
tools: {
|
||||||
|
download: true,
|
||||||
|
selection: true,
|
||||||
|
zoom: true,
|
||||||
|
zoomin: true,
|
||||||
|
zoomout: true,
|
||||||
|
pan: true,
|
||||||
|
reset: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
background: theme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
},
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
horizontal: true,
|
||||||
|
columnWidth: '85%',
|
||||||
|
distributed: false,
|
||||||
|
barHeight: '90%',
|
||||||
|
dataLabels: {
|
||||||
|
position: 'top'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toString();
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
colors: [theme === 'dark' ? '#fff' : '#000']
|
||||||
|
},
|
||||||
|
offsetX: 10,
|
||||||
|
},
|
||||||
|
stroke: {
|
||||||
|
show: true,
|
||||||
|
width: 1,
|
||||||
|
colors: [theme === 'dark' ? '#374151' : '#E5E7EB'],
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: [...new Set(data.map(item => item.kabupaten))].sort(),
|
||||||
|
title: {
|
||||||
|
text: 'Jumlah Mahasiswa',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
title: {
|
||||||
|
text: 'Kabupaten',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
},
|
||||||
|
maxWidth: 200,
|
||||||
|
},
|
||||||
|
tickAmount: undefined,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
padding: {
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
colors: ['#3B82F6'],
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const series = [{
|
||||||
|
name: 'Jumlah Mahasiswa',
|
||||||
|
data: [...new Set(data.map(item => item.kabupaten))].sort().map(kabupaten => {
|
||||||
|
const item = data.find(d => d.kabupaten === kabupaten);
|
||||||
|
return item ? item.asal_daerah_mahasiswa_prestasi : 0;
|
||||||
|
})
|
||||||
|
}];
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Asal Daerah Mahasiswa Prestasi {selectedJenisPrestasi}
|
||||||
|
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[600px] w-full">
|
||||||
|
<Chart
|
||||||
|
options={chartOptions}
|
||||||
|
series={series}
|
||||||
|
type="bar"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
242
components/AsalDaerahStatusChart.tsx
Normal file
242
components/AsalDaerahStatusChart.tsx
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface AsalDaerahStatusData {
|
||||||
|
kabupaten: string;
|
||||||
|
tahun_angkatan?: number;
|
||||||
|
status_kuliah: string;
|
||||||
|
total_mahasiswa: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedYear: string;
|
||||||
|
selectedStatus: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AsalDaerahStatusChart({ selectedYear, selectedStatus }: Props) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [data, setData] = useState<AsalDaerahStatusData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
console.log('Fetching data with params:', { selectedYear, selectedStatus });
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/mahasiswa/asal-daerah-status?tahun_angkatan=${selectedYear}&status_kuliah=${selectedStatus}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch data');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('Received data:', result);
|
||||||
|
|
||||||
|
// Sort data by kabupaten
|
||||||
|
const sortedData = result.sort((a: AsalDaerahStatusData, b: AsalDaerahStatusData) =>
|
||||||
|
a.kabupaten.localeCompare(b.kabupaten)
|
||||||
|
);
|
||||||
|
|
||||||
|
setData(sortedData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in fetchData:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [selectedYear, selectedStatus]);
|
||||||
|
|
||||||
|
// Log data changes
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('Current data state:', data);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// Get unique kabupaten
|
||||||
|
const kabupaten = [...new Set(data.map(item => item.kabupaten))].sort();
|
||||||
|
console.log('Kabupaten:', kabupaten);
|
||||||
|
|
||||||
|
const chartOptions: ApexOptions = {
|
||||||
|
chart: {
|
||||||
|
type: 'bar',
|
||||||
|
stacked: false,
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
},
|
||||||
|
background: theme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
},
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
horizontal: true,
|
||||||
|
columnWidth: '55%',
|
||||||
|
dataLabels: {
|
||||||
|
position: 'top'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toString();
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: [theme === 'dark' ? '#fff' : '#000']
|
||||||
|
},
|
||||||
|
offsetX: 10,
|
||||||
|
},
|
||||||
|
stroke: {
|
||||||
|
show: true,
|
||||||
|
width: 2,
|
||||||
|
colors: ['transparent'],
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: kabupaten,
|
||||||
|
title: {
|
||||||
|
text: 'Jumlah Mahasiswa',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
title: {
|
||||||
|
text: 'Kabupaten',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
fontSize: '14px',
|
||||||
|
markers: {
|
||||||
|
size: 12,
|
||||||
|
},
|
||||||
|
itemMargin: {
|
||||||
|
horizontal: 10,
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors: ['#008FFB'],
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process data for series
|
||||||
|
const processSeriesData = () => {
|
||||||
|
const seriesData = kabupaten.map(kab => {
|
||||||
|
const item = data.find(d => d.kabupaten === kab);
|
||||||
|
return item ? item.total_mahasiswa : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return [{
|
||||||
|
name: 'Jumlah Mahasiswa',
|
||||||
|
data: seriesData
|
||||||
|
}];
|
||||||
|
};
|
||||||
|
|
||||||
|
const series = processSeriesData();
|
||||||
|
console.log('Processed series data:', series);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Asal Daerah Mahasiswa {selectedStatus}
|
||||||
|
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[500px] w-full max-w-5xl mx-auto">
|
||||||
|
{typeof window !== 'undefined' && (
|
||||||
|
<Chart
|
||||||
|
options={chartOptions}
|
||||||
|
series={series}
|
||||||
|
type="bar"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
components/FilterJenisBeasiswa.tsx
Normal file
27
components/FilterJenisBeasiswa.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedJenisBeasiswa: string;
|
||||||
|
onJenisBeasiswaChange: (jenisBeasiswa: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FilterJenisBeasiswa({ selectedJenisBeasiswa, onJenisBeasiswaChange }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="w-64">
|
||||||
|
<Select
|
||||||
|
value={selectedJenisBeasiswa}
|
||||||
|
onValueChange={onJenisBeasiswaChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="focus:outline-none focus:ring-0 focus:ring-offset-0">
|
||||||
|
<SelectValue placeholder="Pilih Jenis Beasiswa" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Pemerintah">Pemerintah</SelectItem>
|
||||||
|
<SelectItem value="Non-Pemerintah">Non-Pemerintah</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
components/FilterJenisPrestasi.tsx
Normal file
27
components/FilterJenisPrestasi.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedJenisPrestasi: string;
|
||||||
|
onJenisPrestasiChange: (jenisPrestasi: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FilterJenisPrestasi({ selectedJenisPrestasi, onJenisPrestasiChange }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="w-64">
|
||||||
|
<Select
|
||||||
|
value={selectedJenisPrestasi}
|
||||||
|
onValueChange={onJenisPrestasiChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="focus:outline-none focus:ring-0 focus:ring-offset-0">
|
||||||
|
<SelectValue placeholder="Pilih Jenis Prestasi" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Akademik">Akademik</SelectItem>
|
||||||
|
<SelectItem value="Non-Akademik">Non-Akademik</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
components/FilterStatusKuliah.tsx
Normal file
38
components/FilterStatusKuliah.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedStatus: string;
|
||||||
|
onStatusChange: (status: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FilterStatusKuliah({ selectedStatus, onStatusChange }: Props) {
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: 'Aktif', label: 'Aktif' },
|
||||||
|
{ value: 'Lulus', label: 'Lulus' },
|
||||||
|
{ value: 'Cuti', label: 'Cuti' },
|
||||||
|
{ value: 'DO', label: 'DO' }
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-64">
|
||||||
|
<Select
|
||||||
|
value={selectedStatus}
|
||||||
|
onValueChange={onStatusChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="focus:outline-none focus:ring-0 focus:ring-offset-0">
|
||||||
|
<SelectValue placeholder="Pilih Status Kuliah" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{statusOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
components/FilterTahunAngkatan.tsx
Normal file
58
components/FilterTahunAngkatan.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
||||||
|
interface TahunAngkatan {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedYear: string;
|
||||||
|
onYearChange: (year: string) => void;
|
||||||
|
showAllOption?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FilterTahunAngkatan({ selectedYear, onYearChange, showAllOption = true }: Props) {
|
||||||
|
const [tahunAngkatan, setTahunAngkatan] = useState<TahunAngkatan[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchTahunAngkatan = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/mahasiswa/tahun-angkatan');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch tahun angkatan');
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
setTahunAngkatan(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching tahun angkatan:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchTahunAngkatan();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-64">
|
||||||
|
<Select value={selectedYear} onValueChange={onYearChange}>
|
||||||
|
<SelectTrigger className="focus:outline-none focus:ring-0 focus:ring-offset-0">
|
||||||
|
<SelectValue placeholder="Pilih Tahun Angkatan" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{showAllOption && (
|
||||||
|
<SelectItem value="all">Semua Angkatan</SelectItem>
|
||||||
|
)}
|
||||||
|
{tahunAngkatan.map((tahun) => (
|
||||||
|
<SelectItem
|
||||||
|
key={tahun.tahun_angkatan}
|
||||||
|
value={tahun.tahun_angkatan.toString()}
|
||||||
|
>
|
||||||
|
{tahun.tahun_angkatan}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
344
components/IPKBeasiswaChart.tsx
Normal file
344
components/IPKBeasiswaChart.tsx
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface IPKBeasiswaData {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
total_mahasiswa_beasiswa: number;
|
||||||
|
rata_rata_ipk: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedJenisBeasiswa: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IPKBeasiswaChart({ selectedJenisBeasiswa }: Props) {
|
||||||
|
const { theme, systemTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [data, setData] = useState<IPKBeasiswaData[]>([]);
|
||||||
|
const [series, setSeries] = useState<ApexAxisChartSeries>([{
|
||||||
|
name: 'Rata-rata IPK',
|
||||||
|
data: []
|
||||||
|
}]);
|
||||||
|
const [options, setOptions] = useState<ApexOptions>({
|
||||||
|
chart: {
|
||||||
|
type: 'line',
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
tools: {
|
||||||
|
download: true,
|
||||||
|
selection: true,
|
||||||
|
zoom: true,
|
||||||
|
zoomin: true,
|
||||||
|
zoomout: true,
|
||||||
|
pan: true,
|
||||||
|
reset: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
background: theme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
zoom: {
|
||||||
|
enabled: true,
|
||||||
|
type: 'x',
|
||||||
|
autoScaleYaxis: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stroke: {
|
||||||
|
curve: 'smooth',
|
||||||
|
width: 3,
|
||||||
|
lineCap: 'round'
|
||||||
|
},
|
||||||
|
markers: {
|
||||||
|
size: 5,
|
||||||
|
strokeWidth: 2,
|
||||||
|
strokeColors: theme === 'dark' ? ['#fff'] : ['#3B82F6'],
|
||||||
|
colors: ['#3B82F6'],
|
||||||
|
hover: {
|
||||||
|
size: 7
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toFixed(2);
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
enabled: false
|
||||||
|
},
|
||||||
|
offsetY: -10
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: [],
|
||||||
|
title: {
|
||||||
|
text: 'Tahun Angkatan',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisBorder: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
},
|
||||||
|
axisTicks: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
title: {
|
||||||
|
text: 'Rata-rata IPK',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
},
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toFixed(2);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
min: 0,
|
||||||
|
max: 4,
|
||||||
|
axisBorder: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
borderColor: theme === 'dark' ? '#374151' : '#E5E7EB',
|
||||||
|
strokeDashArray: 4,
|
||||||
|
padding: {
|
||||||
|
top: 20,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors: ['#3B82F6'],
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toFixed(2);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
marker: {
|
||||||
|
show: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
show: true,
|
||||||
|
position: 'top',
|
||||||
|
horizontalAlign: 'right',
|
||||||
|
labels: {
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update theme when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
const currentTheme = theme === 'system' ? systemTheme : theme;
|
||||||
|
setOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
chart: {
|
||||||
|
...prev.chart,
|
||||||
|
background: currentTheme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
...prev.dataLabels,
|
||||||
|
style: {
|
||||||
|
...prev.dataLabels?.style,
|
||||||
|
colors: [currentTheme === 'dark' ? '#fff' : '#000']
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
...prev.dataLabels?.background,
|
||||||
|
foreColor: currentTheme === 'dark' ? '#fff' : '#000',
|
||||||
|
borderColor: currentTheme === 'dark' ? '#374151' : '#3B82F6'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
...prev.xaxis,
|
||||||
|
title: {
|
||||||
|
...prev.xaxis?.title,
|
||||||
|
style: {
|
||||||
|
...prev.xaxis?.title?.style,
|
||||||
|
color: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
...prev.xaxis?.labels,
|
||||||
|
style: {
|
||||||
|
...prev.xaxis?.labels?.style,
|
||||||
|
colors: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisBorder: {
|
||||||
|
...prev.xaxis?.axisBorder,
|
||||||
|
color: currentTheme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
},
|
||||||
|
axisTicks: {
|
||||||
|
...prev.xaxis?.axisTicks,
|
||||||
|
color: currentTheme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
...prev.yaxis,
|
||||||
|
title: {
|
||||||
|
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title : prev.yaxis?.title),
|
||||||
|
style: {
|
||||||
|
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title?.style : prev.yaxis?.title?.style),
|
||||||
|
color: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels : prev.yaxis?.labels),
|
||||||
|
style: {
|
||||||
|
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels?.style : prev.yaxis?.labels?.style),
|
||||||
|
colors: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
...prev.tooltip,
|
||||||
|
theme: currentTheme === 'dark' ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}, [theme, systemTheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/mahasiswa/ipk-beasiswa?jenisBeasiswa=${selectedJenisBeasiswa}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(result)) {
|
||||||
|
throw new Error('Invalid data format received from server');
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(result);
|
||||||
|
|
||||||
|
// Process data for chart
|
||||||
|
const tahunAngkatan = result.map(item => item.tahun_angkatan);
|
||||||
|
const rataRataIPK = result.map(item => item.rata_rata_ipk);
|
||||||
|
|
||||||
|
setSeries([{
|
||||||
|
name: 'Rata-rata IPK',
|
||||||
|
data: rataRataIPK
|
||||||
|
}]);
|
||||||
|
setOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
xaxis: {
|
||||||
|
...prev.xaxis,
|
||||||
|
categories: tahunAngkatan,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in fetchData:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [selectedJenisBeasiswa]);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500 dark:text-red-400">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Rata-rata IPK Mahasiswa Beasiswa {selectedJenisBeasiswa}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[300px] sm:h-[350px] md:h-[400px] w-full max-w-5xl mx-auto">
|
||||||
|
<Chart
|
||||||
|
options={options}
|
||||||
|
series={series}
|
||||||
|
type="line"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
339
components/IPKChart.tsx
Normal file
339
components/IPKChart.tsx
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface IPKData {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
rata_rata_ipk: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IPKChart() {
|
||||||
|
const { theme, systemTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [data, setData] = useState<IPKData[]>([]);
|
||||||
|
const [series, setSeries] = useState<ApexAxisChartSeries>([{
|
||||||
|
name: 'Rata-rata IPK',
|
||||||
|
data: []
|
||||||
|
}]);
|
||||||
|
const [options, setOptions] = useState<ApexOptions>({
|
||||||
|
chart: {
|
||||||
|
type: 'line',
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
tools: {
|
||||||
|
download: true,
|
||||||
|
selection: true,
|
||||||
|
zoom: true,
|
||||||
|
zoomin: true,
|
||||||
|
zoomout: true,
|
||||||
|
pan: true,
|
||||||
|
reset: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
background: theme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
zoom: {
|
||||||
|
enabled: true,
|
||||||
|
type: 'x',
|
||||||
|
autoScaleYaxis: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stroke: {
|
||||||
|
curve: 'smooth',
|
||||||
|
width: 3,
|
||||||
|
lineCap: 'round'
|
||||||
|
},
|
||||||
|
markers: {
|
||||||
|
size: 5,
|
||||||
|
strokeWidth: 2,
|
||||||
|
strokeColors: theme === 'dark' ? ['#fff'] : ['#3B82F6'],
|
||||||
|
colors: ['#3B82F6'],
|
||||||
|
hover: {
|
||||||
|
size: 7
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toFixed(2);
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
enabled: false
|
||||||
|
},
|
||||||
|
offsetY: -10
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: [],
|
||||||
|
title: {
|
||||||
|
text: 'Tahun Angkatan',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisBorder: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
},
|
||||||
|
axisTicks: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
title: {
|
||||||
|
text: 'Rata-rata IPK',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
},
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toFixed(2);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
min: 0,
|
||||||
|
max: 4,
|
||||||
|
axisBorder: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
borderColor: theme === 'dark' ? '#374151' : '#E5E7EB',
|
||||||
|
strokeDashArray: 4,
|
||||||
|
padding: {
|
||||||
|
top: 20,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors: ['#3B82F6'], // Blue color for line
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toFixed(2);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
marker: {
|
||||||
|
show: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
show: true,
|
||||||
|
position: 'top',
|
||||||
|
horizontalAlign: 'right',
|
||||||
|
labels: {
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update theme when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
const currentTheme = theme === 'system' ? systemTheme : theme;
|
||||||
|
setOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
chart: {
|
||||||
|
...prev.chart,
|
||||||
|
background: currentTheme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
...prev.dataLabels,
|
||||||
|
style: {
|
||||||
|
...prev.dataLabels?.style,
|
||||||
|
colors: [currentTheme === 'dark' ? '#fff' : '#000']
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
...prev.dataLabels?.background,
|
||||||
|
foreColor: currentTheme === 'dark' ? '#fff' : '#000',
|
||||||
|
borderColor: currentTheme === 'dark' ? '#374151' : '#3B82F6'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
...prev.xaxis,
|
||||||
|
title: {
|
||||||
|
...prev.xaxis?.title,
|
||||||
|
style: {
|
||||||
|
...prev.xaxis?.title?.style,
|
||||||
|
color: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
...prev.xaxis?.labels,
|
||||||
|
style: {
|
||||||
|
...prev.xaxis?.labels?.style,
|
||||||
|
colors: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisBorder: {
|
||||||
|
...prev.xaxis?.axisBorder,
|
||||||
|
color: currentTheme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
},
|
||||||
|
axisTicks: {
|
||||||
|
...prev.xaxis?.axisTicks,
|
||||||
|
color: currentTheme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
...prev.yaxis,
|
||||||
|
title: {
|
||||||
|
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title : prev.yaxis?.title),
|
||||||
|
style: {
|
||||||
|
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title?.style : prev.yaxis?.title?.style),
|
||||||
|
color: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels : prev.yaxis?.labels),
|
||||||
|
style: {
|
||||||
|
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels?.style : prev.yaxis?.labels?.style),
|
||||||
|
colors: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
...prev.tooltip,
|
||||||
|
theme: currentTheme === 'dark' ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}, [theme, systemTheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await fetch('/api/mahasiswa/ipk');
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(result)) {
|
||||||
|
throw new Error('Invalid data format received from server');
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(result);
|
||||||
|
|
||||||
|
// Process data for chart
|
||||||
|
const tahunAngkatan = result.map(item => item.tahun_angkatan);
|
||||||
|
const rataRataIPK = result.map(item => item.rata_rata_ipk);
|
||||||
|
|
||||||
|
setSeries([{
|
||||||
|
name: 'Rata-rata IPK',
|
||||||
|
data: rataRataIPK
|
||||||
|
}]);
|
||||||
|
setOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
xaxis: {
|
||||||
|
...prev.xaxis,
|
||||||
|
categories: tahunAngkatan,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in fetchData:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500 dark:text-red-400">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Rata-rata IPK Mahasiswa
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[300px] sm:h-[350px] md:h-[400px] w-full max-w-5xl mx-auto">
|
||||||
|
<Chart
|
||||||
|
options={options}
|
||||||
|
series={series}
|
||||||
|
type="line"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
295
components/IPKJenisKelaminChart.tsx
Normal file
295
components/IPKJenisKelaminChart.tsx
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tahunAngkatan: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IPKJenisKelaminChart({ tahunAngkatan }: Props) {
|
||||||
|
const { theme, systemTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [series, setSeries] = useState<any[]>([]);
|
||||||
|
const [categories, setCategories] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const [options, setOptions] = useState<ApexOptions>({
|
||||||
|
chart: {
|
||||||
|
type: 'bar',
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
tools: {
|
||||||
|
download: true,
|
||||||
|
selection: true,
|
||||||
|
zoom: true,
|
||||||
|
zoomin: true,
|
||||||
|
zoomout: true,
|
||||||
|
pan: true,
|
||||||
|
reset: true,
|
||||||
|
customIcons: []
|
||||||
|
},
|
||||||
|
export: {
|
||||||
|
csv: {
|
||||||
|
filename: `ipk-jenis-kelamin-angkatan`,
|
||||||
|
columnDelimiter: ',',
|
||||||
|
headerCategory: 'Jenis Kelamin',
|
||||||
|
headerValue: 'Rata-rata IPK'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
background: theme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
},
|
||||||
|
colors: ['#3B82F6', '#EC4899'],
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
horizontal: false,
|
||||||
|
columnWidth: '30%',
|
||||||
|
borderRadius: 2,
|
||||||
|
distributed: true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
states: {
|
||||||
|
hover: {
|
||||||
|
filter: {
|
||||||
|
type: 'none'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
active: {
|
||||||
|
filter: {
|
||||||
|
type: 'none'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
opacity: 1
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toFixed(2);
|
||||||
|
},
|
||||||
|
offsetY: -20,
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
colors: [theme === 'dark' ? '#fff' : '#000']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: [],
|
||||||
|
title: {
|
||||||
|
text: 'Jenis Kelamin',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
min: 0,
|
||||||
|
max: 4.0,
|
||||||
|
tickAmount: 4,
|
||||||
|
title: {
|
||||||
|
text: 'Rata-rata IPK',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toFixed(2) + " IPK";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
show: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update theme when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
const currentTheme = theme === 'system' ? systemTheme : theme;
|
||||||
|
setOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
chart: {
|
||||||
|
...prev.chart,
|
||||||
|
background: currentTheme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
...prev.dataLabels,
|
||||||
|
style: {
|
||||||
|
...prev.dataLabels?.style,
|
||||||
|
colors: [currentTheme === 'dark' ? '#fff' : '#000']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
...prev.xaxis,
|
||||||
|
title: {
|
||||||
|
...prev.xaxis?.title,
|
||||||
|
style: {
|
||||||
|
...prev.xaxis?.title?.style,
|
||||||
|
color: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
...prev.xaxis?.labels,
|
||||||
|
style: {
|
||||||
|
colors: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
...prev.yaxis,
|
||||||
|
title: {
|
||||||
|
...(prev.yaxis as any)?.title,
|
||||||
|
style: {
|
||||||
|
...(prev.yaxis as any)?.title?.style,
|
||||||
|
color: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
...(prev.yaxis as any)?.labels,
|
||||||
|
style: {
|
||||||
|
colors: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
...prev.tooltip,
|
||||||
|
theme: currentTheme === 'dark' ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}, [theme, systemTheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
if (!tahunAngkatan || tahunAngkatan === 'all') {
|
||||||
|
console.log('Tahun angkatan tidak tersedia atau "all"');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Fetching data for tahun angkatan:', tahunAngkatan);
|
||||||
|
const url = `/api/mahasiswa/ipk-jenis-kelamin?tahun_angkatan=${tahunAngkatan}`;
|
||||||
|
console.log('API URL:', url);
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Error response:', response.status, response.statusText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('Data received from API:', data);
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
console.log('No data received from API');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process data for chart
|
||||||
|
const labels = data.map((item: any) => {
|
||||||
|
console.log('Processing item:', item);
|
||||||
|
return item.jk === 'Pria' ? 'Laki-laki' : 'Perempuan';
|
||||||
|
});
|
||||||
|
|
||||||
|
const values = data.map((item: any) => {
|
||||||
|
const ipk = parseFloat(item.rata_rata_ipk);
|
||||||
|
console.log(`IPK for ${item.jk}:`, ipk);
|
||||||
|
return ipk;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Processed labels:', labels);
|
||||||
|
console.log('Processed values:', values);
|
||||||
|
|
||||||
|
if (values.length === 0) {
|
||||||
|
console.log('No values to display');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCategories(labels);
|
||||||
|
// Untuk bar chart, kita memerlukan array dari objects
|
||||||
|
setSeries([{
|
||||||
|
name: 'Rata-rata IPK',
|
||||||
|
data: values
|
||||||
|
}]);
|
||||||
|
|
||||||
|
setOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
xaxis: {
|
||||||
|
...prev.xaxis,
|
||||||
|
categories: labels
|
||||||
|
},
|
||||||
|
chart: {
|
||||||
|
...prev.chart,
|
||||||
|
toolbar: {
|
||||||
|
...prev.chart?.toolbar,
|
||||||
|
export: {
|
||||||
|
...prev.chart?.toolbar?.export,
|
||||||
|
csv: {
|
||||||
|
...prev.chart?.toolbar?.export?.csv,
|
||||||
|
filename: `ipk-jenis-kelamin-angkatan-${tahunAngkatan}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
}, [tahunAngkatan, mounted]);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Rata-rata IPK Berdasarkan Jenis Kelamin Angkatan {tahunAngkatan}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[400px] w-full">
|
||||||
|
{series.length > 0 && series[0].data?.length > 0 ? (
|
||||||
|
<Chart
|
||||||
|
options={{
|
||||||
|
...options,
|
||||||
|
xaxis: {
|
||||||
|
...options.xaxis,
|
||||||
|
categories: categories
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
series={series}
|
||||||
|
type="bar"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">Tidak ada data</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
337
components/IPKLulusTepatChart.tsx
Normal file
337
components/IPKLulusTepatChart.tsx
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface IPKLulusTepatData {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
rata_rata_ipk: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IPKLulusTepatChartProps {
|
||||||
|
selectedYear: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IPKLulusTepatChart({ selectedYear }: IPKLulusTepatChartProps) {
|
||||||
|
const { theme, systemTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [data, setData] = useState<IPKLulusTepatData[]>([]);
|
||||||
|
const [series, setSeries] = useState<ApexAxisChartSeries>([{
|
||||||
|
name: 'Rata-rata IPK',
|
||||||
|
data: []
|
||||||
|
}]);
|
||||||
|
const [options, setOptions] = useState<ApexOptions>({
|
||||||
|
chart: {
|
||||||
|
type: 'line',
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
tools: {
|
||||||
|
download: true,
|
||||||
|
selection: true,
|
||||||
|
zoom: true,
|
||||||
|
zoomin: true,
|
||||||
|
zoomout: true,
|
||||||
|
pan: true,
|
||||||
|
reset: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
background: theme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
zoom: {
|
||||||
|
enabled: true,
|
||||||
|
type: 'x',
|
||||||
|
autoScaleYaxis: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stroke: {
|
||||||
|
curve: 'smooth',
|
||||||
|
width: 3,
|
||||||
|
lineCap: 'round'
|
||||||
|
},
|
||||||
|
markers: {
|
||||||
|
size: 5,
|
||||||
|
strokeWidth: 2,
|
||||||
|
strokeColors: theme === 'dark' ? ['#fff'] : ['#3B82F6'],
|
||||||
|
colors: ['#3B82F6'],
|
||||||
|
hover: {
|
||||||
|
size: 7
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toFixed(2);
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
enabled: false
|
||||||
|
},
|
||||||
|
offsetY: -10
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: [],
|
||||||
|
title: {
|
||||||
|
text: 'Tahun Angkatan',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisBorder: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
},
|
||||||
|
axisTicks: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
title: {
|
||||||
|
text: 'Rata-rata IPK',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
},
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toFixed(2);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
min: 0,
|
||||||
|
max: 4,
|
||||||
|
axisBorder: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
borderColor: theme === 'dark' ? '#374151' : '#E5E7EB',
|
||||||
|
strokeDashArray: 4,
|
||||||
|
padding: {
|
||||||
|
top: 20,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors: ['#3B82F6'], // Blue color for line
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toFixed(2);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
marker: {
|
||||||
|
show: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
show: true,
|
||||||
|
position: 'top',
|
||||||
|
horizontalAlign: 'right',
|
||||||
|
labels: {
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update theme when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
const currentTheme = theme === 'system' ? systemTheme : theme;
|
||||||
|
setOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
chart: {
|
||||||
|
...prev.chart,
|
||||||
|
background: currentTheme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
...prev.dataLabels,
|
||||||
|
style: {
|
||||||
|
...prev.dataLabels?.style,
|
||||||
|
colors: [currentTheme === 'dark' ? '#fff' : '#000']
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
...prev.dataLabels?.background,
|
||||||
|
foreColor: currentTheme === 'dark' ? '#fff' : '#000',
|
||||||
|
borderColor: currentTheme === 'dark' ? '#374151' : '#3B82F6'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
...prev.xaxis,
|
||||||
|
title: {
|
||||||
|
...prev.xaxis?.title,
|
||||||
|
style: {
|
||||||
|
...prev.xaxis?.title?.style,
|
||||||
|
color: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
...prev.xaxis?.labels,
|
||||||
|
style: {
|
||||||
|
...prev.xaxis?.labels?.style,
|
||||||
|
colors: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisBorder: {
|
||||||
|
...prev.xaxis?.axisBorder,
|
||||||
|
color: currentTheme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
},
|
||||||
|
axisTicks: {
|
||||||
|
...prev.xaxis?.axisTicks,
|
||||||
|
color: currentTheme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
...prev.yaxis,
|
||||||
|
title: {
|
||||||
|
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title : prev.yaxis?.title),
|
||||||
|
style: {
|
||||||
|
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title?.style : prev.yaxis?.title?.style),
|
||||||
|
color: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels : prev.yaxis?.labels),
|
||||||
|
style: {
|
||||||
|
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels?.style : prev.yaxis?.labels?.style),
|
||||||
|
colors: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
...prev.tooltip,
|
||||||
|
theme: currentTheme === 'dark' ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}, [theme, systemTheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const response = await fetch(`/api/mahasiswa/ipk-lulus-tepat?tahunAngkatan=${selectedYear}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch data');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchedData: IPKLulusTepatData[] = await response.json();
|
||||||
|
setData(fetchedData);
|
||||||
|
|
||||||
|
// Process data for chart
|
||||||
|
const tahunAngkatan = fetchedData.map(item => item.tahun_angkatan);
|
||||||
|
const rataRataIPK = fetchedData.map(item => item.rata_rata_ipk);
|
||||||
|
|
||||||
|
setSeries([{
|
||||||
|
name: 'Rata-rata IPK',
|
||||||
|
data: rataRataIPK
|
||||||
|
}]);
|
||||||
|
setOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
xaxis: {
|
||||||
|
...prev.xaxis,
|
||||||
|
categories: tahunAngkatan,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
setError(error instanceof Error ? error.message : 'An error occurred');
|
||||||
|
console.error('Error fetching data:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [selectedYear]);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500 dark:text-red-400">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Rata-rata IPK Mahasiswa Lulus Tepat Waktu
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[300px] sm:h-[350px] md:h-[400px] w-full max-w-5xl mx-auto">
|
||||||
|
<Chart
|
||||||
|
options={options}
|
||||||
|
series={series}
|
||||||
|
type="line"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
347
components/IPKPrestasiChart.tsx
Normal file
347
components/IPKPrestasiChart.tsx
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface IPKPrestasiData {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
total_mahasiswa_prestasi: number;
|
||||||
|
rata_rata_ipk: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedJenisPrestasi: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IPKPrestasiChart({ selectedJenisPrestasi }: Props) {
|
||||||
|
const { theme, systemTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [data, setData] = useState<IPKPrestasiData[]>([]);
|
||||||
|
const [series, setSeries] = useState<ApexAxisChartSeries>([{
|
||||||
|
name: 'Rata-rata IPK',
|
||||||
|
data: []
|
||||||
|
}]);
|
||||||
|
const [options, setOptions] = useState<ApexOptions>({
|
||||||
|
chart: {
|
||||||
|
type: 'line',
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
tools: {
|
||||||
|
download: true,
|
||||||
|
selection: true,
|
||||||
|
zoom: true,
|
||||||
|
zoomin: true,
|
||||||
|
zoomout: true,
|
||||||
|
pan: true,
|
||||||
|
reset: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
background: theme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
zoom: {
|
||||||
|
enabled: true,
|
||||||
|
type: 'x',
|
||||||
|
autoScaleYaxis: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stroke: {
|
||||||
|
curve: 'smooth',
|
||||||
|
width: 3,
|
||||||
|
lineCap: 'round'
|
||||||
|
},
|
||||||
|
markers: {
|
||||||
|
size: 5,
|
||||||
|
strokeWidth: 2,
|
||||||
|
strokeColors: theme === 'dark' ? ['#fff'] : ['#3B82F6'],
|
||||||
|
colors: ['#3B82F6'],
|
||||||
|
hover: {
|
||||||
|
size: 7
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toFixed(2);
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
enabled: false
|
||||||
|
},
|
||||||
|
offsetY: -10
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: [],
|
||||||
|
title: {
|
||||||
|
text: 'Tahun Angkatan',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisBorder: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
},
|
||||||
|
axisTicks: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
title: {
|
||||||
|
text: 'Rata-rata IPK',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
},
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toFixed(2);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
min: 0,
|
||||||
|
max: 4,
|
||||||
|
axisBorder: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
borderColor: theme === 'dark' ? '#374151' : '#E5E7EB',
|
||||||
|
strokeDashArray: 4,
|
||||||
|
padding: {
|
||||||
|
top: 20,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors: ['#3B82F6'],
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toFixed(2);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
marker: {
|
||||||
|
show: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
show: true,
|
||||||
|
position: 'top',
|
||||||
|
horizontalAlign: 'right',
|
||||||
|
labels: {
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update theme when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
const currentTheme = theme === 'system' ? systemTheme : theme;
|
||||||
|
setOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
chart: {
|
||||||
|
...prev.chart,
|
||||||
|
background: currentTheme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
...prev.dataLabels,
|
||||||
|
style: {
|
||||||
|
...prev.dataLabels?.style,
|
||||||
|
colors: [currentTheme === 'dark' ? '#fff' : '#000']
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
...prev.dataLabels?.background,
|
||||||
|
foreColor: currentTheme === 'dark' ? '#fff' : '#000',
|
||||||
|
borderColor: currentTheme === 'dark' ? '#374151' : '#3B82F6'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
...prev.xaxis,
|
||||||
|
title: {
|
||||||
|
...prev.xaxis?.title,
|
||||||
|
style: {
|
||||||
|
...prev.xaxis?.title?.style,
|
||||||
|
color: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
...prev.xaxis?.labels,
|
||||||
|
style: {
|
||||||
|
...prev.xaxis?.labels?.style,
|
||||||
|
colors: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisBorder: {
|
||||||
|
...prev.xaxis?.axisBorder,
|
||||||
|
color: currentTheme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
},
|
||||||
|
axisTicks: {
|
||||||
|
...prev.xaxis?.axisTicks,
|
||||||
|
color: currentTheme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
...prev.yaxis,
|
||||||
|
title: {
|
||||||
|
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title : prev.yaxis?.title),
|
||||||
|
style: {
|
||||||
|
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title?.style : prev.yaxis?.title?.style),
|
||||||
|
color: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels : prev.yaxis?.labels),
|
||||||
|
style: {
|
||||||
|
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels?.style : prev.yaxis?.labels?.style),
|
||||||
|
colors: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
...prev.tooltip,
|
||||||
|
theme: currentTheme === 'dark' ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}, [theme, systemTheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/mahasiswa/ipk-prestasi?jenisPrestasi=${selectedJenisPrestasi}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(result)) {
|
||||||
|
throw new Error('Invalid data format received from server');
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(result);
|
||||||
|
|
||||||
|
// Sort data by tahun_angkatan in ascending order
|
||||||
|
const sortedData = [...result].sort((a, b) => a.tahun_angkatan - b.tahun_angkatan);
|
||||||
|
|
||||||
|
// Process data for chart
|
||||||
|
const tahunAngkatan = sortedData.map(item => item.tahun_angkatan);
|
||||||
|
const rataRataIPK = sortedData.map(item => item.rata_rata_ipk);
|
||||||
|
|
||||||
|
setSeries([{
|
||||||
|
name: 'Rata-rata IPK',
|
||||||
|
data: rataRataIPK
|
||||||
|
}]);
|
||||||
|
setOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
xaxis: {
|
||||||
|
...prev.xaxis,
|
||||||
|
categories: tahunAngkatan,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in fetchData:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [selectedJenisPrestasi]);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500 dark:text-red-400">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Rata-rata IPK Mahasiswa Prestasi {selectedJenisPrestasi}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[300px] sm:h-[350px] md:h-[400px] w-full max-w-5xl mx-auto">
|
||||||
|
<Chart
|
||||||
|
options={options}
|
||||||
|
series={series}
|
||||||
|
type="line"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
295
components/IpkStatusChart.tsx
Normal file
295
components/IpkStatusChart.tsx
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface IpkStatusData {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
status_kuliah: string;
|
||||||
|
total_mahasiswa: number;
|
||||||
|
rata_rata_ipk: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedYear: string;
|
||||||
|
selectedStatus: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IpkStatusChart({ selectedYear, selectedStatus }: Props) {
|
||||||
|
const { theme, systemTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [data, setData] = useState<IpkStatusData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const url = `/api/mahasiswa/ipk-status?tahun_angkatan=${selectedYear}&status_kuliah=${selectedStatus}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('Error response:', errorText);
|
||||||
|
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('Received data:', result);
|
||||||
|
|
||||||
|
if (!Array.isArray(result)) {
|
||||||
|
console.error('Invalid data format:', result);
|
||||||
|
throw new Error('Invalid data format received from server');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort data by tahun_angkatan
|
||||||
|
const sortedData = result.sort((a: IpkStatusData, b: IpkStatusData) =>
|
||||||
|
a.tahun_angkatan - b.tahun_angkatan
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Sorted data:', sortedData);
|
||||||
|
setData(sortedData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in fetchData:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [selectedYear, selectedStatus]);
|
||||||
|
|
||||||
|
// Log data changes
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('Current data state:', data);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const chartOptions: ApexOptions = {
|
||||||
|
chart: {
|
||||||
|
type: selectedYear === 'all' ? 'line' : 'bar',
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
tools: {
|
||||||
|
download: true,
|
||||||
|
selection: true,
|
||||||
|
zoom: true,
|
||||||
|
zoomin: true,
|
||||||
|
zoomout: true,
|
||||||
|
pan: true,
|
||||||
|
reset: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
background: theme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
zoom: {
|
||||||
|
enabled: true,
|
||||||
|
type: 'x',
|
||||||
|
autoScaleYaxis: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
horizontal: false,
|
||||||
|
columnWidth: '55%',
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
stroke: {
|
||||||
|
curve: 'smooth',
|
||||||
|
width: 3,
|
||||||
|
lineCap: 'round'
|
||||||
|
},
|
||||||
|
markers: {
|
||||||
|
size: 5,
|
||||||
|
strokeWidth: 2,
|
||||||
|
strokeColors: theme === 'dark' ? ['#fff'] : ['#3B82F6'],
|
||||||
|
colors: ['#3B82F6'],
|
||||||
|
hover: {
|
||||||
|
size: 7
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toFixed(2);
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
colors: [theme === 'dark' ? '#fff' : '#000']
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
enabled: false
|
||||||
|
},
|
||||||
|
offsetY: -10
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: data.map(item => item.tahun_angkatan.toString()),
|
||||||
|
title: {
|
||||||
|
text: 'Tahun Angkatan',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisBorder: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
},
|
||||||
|
axisTicks: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
title: {
|
||||||
|
text: 'Rata-rata IPK',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
min: 0,
|
||||||
|
max: 4,
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
},
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toFixed(2);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisBorder: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
borderColor: theme === 'dark' ? '#374151' : '#E5E7EB',
|
||||||
|
strokeDashArray: 4,
|
||||||
|
padding: {
|
||||||
|
top: 20,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors: ['#3B82F6'],
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toFixed(2);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
marker: {
|
||||||
|
show: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
show: true,
|
||||||
|
position: 'top',
|
||||||
|
horizontalAlign: 'right',
|
||||||
|
labels: {
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process data for series
|
||||||
|
const processSeriesData = () => {
|
||||||
|
return [{
|
||||||
|
name: 'Rata-rata IPK',
|
||||||
|
data: data.map(item => item.rata_rata_ipk)
|
||||||
|
}];
|
||||||
|
};
|
||||||
|
|
||||||
|
const series = processSeriesData();
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Rata-rata IPK Mahasiswa {selectedStatus}
|
||||||
|
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[300px] sm:h-[350px] md:h-[400px] w-full max-w-5xl mx-auto">
|
||||||
|
{typeof window !== 'undefined' && (
|
||||||
|
<Chart
|
||||||
|
options={chartOptions}
|
||||||
|
series={series}
|
||||||
|
type={selectedYear === 'all' ? 'line' : 'bar'}
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
265
components/JenisPendaftaranBeasiswaChart.tsx
Normal file
265
components/JenisPendaftaranBeasiswaChart.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface JenisPendaftaranBeasiswaData {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
jenis_pendaftaran: string;
|
||||||
|
jumlah_mahasiswa_beasiswa: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedYear: string;
|
||||||
|
selectedJenisBeasiswa: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JenisPendaftaranBeasiswaChart({ selectedYear, selectedJenisBeasiswa }: Props) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [data, setData] = useState<JenisPendaftaranBeasiswaData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const url = `/api/mahasiswa/jenis-pendaftaran-beasiswa?tahunAngkatan=${selectedYear}&jenisBeasiswa=${selectedJenisBeasiswa}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(result)) {
|
||||||
|
throw new Error('Invalid data format received from server');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort data by tahun_angkatan
|
||||||
|
const sortedData = result.sort((a: JenisPendaftaranBeasiswaData, b: JenisPendaftaranBeasiswaData) =>
|
||||||
|
a.tahun_angkatan - b.tahun_angkatan
|
||||||
|
);
|
||||||
|
|
||||||
|
setData(sortedData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in fetchData:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [selectedYear, selectedJenisBeasiswa]);
|
||||||
|
|
||||||
|
// Process data for series
|
||||||
|
const processSeriesData = () => {
|
||||||
|
if (!data.length) return [];
|
||||||
|
|
||||||
|
const years = [...new Set(data.map(item => item.tahun_angkatan))].sort();
|
||||||
|
const pendaftaranTypes = [...new Set(data.map(item => item.jenis_pendaftaran))].sort();
|
||||||
|
|
||||||
|
return pendaftaranTypes.map(pendaftaran => ({
|
||||||
|
name: pendaftaran,
|
||||||
|
data: years.map(year => {
|
||||||
|
const item = data.find(d => d.tahun_angkatan === year && d.jenis_pendaftaran === pendaftaran);
|
||||||
|
return item ? item.jumlah_mahasiswa_beasiswa : 0;
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartOptions: ApexOptions = {
|
||||||
|
chart: {
|
||||||
|
type: 'bar',
|
||||||
|
stacked: false,
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
tools: {
|
||||||
|
download: true,
|
||||||
|
selection: true,
|
||||||
|
zoom: true,
|
||||||
|
zoomin: true,
|
||||||
|
zoomout: true,
|
||||||
|
pan: true,
|
||||||
|
reset: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
background: theme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
},
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
horizontal: false,
|
||||||
|
columnWidth: '55%',
|
||||||
|
borderRadius: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toString();
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: [theme === 'dark' ? '#fff' : '#000']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stroke: {
|
||||||
|
show: true,
|
||||||
|
width: 2,
|
||||||
|
colors: ['transparent']
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: [...new Set(data.map(item => item.tahun_angkatan))].sort(),
|
||||||
|
title: {
|
||||||
|
text: 'Tahun Angkatan',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisBorder: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
},
|
||||||
|
axisTicks: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
title: {
|
||||||
|
text: 'Jumlah Mahasiswa',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisBorder: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
opacity: 1
|
||||||
|
},
|
||||||
|
colors: ['#3B82F6', '#EC4899', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#06B6D4'],
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
fontSize: '14px',
|
||||||
|
markers: {
|
||||||
|
size: 12,
|
||||||
|
},
|
||||||
|
itemMargin: {
|
||||||
|
horizontal: 10,
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
borderColor: theme === 'dark' ? '#374151' : '#E5E7EB',
|
||||||
|
strokeDashArray: 4,
|
||||||
|
padding: {
|
||||||
|
top: 20,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const series = processSeriesData();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Jenis Pendaftaran Mahasiswa Beasiswa {selectedJenisBeasiswa}
|
||||||
|
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[300px] sm:h-[350px] md:h-[400px] w-full max-w-5xl mx-auto">
|
||||||
|
{typeof window !== 'undefined' && series.length > 0 && (
|
||||||
|
<Chart
|
||||||
|
options={chartOptions}
|
||||||
|
series={series}
|
||||||
|
type="bar"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
components/JenisPendaftaranBeasiswaPieChart.tsx
Normal file
192
components/JenisPendaftaranBeasiswaPieChart.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface JenisPendaftaranBeasiswaData {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
jenis_pendaftaran: string;
|
||||||
|
jumlah_mahasiswa_beasiswa: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedYear: string;
|
||||||
|
selectedJenisBeasiswa: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JenisPendaftaranBeasiswaPieChart({ selectedYear, selectedJenisBeasiswa }: Props) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [data, setData] = useState<JenisPendaftaranBeasiswaData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const url = `/api/mahasiswa/jenis-pendaftaran-beasiswa?tahunAngkatan=${selectedYear}&jenisBeasiswa=${selectedJenisBeasiswa}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
setData(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in fetchData:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [selectedYear, selectedJenisBeasiswa]);
|
||||||
|
|
||||||
|
const chartOptions: ApexOptions = {
|
||||||
|
chart: {
|
||||||
|
type: 'pie',
|
||||||
|
background: theme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
tools: {
|
||||||
|
download: true,
|
||||||
|
selection: true,
|
||||||
|
zoom: true,
|
||||||
|
zoomin: true,
|
||||||
|
zoomout: true,
|
||||||
|
pan: true,
|
||||||
|
reset: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: [],
|
||||||
|
colors: ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4'],
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
fontSize: '14px',
|
||||||
|
markers: {
|
||||||
|
size: 12,
|
||||||
|
strokeWidth: 0
|
||||||
|
},
|
||||||
|
itemMargin: {
|
||||||
|
horizontal: 10
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return `${val.toFixed(0)}%`;
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontFamily: 'Inter, sans-serif',
|
||||||
|
fontWeight: '500'
|
||||||
|
},
|
||||||
|
offsetY: 0,
|
||||||
|
dropShadow: {
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process data for series
|
||||||
|
const processSeriesData = () => {
|
||||||
|
const jenisPendaftaran = [...new Set(data.map(item => item.jenis_pendaftaran))].sort();
|
||||||
|
const jumlahData = jenisPendaftaran.map(jenis => {
|
||||||
|
const item = data.find(d => d.jenis_pendaftaran === jenis);
|
||||||
|
return item ? item.jumlah_mahasiswa_beasiswa : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
series: jumlahData,
|
||||||
|
labels: jenisPendaftaran
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const { series, labels } = processSeriesData();
|
||||||
|
const totalMahasiswa = data.reduce((sum, item) => sum + item.jumlah_mahasiswa_beasiswa, 0);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Jenis Pendaftaran Mahasiswa Beasiswa {selectedJenisBeasiswa}
|
||||||
|
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="text-lg font-semibold text-gray-600 dark:text-gray-300">
|
||||||
|
Total Mahasiswa: {totalMahasiswa}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[350px] w-full max-w-5xl mx-auto">
|
||||||
|
{typeof window !== 'undefined' && (
|
||||||
|
<Chart
|
||||||
|
options={{...chartOptions, labels}}
|
||||||
|
series={series}
|
||||||
|
type="pie"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
293
components/JenisPendaftaranChart.tsx
Normal file
293
components/JenisPendaftaranChart.tsx
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface JenisPendaftaranData {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
jenis_pendaftaran: string;
|
||||||
|
jumlah: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JenisPendaftaranChart() {
|
||||||
|
const { theme, systemTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [data, setData] = useState<JenisPendaftaranData[]>([]);
|
||||||
|
const [series, setSeries] = useState<ApexAxisChartSeries>([]);
|
||||||
|
const [options, setOptions] = useState<ApexOptions>({
|
||||||
|
chart: {
|
||||||
|
type: 'bar',
|
||||||
|
stacked: false,
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
},
|
||||||
|
background: theme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
},
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
horizontal: true,
|
||||||
|
columnWidth: '55%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toString();
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: [theme === 'dark' ? '#fff' : '#000']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stroke: {
|
||||||
|
show: true,
|
||||||
|
width: 2,
|
||||||
|
colors: ['transparent'],
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: [],
|
||||||
|
title: {
|
||||||
|
text: 'Jumlah Mahasiswa',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
title: {
|
||||||
|
text: 'Tahun Angkatan',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
fontSize: '14px',
|
||||||
|
markers: {
|
||||||
|
size: 12,
|
||||||
|
},
|
||||||
|
itemMargin: {
|
||||||
|
horizontal: 10,
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors: ['#3B82F6', '#10B981', '#F59E0B', '#EF4444'],
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update theme when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
const currentTheme = theme === 'system' ? systemTheme : theme;
|
||||||
|
setOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
chart: {
|
||||||
|
...prev.chart,
|
||||||
|
background: currentTheme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
...prev.dataLabels,
|
||||||
|
style: {
|
||||||
|
...prev.dataLabels?.style,
|
||||||
|
colors: [currentTheme === 'dark' ? '#fff' : '#000']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
...prev.xaxis,
|
||||||
|
title: {
|
||||||
|
...prev.xaxis?.title,
|
||||||
|
style: {
|
||||||
|
...prev.xaxis?.title?.style,
|
||||||
|
color: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
...prev.xaxis?.labels,
|
||||||
|
style: {
|
||||||
|
...prev.xaxis?.labels?.style,
|
||||||
|
colors: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
...prev.yaxis,
|
||||||
|
title: {
|
||||||
|
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title : prev.yaxis?.title),
|
||||||
|
style: {
|
||||||
|
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title?.style : prev.yaxis?.title?.style),
|
||||||
|
color: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels : prev.yaxis?.labels),
|
||||||
|
style: {
|
||||||
|
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels?.style : prev.yaxis?.labels?.style),
|
||||||
|
colors: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
...prev.legend,
|
||||||
|
labels: {
|
||||||
|
...prev.legend?.labels,
|
||||||
|
colors: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
...prev.tooltip,
|
||||||
|
theme: currentTheme === 'dark' ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}, [theme, systemTheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await fetch('/api/mahasiswa/jenis-pendaftaran');
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(result)) {
|
||||||
|
throw new Error('Invalid data format received from server');
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(result);
|
||||||
|
|
||||||
|
const tahunAngkatan = [...new Set(result.map(item => item.tahun_angkatan))]
|
||||||
|
.sort((a, b) => b - a);
|
||||||
|
const jenisPendaftaran = [...new Set(result.map(item => item.jenis_pendaftaran))].sort();
|
||||||
|
|
||||||
|
const seriesData = jenisPendaftaran.map(jenis => ({
|
||||||
|
name: jenis,
|
||||||
|
data: tahunAngkatan.map(tahun => {
|
||||||
|
const item = result.find(d => d.tahun_angkatan === tahun && d.jenis_pendaftaran === jenis);
|
||||||
|
return item ? item.jumlah : 0;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
setSeries(seriesData);
|
||||||
|
setOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
xaxis: {
|
||||||
|
...prev.xaxis,
|
||||||
|
categories: tahunAngkatan,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in fetchData:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500 dark:text-red-400">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Jenis Pendaftaran Mahasiswa
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[400px] w-full">
|
||||||
|
<Chart
|
||||||
|
options={options}
|
||||||
|
series={series}
|
||||||
|
type="bar"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
262
components/JenisPendaftaranLulusChart.tsx
Normal file
262
components/JenisPendaftaranLulusChart.tsx
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface JenisPendaftaranLulusData {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
jenis_pendaftaran: string;
|
||||||
|
jumlah_lulus_tepat_waktu: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedYear: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JenisPendaftaranLulusChart({ selectedYear }: Props) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [data, setData] = useState<JenisPendaftaranLulusData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const url = `/api/mahasiswa/jenis-pendaftaran-lulus?tahun_angkatan=${selectedYear}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(result)) {
|
||||||
|
throw new Error('Invalid data format received from server');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort data by tahun_angkatan
|
||||||
|
const sortedData = result.sort((a: JenisPendaftaranLulusData, b: JenisPendaftaranLulusData) =>
|
||||||
|
a.tahun_angkatan - b.tahun_angkatan
|
||||||
|
);
|
||||||
|
|
||||||
|
setData(sortedData);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [selectedYear]);
|
||||||
|
|
||||||
|
// Process data for series
|
||||||
|
const processSeriesData = () => {
|
||||||
|
if (!data.length) return [];
|
||||||
|
|
||||||
|
const years = [...new Set(data.map(item => item.tahun_angkatan))].sort();
|
||||||
|
const pendaftaranTypes = [...new Set(data.map(item => item.jenis_pendaftaran))];
|
||||||
|
|
||||||
|
return pendaftaranTypes.map(type => ({
|
||||||
|
name: type,
|
||||||
|
data: years.map(year => {
|
||||||
|
const item = data.find(d => d.tahun_angkatan === year && d.jenis_pendaftaran === type);
|
||||||
|
return item ? item.jumlah_lulus_tepat_waktu : 0;
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartOptions: ApexOptions = {
|
||||||
|
chart: {
|
||||||
|
type: 'bar',
|
||||||
|
stacked: false,
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
tools: {
|
||||||
|
download: true,
|
||||||
|
selection: true,
|
||||||
|
zoom: true,
|
||||||
|
zoomin: true,
|
||||||
|
zoomout: true,
|
||||||
|
pan: true,
|
||||||
|
reset: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
background: theme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
},
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
horizontal: false,
|
||||||
|
columnWidth: '55%',
|
||||||
|
borderRadius: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toString();
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: [theme === 'dark' ? '#fff' : '#000']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stroke: {
|
||||||
|
show: true,
|
||||||
|
width: 2,
|
||||||
|
colors: ['transparent']
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: [...new Set(data.map(item => item.tahun_angkatan))].sort(),
|
||||||
|
title: {
|
||||||
|
text: 'Tahun Angkatan',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisBorder: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
},
|
||||||
|
axisTicks: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
title: {
|
||||||
|
text: 'Jumlah Mahasiswa',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisBorder: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
opacity: 1
|
||||||
|
},
|
||||||
|
colors: ['#008FFB', '#00E396', '#FEB019', '#FF4560', '#775DD0'],
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
fontSize: '14px',
|
||||||
|
markers: {
|
||||||
|
size: 12,
|
||||||
|
},
|
||||||
|
itemMargin: {
|
||||||
|
horizontal: 10,
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
borderColor: theme === 'dark' ? '#374151' : '#E5E7EB',
|
||||||
|
strokeDashArray: 4,
|
||||||
|
padding: {
|
||||||
|
top: 20,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const series = processSeriesData();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Jenis Pendaftaran Mahasiswa Lulus Tepat Waktu
|
||||||
|
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[300px] sm:h-[350px] md:h-[400px] w-full max-w-5xl mx-auto">
|
||||||
|
{typeof window !== 'undefined' && series.length > 0 && (
|
||||||
|
<Chart
|
||||||
|
options={chartOptions}
|
||||||
|
series={series}
|
||||||
|
type="bar"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
191
components/JenisPendaftaranLulusPieChart.tsx
Normal file
191
components/JenisPendaftaranLulusPieChart.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface JenisPendaftaranLulusData {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
jenis_pendaftaran: string;
|
||||||
|
jumlah_lulus_tepat_waktu: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedYear: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JenisPendaftaranLulusPieChart({ selectedYear }: Props) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [data, setData] = useState<JenisPendaftaranLulusData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const url = `/api/mahasiswa/jenis-pendaftaran-lulus?tahun_angkatan=${selectedYear}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
setData(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in fetchData:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [selectedYear]);
|
||||||
|
|
||||||
|
const chartOptions: ApexOptions = {
|
||||||
|
chart: {
|
||||||
|
type: 'pie',
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
tools: {
|
||||||
|
download: true,
|
||||||
|
selection: true,
|
||||||
|
zoom: true,
|
||||||
|
zoomin: true,
|
||||||
|
zoomout: true,
|
||||||
|
pan: true,
|
||||||
|
reset: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
background: theme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
},
|
||||||
|
labels: [],
|
||||||
|
colors: ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'],
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
fontSize: '14px',
|
||||||
|
markers: {
|
||||||
|
size: 12,
|
||||||
|
strokeWidth: 0
|
||||||
|
},
|
||||||
|
itemMargin: {
|
||||||
|
horizontal: 10
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return `${val.toFixed(0)}%`;
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontFamily: 'Inter, sans-serif',
|
||||||
|
fontWeight: '500'
|
||||||
|
},
|
||||||
|
offsetY: 0,
|
||||||
|
dropShadow: {
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process data for series
|
||||||
|
const processSeriesData = () => {
|
||||||
|
const jenisPendaftaran = [...new Set(data.map(item => item.jenis_pendaftaran))].sort();
|
||||||
|
const jumlahData = jenisPendaftaran.map(jenis => {
|
||||||
|
const item = data.find(d => d.jenis_pendaftaran === jenis);
|
||||||
|
return item ? item.jumlah_lulus_tepat_waktu : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
series: jumlahData,
|
||||||
|
labels: jenisPendaftaran
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const { series, labels } = processSeriesData();
|
||||||
|
const totalMahasiswa = data.reduce((sum, item) => sum + item.jumlah_lulus_tepat_waktu, 0);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Jenis Pendaftaran Mahasiswa Lulus Tepat Waktu
|
||||||
|
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="text-lg font-semibold text-gray-600 dark:text-gray-300">
|
||||||
|
Total Mahasiswa: {totalMahasiswa}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[350px] w-full max-w-5xl mx-auto">
|
||||||
|
{typeof window !== 'undefined' && (
|
||||||
|
<Chart
|
||||||
|
options={{...chartOptions, labels}}
|
||||||
|
series={series}
|
||||||
|
type="pie"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
250
components/JenisPendaftaranPerAngkatanChart.tsx
Normal file
250
components/JenisPendaftaranPerAngkatanChart.tsx
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface JenisPendaftaranData {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
jenis_pendaftaran: string;
|
||||||
|
jumlah: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tahunAngkatan: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JenisPendaftaranPerAngkatanChart({ tahunAngkatan }: Props) {
|
||||||
|
const { theme, systemTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [data, setData] = useState<JenisPendaftaranData[]>([]);
|
||||||
|
const [series, setSeries] = useState<number[]>([]);
|
||||||
|
const [options, setOptions] = useState<ApexOptions>({
|
||||||
|
chart: {
|
||||||
|
type: 'pie',
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
tools: {
|
||||||
|
download: true,
|
||||||
|
selection: true,
|
||||||
|
zoom: true,
|
||||||
|
zoomin: true,
|
||||||
|
zoomout: true,
|
||||||
|
pan: true,
|
||||||
|
reset: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: [],
|
||||||
|
colors: ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'],
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
fontSize: '14px',
|
||||||
|
markers: {
|
||||||
|
size: 12,
|
||||||
|
strokeWidth: 0
|
||||||
|
},
|
||||||
|
itemMargin: {
|
||||||
|
horizontal: 10
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number, opts: any) {
|
||||||
|
return `${val.toFixed(0)}%`;
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontFamily: 'Inter, sans-serif',
|
||||||
|
fontWeight: '500'
|
||||||
|
},
|
||||||
|
offsetY: 0,
|
||||||
|
dropShadow: {
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update theme when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
const currentTheme = theme === 'system' ? systemTheme : theme;
|
||||||
|
setOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
chart: {
|
||||||
|
...prev.chart,
|
||||||
|
background: currentTheme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
...prev.legend,
|
||||||
|
labels: {
|
||||||
|
...prev.legend?.labels,
|
||||||
|
colors: currentTheme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
...prev.tooltip,
|
||||||
|
theme: currentTheme === 'dark' ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}, [theme, systemTheme]);
|
||||||
|
|
||||||
|
// Update dataLabels formatter when data changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (data.length > 0) {
|
||||||
|
setOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
dataLabels: {
|
||||||
|
...prev.dataLabels,
|
||||||
|
formatter: function(val: number, opts: any) {
|
||||||
|
// Calculate the percentage based on the current data
|
||||||
|
const total = data.reduce((sum, item) => sum + item.jumlah, 0);
|
||||||
|
const seriesIndex = opts.seriesIndex;
|
||||||
|
const jenisPendaftaran = prev.labels?.[seriesIndex];
|
||||||
|
|
||||||
|
if (jenisPendaftaran) {
|
||||||
|
const item = data.find(d => d.jenis_pendaftaran === jenisPendaftaran);
|
||||||
|
const jumlah = item ? item.jumlah : 0;
|
||||||
|
const percentage = (jumlah / total) * 100;
|
||||||
|
return `${percentage.toFixed(0)}%`;
|
||||||
|
}
|
||||||
|
return `${val.toFixed(0)}%`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/mahasiswa/jenis-pendaftaran?tahun_angkatan=${tahunAngkatan}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(result)) {
|
||||||
|
throw new Error('Invalid data format received from server');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process data for pie chart
|
||||||
|
const jenisPendaftaran = [...new Set(result.map(item => item.jenis_pendaftaran))].sort();
|
||||||
|
const jumlahData = jenisPendaftaran.map(jenis => {
|
||||||
|
const item = result.find(d => d.jenis_pendaftaran === jenis);
|
||||||
|
return item ? item.jumlah : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
setSeries(jumlahData);
|
||||||
|
setOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
labels: jenisPendaftaran,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Store processed data
|
||||||
|
setData(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in fetchData:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tahunAngkatan) {
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
}, [tahunAngkatan]);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500 dark:text-red-400">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalMahasiswa = data.reduce((sum, item) => sum + item.jumlah, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Jenis Pendaftaran Angkatan {tahunAngkatan}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="text-lg font-semibold text-gray-600 dark:text-gray-300">
|
||||||
|
Total Mahasiswa: {totalMahasiswa}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[350px] w-full max-w-5xl mx-auto">
|
||||||
|
<Chart
|
||||||
|
options={options}
|
||||||
|
series={series}
|
||||||
|
type="pie"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
263
components/JenisPendaftaranPrestasiChart.tsx
Normal file
263
components/JenisPendaftaranPrestasiChart.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface JenisPendaftaranPrestasiData {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
jenis_pendaftaran: string;
|
||||||
|
jenis_pendaftaran_mahasiswa_prestasi: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedJenisPrestasi: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JenisPendaftaranPrestasiChart({ selectedJenisPrestasi }: Props) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [data, setData] = useState<JenisPendaftaranPrestasiData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const url = `/api/mahasiswa/jenis-pendaftaran-prestasi?jenisPrestasi=${selectedJenisPrestasi}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(result)) {
|
||||||
|
throw new Error('Invalid data format received from server');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort data by tahun_angkatan
|
||||||
|
const sortedData = result.sort((a: JenisPendaftaranPrestasiData, b: JenisPendaftaranPrestasiData) =>
|
||||||
|
a.tahun_angkatan - b.tahun_angkatan
|
||||||
|
);
|
||||||
|
|
||||||
|
setData(sortedData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in fetchData:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [selectedJenisPrestasi]);
|
||||||
|
|
||||||
|
// Process data for series
|
||||||
|
const processSeriesData = () => {
|
||||||
|
if (!data.length) return [];
|
||||||
|
|
||||||
|
const years = [...new Set(data.map(item => item.tahun_angkatan))].sort();
|
||||||
|
const pendaftaranTypes = [...new Set(data.map(item => item.jenis_pendaftaran))].sort();
|
||||||
|
|
||||||
|
return pendaftaranTypes.map(pendaftaran => ({
|
||||||
|
name: pendaftaran,
|
||||||
|
data: years.map(year => {
|
||||||
|
const item = data.find(d => d.tahun_angkatan === year && d.jenis_pendaftaran === pendaftaran);
|
||||||
|
return item?.jenis_pendaftaran_mahasiswa_prestasi || 0;
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartOptions: ApexOptions = {
|
||||||
|
chart: {
|
||||||
|
type: 'bar',
|
||||||
|
stacked: false,
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
tools: {
|
||||||
|
download: true,
|
||||||
|
selection: true,
|
||||||
|
zoom: true,
|
||||||
|
zoomin: true,
|
||||||
|
zoomout: true,
|
||||||
|
pan: true,
|
||||||
|
reset: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
background: theme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
},
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
horizontal: false,
|
||||||
|
columnWidth: '55%',
|
||||||
|
borderRadius: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val?.toString() || '0';
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: [theme === 'dark' ? '#fff' : '#000']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stroke: {
|
||||||
|
show: true,
|
||||||
|
width: 2,
|
||||||
|
colors: ['transparent']
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: [...new Set(data.map(item => item.tahun_angkatan))].sort(),
|
||||||
|
title: {
|
||||||
|
text: 'Tahun Angkatan',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisBorder: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
},
|
||||||
|
axisTicks: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
title: {
|
||||||
|
text: 'Jumlah Mahasiswa',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisBorder: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
opacity: 1
|
||||||
|
},
|
||||||
|
colors: ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4'],
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
fontSize: '14px',
|
||||||
|
markers: {
|
||||||
|
size: 12,
|
||||||
|
},
|
||||||
|
itemMargin: {
|
||||||
|
horizontal: 10,
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
borderColor: theme === 'dark' ? '#374151' : '#E5E7EB',
|
||||||
|
strokeDashArray: 4,
|
||||||
|
padding: {
|
||||||
|
top: 20,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const series = processSeriesData();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Jenis Pendaftaran Mahasiswa Berprestasi {selectedJenisPrestasi}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[300px] sm:h-[350px] md:h-[400px] w-full max-w-5xl mx-auto">
|
||||||
|
{typeof window !== 'undefined' && series.length > 0 && (
|
||||||
|
<Chart
|
||||||
|
options={chartOptions}
|
||||||
|
series={series}
|
||||||
|
type="bar"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
188
components/JenisPendaftaranPrestasiPieChart.tsx
Normal file
188
components/JenisPendaftaranPrestasiPieChart.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface JenisPendaftaranPrestasiData {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
jenis_pendaftaran: string;
|
||||||
|
jenis_pendaftaran_mahasiswa_prestasi: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedYear: string;
|
||||||
|
selectedJenisPrestasi: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JenisPendaftaranPrestasiPieChart({ selectedYear, selectedJenisPrestasi }: Props) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [data, setData] = useState<JenisPendaftaranPrestasiData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const url = `/api/mahasiswa/jenis-pendaftaran-prestasi?tahunAngkatan=${selectedYear}&jenisPrestasi=${selectedJenisPrestasi}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
setData(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in fetchData:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [selectedYear, selectedJenisPrestasi]);
|
||||||
|
|
||||||
|
const chartOptions: ApexOptions = {
|
||||||
|
chart: {
|
||||||
|
type: 'pie',
|
||||||
|
background: theme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
tools: {
|
||||||
|
download: true,
|
||||||
|
selection: true,
|
||||||
|
zoom: true,
|
||||||
|
zoomin: true,
|
||||||
|
zoomout: true,
|
||||||
|
pan: true,
|
||||||
|
reset: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: [],
|
||||||
|
colors: ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4'],
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
fontSize: '14px',
|
||||||
|
markers: {
|
||||||
|
size: 12,
|
||||||
|
strokeWidth: 0
|
||||||
|
},
|
||||||
|
itemMargin: {
|
||||||
|
horizontal: 10
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return `${val.toFixed(0)}%`;
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontFamily: 'Inter, sans-serif',
|
||||||
|
fontWeight: '500'
|
||||||
|
},
|
||||||
|
offsetY: 0,
|
||||||
|
dropShadow: {
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process data for series
|
||||||
|
const processSeriesData = () => {
|
||||||
|
const filtered = data.filter(item => String(item.tahun_angkatan) === String(selectedYear));
|
||||||
|
const jenisPendaftaran = [...new Set(filtered.map(item => item.jenis_pendaftaran))].sort();
|
||||||
|
const jumlahData = jenisPendaftaran.map(jenis => {
|
||||||
|
const item = filtered.find(d => d.jenis_pendaftaran === jenis);
|
||||||
|
return item ? item.jenis_pendaftaran_mahasiswa_prestasi : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
series: jumlahData,
|
||||||
|
labels: jenisPendaftaran
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const { series, labels } = processSeriesData();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Jenis Pendaftaran Mahasiswa Berprestasi {selectedJenisPrestasi} Angkatan {selectedYear}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[350px] w-full max-w-5xl mx-auto">
|
||||||
|
{typeof window !== 'undefined' && (
|
||||||
|
<Chart
|
||||||
|
options={{...chartOptions, labels}}
|
||||||
|
series={series}
|
||||||
|
type="pie"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
250
components/JenisPendaftaranStatusChart.tsx
Normal file
250
components/JenisPendaftaranStatusChart.tsx
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface JenisPendaftaranStatusData {
|
||||||
|
jenis_pendaftaran: string;
|
||||||
|
tahun_angkatan: number;
|
||||||
|
status_kuliah: string;
|
||||||
|
total_mahasiswa: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedYear: string;
|
||||||
|
selectedStatus: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JenisPendaftaranStatusChart({ selectedYear, selectedStatus }: Props) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [data, setData] = useState<JenisPendaftaranStatusData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
console.log('Fetching data with params:', { selectedYear, selectedStatus });
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/mahasiswa/jenis-pendaftaran-status?tahun_angkatan=${selectedYear}&status_kuliah=${selectedStatus}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch data');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('Received data:', result);
|
||||||
|
|
||||||
|
// Sort data by tahun_angkatan
|
||||||
|
const sortedData = result.sort((a: JenisPendaftaranStatusData, b: JenisPendaftaranStatusData) =>
|
||||||
|
Number(a.tahun_angkatan) - Number(b.tahun_angkatan)
|
||||||
|
);
|
||||||
|
|
||||||
|
setData(sortedData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in fetchData:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [selectedYear, selectedStatus]);
|
||||||
|
|
||||||
|
// Log data changes
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('Current data state:', data);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// Get unique years and sort them
|
||||||
|
const years = [...new Set(data.map(item => item.tahun_angkatan))].sort((a, b) => Number(a) - Number(b));
|
||||||
|
console.log('Sorted years:', years);
|
||||||
|
|
||||||
|
// Get unique jenis pendaftaran
|
||||||
|
const jenisPendaftaran = [...new Set(data.map(item => item.jenis_pendaftaran))];
|
||||||
|
console.log('Jenis pendaftaran:', jenisPendaftaran);
|
||||||
|
|
||||||
|
const chartOptions: ApexOptions = {
|
||||||
|
chart: {
|
||||||
|
type: 'bar',
|
||||||
|
stacked: false,
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
},
|
||||||
|
background: theme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
},
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
horizontal: false,
|
||||||
|
columnWidth: '55%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toString();
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: [theme === 'dark' ? '#fff' : '#000']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stroke: {
|
||||||
|
show: true,
|
||||||
|
width: 2,
|
||||||
|
colors: ['transparent'],
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: years,
|
||||||
|
title: {
|
||||||
|
text: 'Tahun Angkatan',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
title: {
|
||||||
|
text: 'Jumlah Mahasiswa',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
fontSize: '14px',
|
||||||
|
markers: {
|
||||||
|
size: 12,
|
||||||
|
},
|
||||||
|
itemMargin: {
|
||||||
|
horizontal: 10,
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors: ['#008FFB', '#00E396', '#FEB019', '#FF4560', '#775DD0'],
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process data for series
|
||||||
|
const processSeriesData = () => {
|
||||||
|
return jenisPendaftaran.map(jenis => {
|
||||||
|
const seriesData = new Array(years.length).fill(0);
|
||||||
|
|
||||||
|
data.forEach(item => {
|
||||||
|
if (item.jenis_pendaftaran === jenis) {
|
||||||
|
const yearIndex = years.indexOf(item.tahun_angkatan);
|
||||||
|
if (yearIndex !== -1) {
|
||||||
|
seriesData[yearIndex] = item.total_mahasiswa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: jenis,
|
||||||
|
data: seriesData
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const series = processSeriesData();
|
||||||
|
console.log('Processed series data:', series);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Jenis Pendaftaran Mahasiswa {selectedStatus}
|
||||||
|
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[350px] w-full max-w-5xl mx-auto">
|
||||||
|
{typeof window !== 'undefined' && (
|
||||||
|
<Chart
|
||||||
|
options={chartOptions}
|
||||||
|
series={series}
|
||||||
|
type="bar"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
193
components/JenisPendaftaranStatusPieChart.tsx
Normal file
193
components/JenisPendaftaranStatusPieChart.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface JenisPendaftaranStatusData {
|
||||||
|
jenis_pendaftaran: string;
|
||||||
|
tahun_angkatan: number;
|
||||||
|
status_kuliah: string;
|
||||||
|
total_mahasiswa: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedYear: string;
|
||||||
|
selectedStatus: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JenisPendaftaranStatusPieChart({ selectedYear, selectedStatus }: Props) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [data, setData] = useState<JenisPendaftaranStatusData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/mahasiswa/jenis-pendaftaran-status?tahun_angkatan=${selectedYear}&status_kuliah=${selectedStatus}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch data');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
setData(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in fetchData:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [selectedYear, selectedStatus]);
|
||||||
|
|
||||||
|
const chartOptions: ApexOptions = {
|
||||||
|
chart: {
|
||||||
|
type: 'pie',
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
tools: {
|
||||||
|
download: true,
|
||||||
|
selection: true,
|
||||||
|
zoom: true,
|
||||||
|
zoomin: true,
|
||||||
|
zoomout: true,
|
||||||
|
pan: true,
|
||||||
|
reset: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
background: theme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
},
|
||||||
|
labels: [],
|
||||||
|
colors: ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'],
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
fontSize: '14px',
|
||||||
|
markers: {
|
||||||
|
size: 12,
|
||||||
|
strokeWidth: 0
|
||||||
|
},
|
||||||
|
itemMargin: {
|
||||||
|
horizontal: 10
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return `${val.toFixed(0)}%`;
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontFamily: 'Inter, sans-serif',
|
||||||
|
fontWeight: '500'
|
||||||
|
},
|
||||||
|
offsetY: 0,
|
||||||
|
dropShadow: {
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process data for series
|
||||||
|
const processSeriesData = () => {
|
||||||
|
const jenisPendaftaran = [...new Set(data.map(item => item.jenis_pendaftaran))].sort();
|
||||||
|
const jumlahData = jenisPendaftaran.map(jenis => {
|
||||||
|
const item = data.find(d => d.jenis_pendaftaran === jenis);
|
||||||
|
return item ? item.total_mahasiswa : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
series: jumlahData,
|
||||||
|
labels: jenisPendaftaran
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const { series, labels } = processSeriesData();
|
||||||
|
const totalMahasiswa = data.reduce((sum, item) => sum + item.total_mahasiswa, 0);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Jenis Pendaftaran Mahasiswa {selectedStatus}
|
||||||
|
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="text-lg font-semibold text-gray-600 dark:text-gray-300">
|
||||||
|
Total Mahasiswa: {totalMahasiswa}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[350px] w-full max-w-5xl mx-auto">
|
||||||
|
{typeof window !== 'undefined' && (
|
||||||
|
<Chart
|
||||||
|
options={{...chartOptions, labels}}
|
||||||
|
series={series}
|
||||||
|
type="pie"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
280
components/LulusTepatWaktuChart.tsx
Normal file
280
components/LulusTepatWaktuChart.tsx
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface LulusTepatWaktuData {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
jk: string;
|
||||||
|
jumlah_lulus_tepat_waktu: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedYear: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LulusTepatWaktuChart({ selectedYear }: Props) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [data, setData] = useState<LulusTepatWaktuData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const url = `/api/mahasiswa/lulus-tepat-waktu?tahun_angkatan=${selectedYear}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('Error response:', errorText);
|
||||||
|
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(result)) {
|
||||||
|
console.error('Invalid data format:', result);
|
||||||
|
throw new Error('Invalid data format received from server');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort data by tahun_angkatan
|
||||||
|
const sortedData = result.sort((a: LulusTepatWaktuData, b: LulusTepatWaktuData) =>
|
||||||
|
a.tahun_angkatan - b.tahun_angkatan
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Sorted data:', sortedData);
|
||||||
|
setData(sortedData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in fetchData:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [selectedYear]);
|
||||||
|
|
||||||
|
// Process data for series
|
||||||
|
const processSeriesData = () => {
|
||||||
|
if (!data.length) return [];
|
||||||
|
|
||||||
|
const years = [...new Set(data.map(item => item.tahun_angkatan))].sort();
|
||||||
|
|
||||||
|
const pria = years.map(year => {
|
||||||
|
const item = data.find(d => d.tahun_angkatan === year && d.jk === 'Pria');
|
||||||
|
return item ? item.jumlah_lulus_tepat_waktu : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const wanita = years.map(year => {
|
||||||
|
const item = data.find(d => d.tahun_angkatan === year && d.jk === 'Wanita');
|
||||||
|
return item ? item.jumlah_lulus_tepat_waktu : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'Laki-laki',
|
||||||
|
data: pria
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Perempuan',
|
||||||
|
data: wanita
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartOptions: ApexOptions = {
|
||||||
|
chart: {
|
||||||
|
type: 'bar',
|
||||||
|
stacked: false,
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
tools: {
|
||||||
|
download: true,
|
||||||
|
selection: true,
|
||||||
|
zoom: true,
|
||||||
|
zoomin: true,
|
||||||
|
zoomout: true,
|
||||||
|
pan: true,
|
||||||
|
reset: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
background: theme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
},
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
horizontal: false,
|
||||||
|
columnWidth: '55%',
|
||||||
|
borderRadius: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toString();
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: [theme === 'dark' ? '#fff' : '#000']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stroke: {
|
||||||
|
show: true,
|
||||||
|
width: 2,
|
||||||
|
colors: ['transparent']
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: [...new Set(data.map(item => item.tahun_angkatan))].sort(),
|
||||||
|
title: {
|
||||||
|
text: 'Tahun Angkatan',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisBorder: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
},
|
||||||
|
axisTicks: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
title: {
|
||||||
|
text: 'Jumlah Mahasiswa',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisBorder: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
opacity: 1
|
||||||
|
},
|
||||||
|
colors: ['#3B82F6', '#EC4899'],
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
fontSize: '14px',
|
||||||
|
markers: {
|
||||||
|
size: 12,
|
||||||
|
},
|
||||||
|
itemMargin: {
|
||||||
|
horizontal: 10,
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
borderColor: theme === 'dark' ? '#374151' : '#E5E7EB',
|
||||||
|
strokeDashArray: 4,
|
||||||
|
padding: {
|
||||||
|
top: 20,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const series = processSeriesData();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Mahasiswa Lulus Tepat Waktu
|
||||||
|
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[300px] sm:h-[350px] md:h-[400px] w-full max-w-5xl mx-auto">
|
||||||
|
{typeof window !== 'undefined' && series.length > 0 && (
|
||||||
|
<Chart
|
||||||
|
options={chartOptions}
|
||||||
|
series={series}
|
||||||
|
type="bar"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
components/LulusTepatWaktuPieChart.tsx
Normal file
184
components/LulusTepatWaktuPieChart.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface LulusTepatWaktuData {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
jk: string;
|
||||||
|
jumlah_lulus_tepat_waktu: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedYear: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LulusTepatWaktuPieChart({ selectedYear }: Props) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [data, setData] = useState<LulusTepatWaktuData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const url = `/api/mahasiswa/lulus-tepat-waktu?tahun_angkatan=${selectedYear}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
setData(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in fetchData:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [selectedYear]);
|
||||||
|
|
||||||
|
const chartOptions: ApexOptions = {
|
||||||
|
chart: {
|
||||||
|
type: 'pie',
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
tools: {
|
||||||
|
download: true,
|
||||||
|
selection: true,
|
||||||
|
zoom: true,
|
||||||
|
zoomin: true,
|
||||||
|
zoomout: true,
|
||||||
|
pan: true,
|
||||||
|
reset: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
background: theme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
},
|
||||||
|
labels: ['Laki-laki', 'Perempuan'],
|
||||||
|
colors: ['#3B82F6', '#EC4899'],
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
fontSize: '14px',
|
||||||
|
markers: {
|
||||||
|
size: 12,
|
||||||
|
strokeWidth: 0
|
||||||
|
},
|
||||||
|
itemMargin: {
|
||||||
|
horizontal: 10
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return `${val.toFixed(0)}%`;
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontFamily: 'Inter, sans-serif',
|
||||||
|
fontWeight: '500'
|
||||||
|
},
|
||||||
|
offsetY: 0,
|
||||||
|
dropShadow: {
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process data for series
|
||||||
|
const processSeriesData = () => {
|
||||||
|
const maleData = data.find(item => item.jk === 'Pria')?.jumlah_lulus_tepat_waktu || 0;
|
||||||
|
const femaleData = data.find(item => item.jk === 'Wanita')?.jumlah_lulus_tepat_waktu || 0;
|
||||||
|
return [maleData, femaleData];
|
||||||
|
};
|
||||||
|
|
||||||
|
const series = processSeriesData();
|
||||||
|
const totalMahasiswa = data.reduce((sum, item) => sum + item.jumlah_lulus_tepat_waktu, 0);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Mahasiswa Lulus Tepat Waktu
|
||||||
|
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="text-lg font-semibold text-gray-600 dark:text-gray-300">
|
||||||
|
Total Mahasiswa: {totalMahasiswa}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[350px] w-full max-w-5xl mx-auto">
|
||||||
|
{typeof window !== 'undefined' && (
|
||||||
|
<Chart
|
||||||
|
options={chartOptions}
|
||||||
|
series={series}
|
||||||
|
type="pie"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
265
components/NamaBeasiswaChart.tsx
Normal file
265
components/NamaBeasiswaChart.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface NamaBeasiswaData {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
nama_beasiswa: string;
|
||||||
|
jumlah_nama_beasiswa: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedYear: string;
|
||||||
|
selectedJenisBeasiswa: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NamaBeasiswaChart({ selectedYear, selectedJenisBeasiswa }: Props) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [data, setData] = useState<NamaBeasiswaData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const url = `/api/mahasiswa/nama-beasiswa?tahunAngkatan=${selectedYear}&jenisBeasiswa=${selectedJenisBeasiswa}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(result)) {
|
||||||
|
throw new Error('Invalid data format received from server');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort data by tahun_angkatan
|
||||||
|
const sortedData = result.sort((a: NamaBeasiswaData, b: NamaBeasiswaData) =>
|
||||||
|
a.tahun_angkatan - b.tahun_angkatan
|
||||||
|
);
|
||||||
|
|
||||||
|
setData(sortedData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in fetchData:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [selectedYear, selectedJenisBeasiswa]);
|
||||||
|
|
||||||
|
// Process data for series
|
||||||
|
const processSeriesData = () => {
|
||||||
|
if (!data.length) return [];
|
||||||
|
|
||||||
|
const years = [...new Set(data.map(item => item.tahun_angkatan))].sort();
|
||||||
|
const beasiswaTypes = [...new Set(data.map(item => item.nama_beasiswa))].sort();
|
||||||
|
|
||||||
|
return beasiswaTypes.map(beasiswa => ({
|
||||||
|
name: beasiswa,
|
||||||
|
data: years.map(year => {
|
||||||
|
const item = data.find(d => d.tahun_angkatan === year && d.nama_beasiswa === beasiswa);
|
||||||
|
return item ? item.jumlah_nama_beasiswa : 0;
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartOptions: ApexOptions = {
|
||||||
|
chart: {
|
||||||
|
type: 'bar',
|
||||||
|
stacked: false,
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
tools: {
|
||||||
|
download: true,
|
||||||
|
selection: true,
|
||||||
|
zoom: true,
|
||||||
|
zoomin: true,
|
||||||
|
zoomout: true,
|
||||||
|
pan: true,
|
||||||
|
reset: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
background: theme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
},
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
horizontal: false,
|
||||||
|
columnWidth: '55%',
|
||||||
|
borderRadius: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toString();
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: [theme === 'dark' ? '#fff' : '#000']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stroke: {
|
||||||
|
show: true,
|
||||||
|
width: 2,
|
||||||
|
colors: ['transparent']
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: [...new Set(data.map(item => item.tahun_angkatan))].sort(),
|
||||||
|
title: {
|
||||||
|
text: 'Tahun Angkatan',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisBorder: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
},
|
||||||
|
axisTicks: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
title: {
|
||||||
|
text: 'Jumlah Mahasiswa',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisBorder: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
opacity: 1
|
||||||
|
},
|
||||||
|
colors: ['#3B82F6', '#EC4899', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#06B6D4'],
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
fontSize: '14px',
|
||||||
|
markers: {
|
||||||
|
size: 12,
|
||||||
|
},
|
||||||
|
itemMargin: {
|
||||||
|
horizontal: 10,
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
borderColor: theme === 'dark' ? '#374151' : '#E5E7EB',
|
||||||
|
strokeDashArray: 4,
|
||||||
|
padding: {
|
||||||
|
top: 20,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const series = processSeriesData();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Nama Beasiswa {selectedJenisBeasiswa}
|
||||||
|
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[400px] sm:h-[350px] md:h-[400px] w-full max-w-5xl mx-auto">
|
||||||
|
{typeof window !== 'undefined' && series.length > 0 && (
|
||||||
|
<Chart
|
||||||
|
options={chartOptions}
|
||||||
|
series={series}
|
||||||
|
type="bar"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
components/NamaBeasiswaPieChart.tsx
Normal file
192
components/NamaBeasiswaPieChart.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface NamaBeasiswaData {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
nama_beasiswa: string;
|
||||||
|
jumlah_nama_beasiswa: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedYear: string;
|
||||||
|
selectedJenisBeasiswa: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NamaBeasiswaPieChart({ selectedYear, selectedJenisBeasiswa }: Props) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [data, setData] = useState<NamaBeasiswaData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const url = `/api/mahasiswa/nama-beasiswa?tahunAngkatan=${selectedYear}&jenisBeasiswa=${selectedJenisBeasiswa}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
setData(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in fetchData:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [selectedYear, selectedJenisBeasiswa]);
|
||||||
|
|
||||||
|
const chartOptions: ApexOptions = {
|
||||||
|
chart: {
|
||||||
|
type: 'pie',
|
||||||
|
background: theme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
tools: {
|
||||||
|
download: true,
|
||||||
|
selection: true,
|
||||||
|
zoom: true,
|
||||||
|
zoomin: true,
|
||||||
|
zoomout: true,
|
||||||
|
pan: true,
|
||||||
|
reset: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: [],
|
||||||
|
colors: ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4'],
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
fontSize: '14px',
|
||||||
|
markers: {
|
||||||
|
size: 12,
|
||||||
|
strokeWidth: 0
|
||||||
|
},
|
||||||
|
itemMargin: {
|
||||||
|
horizontal: 10
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return `${val.toFixed(0)}%`;
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontFamily: 'Inter, sans-serif',
|
||||||
|
fontWeight: '500'
|
||||||
|
},
|
||||||
|
offsetY: 0,
|
||||||
|
dropShadow: {
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process data for series
|
||||||
|
const processSeriesData = () => {
|
||||||
|
const namaBeasiswa = [...new Set(data.map(item => item.nama_beasiswa))].sort();
|
||||||
|
const jumlahData = namaBeasiswa.map(nama => {
|
||||||
|
const item = data.find(d => d.nama_beasiswa === nama);
|
||||||
|
return item ? item.jumlah_nama_beasiswa : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
series: jumlahData,
|
||||||
|
labels: namaBeasiswa
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const { series, labels } = processSeriesData();
|
||||||
|
const totalMahasiswa = data.reduce((sum, item) => sum + item.jumlah_nama_beasiswa, 0);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Nama Beasiswa {selectedJenisBeasiswa}
|
||||||
|
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="text-lg font-semibold text-gray-600 dark:text-gray-300">
|
||||||
|
Total Mahasiswa: {totalMahasiswa}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[350px] w-full max-w-5xl mx-auto">
|
||||||
|
{typeof window !== 'undefined' && (
|
||||||
|
<Chart
|
||||||
|
options={{...chartOptions, labels}}
|
||||||
|
series={series}
|
||||||
|
type="pie"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
318
components/StatistikMahasiswaChart.tsx
Normal file
318
components/StatistikMahasiswaChart.tsx
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
|
||||||
|
// Import ApexCharts secara dinamis untuk menghindari error SSR
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface MahasiswaStatistik {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
total_mahasiswa: number;
|
||||||
|
pria: number;
|
||||||
|
wanita: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatistikMahasiswaChart() {
|
||||||
|
const { theme, systemTheme } = useTheme();
|
||||||
|
const [statistikData, setStatistikData] = useState<MahasiswaStatistik[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [chartOptions, setChartOptions] = useState({
|
||||||
|
chart: {
|
||||||
|
type: 'bar' as const,
|
||||||
|
stacked: false,
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
tools: {
|
||||||
|
download: true,
|
||||||
|
selection: true,
|
||||||
|
zoom: true,
|
||||||
|
zoomin: true,
|
||||||
|
zoomout: true,
|
||||||
|
pan: true,
|
||||||
|
reset: true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
zoom: {
|
||||||
|
enabled: true,
|
||||||
|
type: 'x' as const,
|
||||||
|
autoScaleYaxis: true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
markers: {
|
||||||
|
size: 10,
|
||||||
|
shape: 'circle' as const,
|
||||||
|
strokeWidth: 0,
|
||||||
|
hover: {
|
||||||
|
size: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
horizontal: false,
|
||||||
|
columnWidth: '55%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toString()
|
||||||
|
},
|
||||||
|
position: 'top',
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: ['#000']
|
||||||
|
},
|
||||||
|
},
|
||||||
|
stroke: {
|
||||||
|
show: true,
|
||||||
|
width: [0, 0, 2],
|
||||||
|
colors: ['transparent', 'transparent', '#10B981'],
|
||||||
|
curve: 'straight' as const
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: [] as number[],
|
||||||
|
title: {
|
||||||
|
text: 'Tahun Angkatan',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: [
|
||||||
|
{
|
||||||
|
title: {
|
||||||
|
text: 'Jumlah Mahasiswa',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
fill: {
|
||||||
|
opacity: 1
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'top' as const,
|
||||||
|
fontSize: '14px',
|
||||||
|
markers: {
|
||||||
|
size: 12,
|
||||||
|
},
|
||||||
|
itemMargin: {
|
||||||
|
horizontal: 10,
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
colors: '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors: ['#3B82F6', '#EC4899', '#10B981'],
|
||||||
|
tooltip: {
|
||||||
|
theme: 'light',
|
||||||
|
y: [
|
||||||
|
{
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const statistikResponse = await fetch('/api/mahasiswa/statistik', {
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!statistikResponse.ok) {
|
||||||
|
throw new Error('Failed to fetch statistik data');
|
||||||
|
}
|
||||||
|
|
||||||
|
const statistikData = await statistikResponse.json();
|
||||||
|
setStatistikData(statistikData);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
|
console.error('Error fetching data:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update theme when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
const currentTheme = theme === 'system' ? systemTheme : theme;
|
||||||
|
const textColor = currentTheme === 'dark' ? '#fff' : '#000';
|
||||||
|
const tooltipTheme = currentTheme === 'dark' ? 'dark' : 'light';
|
||||||
|
|
||||||
|
setChartOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
chart: {
|
||||||
|
...prev.chart,
|
||||||
|
background: currentTheme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
...prev.dataLabels,
|
||||||
|
style: {
|
||||||
|
...prev.dataLabels.style,
|
||||||
|
colors: [textColor]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
...prev.xaxis,
|
||||||
|
title: {
|
||||||
|
...prev.xaxis.title,
|
||||||
|
style: {
|
||||||
|
...prev.xaxis.title.style,
|
||||||
|
color: textColor
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
...prev.xaxis.labels,
|
||||||
|
style: {
|
||||||
|
...prev.xaxis.labels.style,
|
||||||
|
colors: textColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: [
|
||||||
|
{
|
||||||
|
...prev.yaxis[0],
|
||||||
|
title: {
|
||||||
|
...prev.yaxis[0].title,
|
||||||
|
style: {
|
||||||
|
...prev.yaxis[0].title.style,
|
||||||
|
color: textColor
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
...prev.yaxis[0].labels,
|
||||||
|
style: {
|
||||||
|
...prev.yaxis[0].labels.style,
|
||||||
|
colors: textColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
legend: {
|
||||||
|
...prev.legend,
|
||||||
|
labels: {
|
||||||
|
...prev.legend.labels,
|
||||||
|
colors: textColor
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
...prev.tooltip,
|
||||||
|
theme: tooltipTheme
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}, [theme, systemTheme]);
|
||||||
|
|
||||||
|
// Update categories when data changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (statistikData.length > 0) {
|
||||||
|
setChartOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
xaxis: {
|
||||||
|
...prev.xaxis,
|
||||||
|
categories: statistikData.map(item => item.tahun_angkatan)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [statistikData]);
|
||||||
|
|
||||||
|
const chartSeries = [
|
||||||
|
{
|
||||||
|
name: 'Laki-laki',
|
||||||
|
type: 'bar' as const,
|
||||||
|
data: statistikData.map(item => item.pria)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Perempuan',
|
||||||
|
type: 'bar' as const,
|
||||||
|
data: statistikData.map(item => item.wanita)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Total',
|
||||||
|
type: 'line' as const,
|
||||||
|
data: statistikData.map(item => item.total_mahasiswa)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500 dark:text-red-400">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Total Mahasiswa
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[300px] sm:h-[350px] md:h-[400px] w-full max-w-5xl mx-auto">
|
||||||
|
<Chart
|
||||||
|
options={chartOptions}
|
||||||
|
series={chartSeries}
|
||||||
|
type="bar"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
238
components/StatistikPerAngkatanChart.tsx
Normal file
238
components/StatistikPerAngkatanChart.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface GenderStatistik {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
jk: string;
|
||||||
|
jumlah: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tahunAngkatan: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatistikPerAngkatanChart({ tahunAngkatan }: Props) {
|
||||||
|
const { theme, systemTheme } = useTheme();
|
||||||
|
const [statistikData, setStatistikData] = useState<GenderStatistik[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/mahasiswa/gender-per-angkatan?tahun=${tahunAngkatan}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(result)) {
|
||||||
|
throw new Error('Invalid data format received from server');
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatistikData(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in fetchData:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tahunAngkatan !== "all") {
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
}, [tahunAngkatan]);
|
||||||
|
|
||||||
|
const [chartOptions, setChartOptions] = useState({
|
||||||
|
chart: {
|
||||||
|
type: 'pie' as const,
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
tools: {
|
||||||
|
download: true,
|
||||||
|
selection: true,
|
||||||
|
zoom: true,
|
||||||
|
zoomin: true,
|
||||||
|
zoomout: true,
|
||||||
|
pan: true,
|
||||||
|
reset: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
background: 'transparent',
|
||||||
|
},
|
||||||
|
labels: ['Laki-laki', 'Perempuan'],
|
||||||
|
colors: ['#3B82F6', '#EC4899'],
|
||||||
|
legend: {
|
||||||
|
position: 'bottom' as const,
|
||||||
|
fontSize: '14px',
|
||||||
|
markers: {
|
||||||
|
size: 12,
|
||||||
|
strokeWidth: 0
|
||||||
|
},
|
||||||
|
itemMargin: {
|
||||||
|
horizontal: 10
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
colors: '#000',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontFamily: 'Inter, sans-serif',
|
||||||
|
fontWeight: '500'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number, opts: any) {
|
||||||
|
return val.toString();
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontFamily: 'Inter, sans-serif',
|
||||||
|
fontWeight: '500'
|
||||||
|
},
|
||||||
|
offsetY: 0,
|
||||||
|
dropShadow: {
|
||||||
|
enabled: false
|
||||||
|
},
|
||||||
|
position: 'top'
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
theme: 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update theme when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
const currentTheme = theme === 'system' ? systemTheme : theme;
|
||||||
|
const textColor = currentTheme === 'dark' ? '#fff' : '#000';
|
||||||
|
const tooltipTheme = currentTheme === 'dark' ? 'dark' : 'light';
|
||||||
|
|
||||||
|
setChartOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
chart: {
|
||||||
|
...prev.chart,
|
||||||
|
background: currentTheme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
...prev.legend,
|
||||||
|
labels: {
|
||||||
|
...prev.legend.labels,
|
||||||
|
colors: textColor,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
...prev.dataLabels,
|
||||||
|
style: {
|
||||||
|
...prev.dataLabels.style,
|
||||||
|
color: textColor
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
...prev.tooltip,
|
||||||
|
theme: tooltipTheme
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}, [theme, systemTheme]);
|
||||||
|
|
||||||
|
// Update dataLabels formatter when data changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (statistikData.length > 0) {
|
||||||
|
setChartOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
dataLabels: {
|
||||||
|
...prev.dataLabels,
|
||||||
|
formatter: function (val: number, opts: any) {
|
||||||
|
const total = statistikData.reduce((sum, item) => sum + item.jumlah, 0);
|
||||||
|
const percentage = ((statistikData[opts.seriesIndex]?.jumlah / total) * 100) || 0;
|
||||||
|
return `${percentage.toFixed(0)}%`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [statistikData]);
|
||||||
|
|
||||||
|
const chartSeries = statistikData.map(item => item.jumlah);
|
||||||
|
const totalMahasiswa = statistikData.reduce((sum, item) => sum + item.jumlah, 0);
|
||||||
|
|
||||||
|
if (tahunAngkatan === "all") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500 dark:text-red-400">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statistikData.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Total Mahasiswa Angkatan {tahunAngkatan}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="text-lg font-semibold text-gray-600 dark:text-gray-300">
|
||||||
|
Total Mahasiswa: {totalMahasiswa}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[350px] w-full max-w-5xl mx-auto">
|
||||||
|
<Chart
|
||||||
|
options={chartOptions}
|
||||||
|
series={chartSeries}
|
||||||
|
type="pie"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
289
components/StatusMahasiswaChart.tsx
Normal file
289
components/StatusMahasiswaChart.tsx
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface StatusData {
|
||||||
|
tahun_angkatan: string;
|
||||||
|
status_kuliah: string;
|
||||||
|
jumlah: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatusMahasiswaChart() {
|
||||||
|
const { theme, systemTheme } = useTheme();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [data, setData] = useState<StatusData[]>([]);
|
||||||
|
const [series, setSeries] = useState<ApexAxisChartSeries>([]);
|
||||||
|
const [options, setOptions] = useState<ApexOptions>({
|
||||||
|
chart: {
|
||||||
|
type: 'bar',
|
||||||
|
stacked: false,
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
horizontal: false,
|
||||||
|
columnWidth: '55%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toString();
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: ['#000']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stroke: {
|
||||||
|
show: true,
|
||||||
|
width: 2,
|
||||||
|
colors: ['transparent'],
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: [],
|
||||||
|
title: {
|
||||||
|
text: 'Tahun Angkatan',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
title: {
|
||||||
|
text: 'Jumlah Mahasiswa',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
fontSize: '14px',
|
||||||
|
markers: {
|
||||||
|
size: 12,
|
||||||
|
},
|
||||||
|
itemMargin: {
|
||||||
|
horizontal: 10,
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
colors: '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors: ['#008FFB', '#00E396', '#FEB019', '#EF4444'],
|
||||||
|
tooltip: {
|
||||||
|
theme: 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await fetch('/api/mahasiswa/status');
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(result)) {
|
||||||
|
throw new Error('Invalid data format received from server');
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(result);
|
||||||
|
|
||||||
|
// Process data to create series
|
||||||
|
const tahunAngkatan = [...new Set(result.map(item => item.tahun_angkatan))].sort();
|
||||||
|
const statuses = ['Aktif', 'Lulus', 'Cuti', 'DO'];
|
||||||
|
|
||||||
|
const seriesData = statuses.map(status => ({
|
||||||
|
name: status,
|
||||||
|
data: tahunAngkatan.map(tahun => {
|
||||||
|
const item = result.find(d => d.tahun_angkatan === tahun && d.status_kuliah === status);
|
||||||
|
return item ? item.jumlah : 0;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
setSeries(seriesData);
|
||||||
|
setOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
xaxis: {
|
||||||
|
...prev.xaxis,
|
||||||
|
categories: tahunAngkatan,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in fetchData:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update theme when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
const currentTheme = theme === 'system' ? systemTheme : theme;
|
||||||
|
const textColor = currentTheme === 'dark' ? '#fff' : '#000';
|
||||||
|
const tooltipTheme = currentTheme === 'dark' ? 'dark' : 'light';
|
||||||
|
|
||||||
|
setOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
chart: {
|
||||||
|
...prev.chart,
|
||||||
|
background: currentTheme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
...prev.dataLabels,
|
||||||
|
style: {
|
||||||
|
...prev.dataLabels?.style,
|
||||||
|
colors: [textColor]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
...prev.xaxis,
|
||||||
|
title: {
|
||||||
|
...prev.xaxis?.title,
|
||||||
|
style: {
|
||||||
|
...prev.xaxis?.title?.style,
|
||||||
|
color: textColor
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
...prev.xaxis?.labels,
|
||||||
|
style: {
|
||||||
|
...prev.xaxis?.labels?.style,
|
||||||
|
colors: textColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
...(prev.yaxis as ApexYAxis),
|
||||||
|
title: {
|
||||||
|
...(prev.yaxis as ApexYAxis)?.title,
|
||||||
|
style: {
|
||||||
|
...(prev.yaxis as ApexYAxis)?.title?.style,
|
||||||
|
color: textColor
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
...(prev.yaxis as ApexYAxis)?.labels,
|
||||||
|
style: {
|
||||||
|
...(prev.yaxis as ApexYAxis)?.labels?.style,
|
||||||
|
colors: textColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
...prev.legend,
|
||||||
|
labels: {
|
||||||
|
...prev.legend?.labels,
|
||||||
|
colors: textColor
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
...prev.tooltip,
|
||||||
|
theme: tooltipTheme
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}, [theme, systemTheme]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500 dark:text-red-400">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Status Mahasiswa
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[350px] w-full max-w-5xl mx-auto">
|
||||||
|
{typeof window !== 'undefined' && (
|
||||||
|
<Chart
|
||||||
|
options={options}
|
||||||
|
series={series}
|
||||||
|
type="bar"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
252
components/StatusMahasiswaFilterChart.tsx
Normal file
252
components/StatusMahasiswaFilterChart.tsx
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface StatusMahasiswaData {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
jk: string;
|
||||||
|
total_mahasiswa: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedYear: string;
|
||||||
|
selectedStatus: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatusMahasiswaFilterChart({ selectedYear, selectedStatus }: Props) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [data, setData] = useState<StatusMahasiswaData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
console.log('Fetching data with params:', { selectedYear, selectedStatus });
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/mahasiswa/status-mahasiswa?tahun_angkatan=${selectedYear}&status_kuliah=${selectedStatus}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch data');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('Received data:', result);
|
||||||
|
|
||||||
|
// Sort data by tahun_angkatan
|
||||||
|
const sortedData = result.sort((a: StatusMahasiswaData, b: StatusMahasiswaData) =>
|
||||||
|
Number(a.tahun_angkatan) - Number(b.tahun_angkatan)
|
||||||
|
);
|
||||||
|
|
||||||
|
setData(sortedData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in fetchData:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [selectedYear, selectedStatus]);
|
||||||
|
|
||||||
|
// Log data changes
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('Current data state:', data);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// Get unique years and sort them
|
||||||
|
const years = [...new Set(data.map(item => item.tahun_angkatan))].sort((a, b) => Number(a) - Number(b));
|
||||||
|
console.log('Sorted years:', years);
|
||||||
|
|
||||||
|
const chartOptions: ApexOptions = {
|
||||||
|
chart: {
|
||||||
|
type: 'bar',
|
||||||
|
stacked: false,
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
},
|
||||||
|
background: theme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
},
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
horizontal: false,
|
||||||
|
columnWidth: '55%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toString();
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: [theme === 'dark' ? '#fff' : '#000']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stroke: {
|
||||||
|
show: true,
|
||||||
|
width: 2,
|
||||||
|
colors: ['transparent'],
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: years,
|
||||||
|
title: {
|
||||||
|
text: 'Tahun Angkatan',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
title: {
|
||||||
|
text: 'Jumlah Mahasiswa',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
fontSize: '14px',
|
||||||
|
markers: {
|
||||||
|
size: 12,
|
||||||
|
},
|
||||||
|
itemMargin: {
|
||||||
|
horizontal: 10,
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors: ['#3B82F6', '#EC4899'],
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process data for series
|
||||||
|
const processSeriesData = () => {
|
||||||
|
const maleData = new Array(years.length).fill(0);
|
||||||
|
const femaleData = new Array(years.length).fill(0);
|
||||||
|
|
||||||
|
data.forEach(item => {
|
||||||
|
const yearIndex = years.indexOf(item.tahun_angkatan);
|
||||||
|
if (yearIndex !== -1) {
|
||||||
|
if (item.jk === 'L') {
|
||||||
|
maleData[yearIndex] = item.total_mahasiswa;
|
||||||
|
} else if (item.jk === 'P') {
|
||||||
|
femaleData[yearIndex] = item.total_mahasiswa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'Laki-laki',
|
||||||
|
data: maleData
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Perempuan',
|
||||||
|
data: femaleData
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const series = processSeriesData();
|
||||||
|
console.log('Processed series data:', series);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Status Mahasiswa {selectedStatus}
|
||||||
|
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[350px] w-full max-w-5xl mx-auto">
|
||||||
|
{typeof window !== 'undefined' && (
|
||||||
|
<Chart
|
||||||
|
options={chartOptions}
|
||||||
|
series={series}
|
||||||
|
type="bar"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
185
components/StatusMahasiswaFilterPieChart.tsx
Normal file
185
components/StatusMahasiswaFilterPieChart.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface StatusMahasiswaData {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
jk: string;
|
||||||
|
total_mahasiswa: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedYear: string;
|
||||||
|
selectedStatus: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatusMahasiswaFilterPieChart({ selectedYear, selectedStatus }: Props) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [data, setData] = useState<StatusMahasiswaData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/mahasiswa/status-mahasiswa?tahun_angkatan=${selectedYear}&status_kuliah=${selectedStatus}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch data');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
setData(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in fetchData:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [selectedYear, selectedStatus]);
|
||||||
|
|
||||||
|
const chartOptions: ApexOptions = {
|
||||||
|
chart: {
|
||||||
|
type: 'pie',
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
tools: {
|
||||||
|
download: true,
|
||||||
|
selection: true,
|
||||||
|
zoom: true,
|
||||||
|
zoomin: true,
|
||||||
|
zoomout: true,
|
||||||
|
pan: true,
|
||||||
|
reset: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
background: theme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
},
|
||||||
|
labels: ['Laki-laki', 'Perempuan'],
|
||||||
|
colors: ['#3B82F6', '#EC4899'],
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
fontSize: '14px',
|
||||||
|
markers: {
|
||||||
|
size: 12,
|
||||||
|
strokeWidth: 0
|
||||||
|
},
|
||||||
|
itemMargin: {
|
||||||
|
horizontal: 10
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return `${val.toFixed(0)}%`;
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontFamily: 'Inter, sans-serif',
|
||||||
|
fontWeight: '500'
|
||||||
|
},
|
||||||
|
offsetY: 0,
|
||||||
|
dropShadow: {
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process data for series
|
||||||
|
const processSeriesData = () => {
|
||||||
|
const maleData = data.find(item => item.jk === 'L')?.total_mahasiswa || 0;
|
||||||
|
const femaleData = data.find(item => item.jk === 'P')?.total_mahasiswa || 0;
|
||||||
|
return [maleData, femaleData];
|
||||||
|
};
|
||||||
|
|
||||||
|
const series = processSeriesData();
|
||||||
|
const totalMahasiswa = data.reduce((sum, item) => sum + item.total_mahasiswa, 0);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Status Mahasiswa {selectedStatus}
|
||||||
|
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="text-lg font-semibold text-gray-600 dark:text-gray-300">
|
||||||
|
Total Mahasiswa: {totalMahasiswa}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[350px] w-full max-w-5xl mx-auto">
|
||||||
|
{typeof window !== 'undefined' && (
|
||||||
|
<Chart
|
||||||
|
options={chartOptions}
|
||||||
|
series={series}
|
||||||
|
type="pie"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
263
components/TingkatPrestasiChart.tsx
Normal file
263
components/TingkatPrestasiChart.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface TingkatPrestasiData {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
tingkat: string;
|
||||||
|
tingkat_mahasiswa_prestasi: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedJenisPrestasi: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TingkatPrestasiChart({ selectedJenisPrestasi }: Props) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [data, setData] = useState<TingkatPrestasiData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const url = `/api/mahasiswa/tingkat-prestasi?jenisPrestasi=${selectedJenisPrestasi}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(result)) {
|
||||||
|
throw new Error('Invalid data format received from server');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort data by tahun_angkatan
|
||||||
|
const sortedData = result.sort((a: TingkatPrestasiData, b: TingkatPrestasiData) =>
|
||||||
|
a.tahun_angkatan - b.tahun_angkatan
|
||||||
|
);
|
||||||
|
|
||||||
|
setData(sortedData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in fetchData:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [selectedJenisPrestasi]);
|
||||||
|
|
||||||
|
// Process data for series
|
||||||
|
const processSeriesData = () => {
|
||||||
|
if (!data.length) return [];
|
||||||
|
|
||||||
|
const years = [...new Set(data.map(item => item.tahun_angkatan))].sort();
|
||||||
|
const tingkatTypes = [...new Set(data.map(item => item.tingkat))].sort();
|
||||||
|
|
||||||
|
return tingkatTypes.map(tingkat => ({
|
||||||
|
name: tingkat,
|
||||||
|
data: years.map(year => {
|
||||||
|
const item = data.find(d => d.tahun_angkatan === year && d.tingkat === tingkat);
|
||||||
|
return item?.tingkat_mahasiswa_prestasi || 0;
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartOptions: ApexOptions = {
|
||||||
|
chart: {
|
||||||
|
type: 'bar',
|
||||||
|
stacked: false,
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
tools: {
|
||||||
|
download: true,
|
||||||
|
selection: true,
|
||||||
|
zoom: true,
|
||||||
|
zoomin: true,
|
||||||
|
zoomout: true,
|
||||||
|
pan: true,
|
||||||
|
reset: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
background: theme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
},
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
horizontal: false,
|
||||||
|
columnWidth: '55%',
|
||||||
|
borderRadius: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val?.toString() || '0';
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: [theme === 'dark' ? '#fff' : '#000']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stroke: {
|
||||||
|
show: true,
|
||||||
|
width: 2,
|
||||||
|
colors: ['transparent']
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: [...new Set(data.map(item => item.tahun_angkatan))].sort(),
|
||||||
|
title: {
|
||||||
|
text: 'Tahun Angkatan',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisBorder: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
},
|
||||||
|
axisTicks: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
title: {
|
||||||
|
text: 'Jumlah Mahasiswa',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisBorder: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
opacity: 1
|
||||||
|
},
|
||||||
|
colors: ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4'],
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
fontSize: '14px',
|
||||||
|
markers: {
|
||||||
|
size: 12,
|
||||||
|
},
|
||||||
|
itemMargin: {
|
||||||
|
horizontal: 10,
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
borderColor: theme === 'dark' ? '#374151' : '#E5E7EB',
|
||||||
|
strokeDashArray: 4,
|
||||||
|
padding: {
|
||||||
|
top: 20,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const series = processSeriesData();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tingkat Prestasi {selectedJenisPrestasi}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[400px] sm:h-[350px] md:h-[400px] w-full max-w-5xl mx-auto">
|
||||||
|
{typeof window !== 'undefined' && series.length > 0 && (
|
||||||
|
<Chart
|
||||||
|
options={chartOptions}
|
||||||
|
series={series}
|
||||||
|
type="bar"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
188
components/TingkatPrestasiPieChart.tsx
Normal file
188
components/TingkatPrestasiPieChart.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface TingkatPrestasiData {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
tingkat: string;
|
||||||
|
tingkat_mahasiswa_prestasi: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedYear: string;
|
||||||
|
selectedJenisPrestasi: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TingkatPrestasiPieChart({ selectedYear, selectedJenisPrestasi }: Props) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [data, setData] = useState<TingkatPrestasiData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const url = `/api/mahasiswa/tingkat-prestasi?tahunAngkatan=${selectedYear}&jenisPrestasi=${selectedJenisPrestasi}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
setData(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in fetchData:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [selectedYear, selectedJenisPrestasi]);
|
||||||
|
|
||||||
|
const chartOptions: ApexOptions = {
|
||||||
|
chart: {
|
||||||
|
type: 'pie',
|
||||||
|
background: theme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
tools: {
|
||||||
|
download: true,
|
||||||
|
selection: true,
|
||||||
|
zoom: true,
|
||||||
|
zoomin: true,
|
||||||
|
zoomout: true,
|
||||||
|
pan: true,
|
||||||
|
reset: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: [],
|
||||||
|
colors: ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4'],
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
fontSize: '14px',
|
||||||
|
markers: {
|
||||||
|
size: 12,
|
||||||
|
strokeWidth: 0
|
||||||
|
},
|
||||||
|
itemMargin: {
|
||||||
|
horizontal: 10
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return `${val.toFixed(0)}%`;
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontFamily: 'Inter, sans-serif',
|
||||||
|
fontWeight: '500'
|
||||||
|
},
|
||||||
|
offsetY: 0,
|
||||||
|
dropShadow: {
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process data for series
|
||||||
|
const processSeriesData = () => {
|
||||||
|
// Filter data sesuai tahun angkatan
|
||||||
|
const filtered = data.filter(item => String(item.tahun_angkatan) === String(selectedYear));
|
||||||
|
const tingkat = [...new Set(filtered.map(item => item.tingkat))].sort();
|
||||||
|
const jumlahData = tingkat.map(t => {
|
||||||
|
const item = filtered.find(d => d.tingkat === t);
|
||||||
|
return item ? item.tingkat_mahasiswa_prestasi : 0;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
series: jumlahData,
|
||||||
|
labels: tingkat
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const { series, labels } = processSeriesData();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tingkat Prestasi {selectedJenisPrestasi} Angkatan {selectedYear}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[350px] w-full max-w-5xl mx-auto">
|
||||||
|
{typeof window !== 'undefined' && (
|
||||||
|
<Chart
|
||||||
|
options={{...chartOptions, labels}}
|
||||||
|
series={series}
|
||||||
|
type="pie"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
277
components/TotalBeasiswaChart.tsx
Normal file
277
components/TotalBeasiswaChart.tsx
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface TotalBeasiswaData {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
jk: string;
|
||||||
|
jumlah_mahasiswa_beasiswa: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedYear: string;
|
||||||
|
selectedJenisBeasiswa: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TotalBeasiswaChart({ selectedYear, selectedJenisBeasiswa }: Props) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [data, setData] = useState<TotalBeasiswaData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const url = `/api/mahasiswa/total-beasiswa?tahunAngkatan=${selectedYear}&jenisBeasiswa=${selectedJenisBeasiswa}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(result)) {
|
||||||
|
throw new Error('Invalid data format received from server');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort data by tahun_angkatan
|
||||||
|
const sortedData = result.sort((a: TotalBeasiswaData, b: TotalBeasiswaData) =>
|
||||||
|
a.tahun_angkatan - b.tahun_angkatan
|
||||||
|
);
|
||||||
|
|
||||||
|
setData(sortedData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in fetchData:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [selectedYear, selectedJenisBeasiswa]);
|
||||||
|
|
||||||
|
// Process data for series
|
||||||
|
const processSeriesData = () => {
|
||||||
|
if (!data.length) return [];
|
||||||
|
|
||||||
|
const years = [...new Set(data.map(item => item.tahun_angkatan))].sort();
|
||||||
|
|
||||||
|
const pria = years.map(year => {
|
||||||
|
const item = data.find(d => d.tahun_angkatan === year && d.jk === 'Pria');
|
||||||
|
return item ? item.jumlah_mahasiswa_beasiswa : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const wanita = years.map(year => {
|
||||||
|
const item = data.find(d => d.tahun_angkatan === year && d.jk === 'Wanita');
|
||||||
|
return item ? item.jumlah_mahasiswa_beasiswa : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'Laki-laki',
|
||||||
|
data: pria
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Perempuan',
|
||||||
|
data: wanita
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartOptions: ApexOptions = {
|
||||||
|
chart: {
|
||||||
|
type: 'bar',
|
||||||
|
stacked: false,
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
tools: {
|
||||||
|
download: true,
|
||||||
|
selection: true,
|
||||||
|
zoom: true,
|
||||||
|
zoomin: true,
|
||||||
|
zoomout: true,
|
||||||
|
pan: true,
|
||||||
|
reset: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
background: theme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
},
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
horizontal: false,
|
||||||
|
columnWidth: '55%',
|
||||||
|
borderRadius: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toString();
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: [theme === 'dark' ? '#fff' : '#000']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stroke: {
|
||||||
|
show: true,
|
||||||
|
width: 2,
|
||||||
|
colors: ['transparent']
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: [...new Set(data.map(item => item.tahun_angkatan))].sort(),
|
||||||
|
title: {
|
||||||
|
text: 'Tahun Angkatan',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisBorder: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
},
|
||||||
|
axisTicks: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
title: {
|
||||||
|
text: 'Jumlah Mahasiswa',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisBorder: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
opacity: 1
|
||||||
|
},
|
||||||
|
colors: ['#3B82F6', '#EC4899'],
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
fontSize: '14px',
|
||||||
|
markers: {
|
||||||
|
size: 12,
|
||||||
|
},
|
||||||
|
itemMargin: {
|
||||||
|
horizontal: 10,
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
borderColor: theme === 'dark' ? '#374151' : '#E5E7EB',
|
||||||
|
strokeDashArray: 4,
|
||||||
|
padding: {
|
||||||
|
top: 20,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const series = processSeriesData();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Total Mahasiswa Beasiswa {selectedJenisBeasiswa}
|
||||||
|
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[300px] sm:h-[350px] md:h-[400px] w-full max-w-5xl mx-auto">
|
||||||
|
{typeof window !== 'undefined' && series.length > 0 && (
|
||||||
|
<Chart
|
||||||
|
options={chartOptions}
|
||||||
|
series={series}
|
||||||
|
type="bar"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
188
components/TotalBeasiswaPieChart.tsx
Normal file
188
components/TotalBeasiswaPieChart.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface TotalBeasiswaData {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
jk: string;
|
||||||
|
jumlah_mahasiswa_beasiswa: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedYear: string;
|
||||||
|
selectedJenisBeasiswa: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TotalBeasiswaPieChart({ selectedYear, selectedJenisBeasiswa }: Props) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [data, setData] = useState<TotalBeasiswaData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const url = `/api/mahasiswa/total-beasiswa?tahunAngkatan=${selectedYear}&jenisBeasiswa=${selectedJenisBeasiswa}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
setData(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in fetchData:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [selectedYear, selectedJenisBeasiswa]);
|
||||||
|
|
||||||
|
const chartOptions: ApexOptions = {
|
||||||
|
chart: {
|
||||||
|
type: 'pie',
|
||||||
|
background: theme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
tools: {
|
||||||
|
download: true,
|
||||||
|
selection: true,
|
||||||
|
zoom: true,
|
||||||
|
zoomin: true,
|
||||||
|
zoomout: true,
|
||||||
|
pan: true,
|
||||||
|
reset: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: ['Laki-laki', 'Perempuan'],
|
||||||
|
colors: ['#3B82F6', '#EC4899'],
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
fontSize: '14px',
|
||||||
|
markers: {
|
||||||
|
size: 12,
|
||||||
|
strokeWidth: 0
|
||||||
|
},
|
||||||
|
itemMargin: {
|
||||||
|
horizontal: 10
|
||||||
|
},
|
||||||
|
formatter: function(legendName: string, opts: any) {
|
||||||
|
return legendName;
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return `${val.toFixed(0)}%`;
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontFamily: 'Inter, sans-serif',
|
||||||
|
fontWeight: '500'
|
||||||
|
},
|
||||||
|
offsetY: 0,
|
||||||
|
dropShadow: {
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process data for series
|
||||||
|
const processSeriesData = () => {
|
||||||
|
const maleData = data.find(item => item.jk === 'Pria')?.jumlah_mahasiswa_beasiswa || 0;
|
||||||
|
const femaleData = data.find(item => item.jk === 'Wanita')?.jumlah_mahasiswa_beasiswa || 0;
|
||||||
|
return [maleData, femaleData];
|
||||||
|
};
|
||||||
|
|
||||||
|
const series = processSeriesData();
|
||||||
|
const totalMahasiswa = data.reduce((sum, item) => sum + item.jumlah_mahasiswa_beasiswa, 0);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Total Mahasiswa Beasiswa {selectedJenisBeasiswa}
|
||||||
|
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="text-lg font-semibold text-gray-600 dark:text-gray-300">
|
||||||
|
Total Mahasiswa: {totalMahasiswa}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[350px] w-full max-w-5xl mx-auto">
|
||||||
|
{typeof window !== 'undefined' && (
|
||||||
|
<Chart
|
||||||
|
options={chartOptions}
|
||||||
|
series={series}
|
||||||
|
type="pie"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
275
components/TotalPrestasiChart.tsx
Normal file
275
components/TotalPrestasiChart.tsx
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface TotalPrestasiData {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
jk: string;
|
||||||
|
jumlah_mahasiswa_prestasi: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedJenisPrestasi: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TotalPrestasiChart({ selectedJenisPrestasi }: Props) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [data, setData] = useState<TotalPrestasiData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const url = `/api/mahasiswa/total-prestasi?jenisPrestasi=${selectedJenisPrestasi}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(result)) {
|
||||||
|
throw new Error('Invalid data format received from server');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort data by tahun_angkatan
|
||||||
|
const sortedData = result.sort((a: TotalPrestasiData, b: TotalPrestasiData) =>
|
||||||
|
a.tahun_angkatan - b.tahun_angkatan
|
||||||
|
);
|
||||||
|
|
||||||
|
setData(sortedData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in fetchData:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [selectedJenisPrestasi]);
|
||||||
|
|
||||||
|
// Process data for series
|
||||||
|
const processSeriesData = () => {
|
||||||
|
if (!data.length) return [];
|
||||||
|
|
||||||
|
const years = [...new Set(data.map(item => item.tahun_angkatan))].sort();
|
||||||
|
|
||||||
|
const pria = years.map(year => {
|
||||||
|
const item = data.find(d => d.tahun_angkatan === year && d.jk === 'Pria');
|
||||||
|
return item ? item.jumlah_mahasiswa_prestasi : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const wanita = years.map(year => {
|
||||||
|
const item = data.find(d => d.tahun_angkatan === year && d.jk === 'Wanita');
|
||||||
|
return item ? item.jumlah_mahasiswa_prestasi : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'Laki-laki',
|
||||||
|
data: pria
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Perempuan',
|
||||||
|
data: wanita
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartOptions: ApexOptions = {
|
||||||
|
chart: {
|
||||||
|
type: 'bar',
|
||||||
|
stacked: false,
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
tools: {
|
||||||
|
download: true,
|
||||||
|
selection: true,
|
||||||
|
zoom: true,
|
||||||
|
zoomin: true,
|
||||||
|
zoomout: true,
|
||||||
|
pan: true,
|
||||||
|
reset: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
background: theme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
},
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
horizontal: false,
|
||||||
|
columnWidth: '55%',
|
||||||
|
borderRadius: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val.toString();
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: [theme === 'dark' ? '#fff' : '#000']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stroke: {
|
||||||
|
show: true,
|
||||||
|
width: 2,
|
||||||
|
colors: ['transparent']
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: [...new Set(data.map(item => item.tahun_angkatan))].sort(),
|
||||||
|
title: {
|
||||||
|
text: 'Tahun Angkatan',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisBorder: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
},
|
||||||
|
axisTicks: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
title: {
|
||||||
|
text: 'Jumlah Mahasiswa',
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '12px',
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisBorder: {
|
||||||
|
show: true,
|
||||||
|
color: theme === 'dark' ? '#374151' : '#E5E7EB'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
opacity: 1
|
||||||
|
},
|
||||||
|
colors: ['#3B82F6', '#EC4899'],
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
fontSize: '14px',
|
||||||
|
markers: {
|
||||||
|
size: 12,
|
||||||
|
},
|
||||||
|
itemMargin: {
|
||||||
|
horizontal: 10,
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
borderColor: theme === 'dark' ? '#374151' : '#E5E7EB',
|
||||||
|
strokeDashArray: 4,
|
||||||
|
padding: {
|
||||||
|
top: 20,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const series = processSeriesData();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Total Mahasiswa Berprestasi {selectedJenisPrestasi}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[300px] sm:h-[350px] md:h-[400px] w-full max-w-5xl mx-auto">
|
||||||
|
{typeof window !== 'undefined' && series.length > 0 && (
|
||||||
|
<Chart
|
||||||
|
options={chartOptions}
|
||||||
|
series={series}
|
||||||
|
type="bar"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
188
components/TotalPrestasiPieChart.tsx
Normal file
188
components/TotalPrestasiPieChart.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { ApexOptions } from 'apexcharts';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Dynamically import ApexCharts to avoid SSR issues
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
interface TotalPrestasiData {
|
||||||
|
tahun_angkatan: number;
|
||||||
|
jk: string;
|
||||||
|
jumlah_mahasiswa_prestasi: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedYear: string;
|
||||||
|
selectedJenisPrestasi: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TotalPrestasiPieChart({ selectedYear, selectedJenisPrestasi }: Props) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [data, setData] = useState<TotalPrestasiData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const url = `/api/mahasiswa/total-prestasi?tahunAngkatan=${selectedYear}&jenisPrestasi=${selectedJenisPrestasi}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
setData(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in fetchData:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [selectedYear, selectedJenisPrestasi]);
|
||||||
|
|
||||||
|
const chartOptions: ApexOptions = {
|
||||||
|
chart: {
|
||||||
|
type: 'pie',
|
||||||
|
background: theme === 'dark' ? '#0F172B' : '#fff',
|
||||||
|
toolbar: {
|
||||||
|
show: true,
|
||||||
|
tools: {
|
||||||
|
download: true,
|
||||||
|
selection: true,
|
||||||
|
zoom: true,
|
||||||
|
zoomin: true,
|
||||||
|
zoomout: true,
|
||||||
|
pan: true,
|
||||||
|
reset: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: ['Laki-laki', 'Perempuan'],
|
||||||
|
colors: ['#3B82F6', '#EC4899'],
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
fontSize: '14px',
|
||||||
|
markers: {
|
||||||
|
size: 12,
|
||||||
|
strokeWidth: 0
|
||||||
|
},
|
||||||
|
itemMargin: {
|
||||||
|
horizontal: 10
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return `${val.toFixed(0)}%`;
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontFamily: 'Inter, sans-serif',
|
||||||
|
fontWeight: '500'
|
||||||
|
},
|
||||||
|
offsetY: 0,
|
||||||
|
dropShadow: {
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return val + " mahasiswa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process data for series
|
||||||
|
const processSeriesData = () => {
|
||||||
|
// Filter data sesuai tahun angkatan
|
||||||
|
const filtered = data.filter(item => String(item.tahun_angkatan) === String(selectedYear));
|
||||||
|
const pria = filtered.find(item => item.jk === 'Pria')?.jumlah_mahasiswa_prestasi || 0;
|
||||||
|
const wanita = filtered.find(item => item.jk === 'Wanita')?.jumlah_mahasiswa_prestasi || 0;
|
||||||
|
return [pria, wanita];
|
||||||
|
};
|
||||||
|
|
||||||
|
const series = processSeriesData();
|
||||||
|
const totalMahasiswa = data
|
||||||
|
.filter(item => String(item.tahun_angkatan) === String(selectedYear))
|
||||||
|
.reduce((sum, item) => sum + item.jumlah_mahasiswa_prestasi, 0);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Loading...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold text-red-500">
|
||||||
|
Error: {error}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Tidak ada data yang tersedia
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-slate-900 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-bold dark:text-white">
|
||||||
|
Total Mahasiswa Berprestasi {selectedJenisPrestasi} Angkatan {selectedYear}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="text-lg font-semibold text-gray-600 dark:text-gray-300">
|
||||||
|
Total Mahasiswa: {totalMahasiswa}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[350px] w-full max-w-5xl mx-auto">
|
||||||
|
{typeof window !== 'undefined' && (
|
||||||
|
<Chart
|
||||||
|
options={chartOptions}
|
||||||
|
series={series}
|
||||||
|
type="pie"
|
||||||
|
height="100%"
|
||||||
|
width="90%"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
components/theme-provider.tsx
Normal file
18
components/theme-provider.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||||
|
|
||||||
|
export function ThemeProvider({ children, ...props }: React.ComponentProps<typeof NextThemesProvider>) {
|
||||||
|
const [mounted, setMounted] = React.useState(false)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||||
|
}
|
||||||
37
components/theme-toggle.tsx
Normal file
37
components/theme-toggle.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Moon, Sun } from "lucide-react"
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { setTheme } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon">
|
||||||
|
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
|
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
|
<span className="sr-only">Toggle theme</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||||
|
Light
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||||
|
Dark
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
374
components/ui/Navbar.tsx
Normal file
374
components/ui/Navbar.tsx
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||||
|
import { ThemeToggle } from '@/components/theme-toggle';
|
||||||
|
import { Menu, User } from 'lucide-react';
|
||||||
|
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||||
|
import SidebarContent from '@/components/ui/SidebarContent';
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
const Navbar = () => {
|
||||||
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [loginData, setLoginData] = useState({ nim: '', password: '' });
|
||||||
|
const [registerData, setRegisterData] = useState({ username: '', nim: '', password: '', confirmPassword: '' });
|
||||||
|
const [userData, setUserData] = useState<any>(null);
|
||||||
|
const { toast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Check login status on component mount and when route changes
|
||||||
|
useEffect(() => {
|
||||||
|
const checkAuth = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/check');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && data.user) {
|
||||||
|
setUserData(data.user);
|
||||||
|
setIsLoggedIn(true);
|
||||||
|
} else {
|
||||||
|
setUserData(null);
|
||||||
|
setIsLoggedIn(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auth check failed:', error);
|
||||||
|
setUserData(null);
|
||||||
|
setIsLoggedIn(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkAuth();
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Validate input
|
||||||
|
if (!loginData.nim || !loginData.password) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Login gagal",
|
||||||
|
description: "NIM dan password harus diisi",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Login attempt with data:', {
|
||||||
|
nim: loginData.nim,
|
||||||
|
password: '***'
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
nim: loginData.nim.trim(),
|
||||||
|
password: loginData.password
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Login response status:', response.status);
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('Login response data:', data);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Login gagal');
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Login berhasil",
|
||||||
|
description: "Selamat datang kembali!",
|
||||||
|
});
|
||||||
|
|
||||||
|
setUserData(data.user);
|
||||||
|
setIsLoggedIn(true);
|
||||||
|
setDialogOpen(false);
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Login gagal",
|
||||||
|
description: error instanceof Error ? error.message : 'Terjadi kesalahan saat login',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegister = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Validate passwords match
|
||||||
|
if (registerData.password !== registerData.confirmPassword) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Registrasi gagal",
|
||||||
|
description: "Password dan konfirmasi password tidak cocok",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: registerData.username,
|
||||||
|
nim: registerData.nim,
|
||||||
|
password: registerData.password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Registrasi gagal');
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Registrasi berhasil",
|
||||||
|
description: "Silakan login dengan akun Anda",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset form and switch to login tab
|
||||||
|
setRegisterData({ username: '', nim: '', password: '', confirmPassword: '' });
|
||||||
|
const tabsList = document.querySelector('[role="tablist"]');
|
||||||
|
if (tabsList) {
|
||||||
|
const loginTab = tabsList.querySelector('[value="login"]');
|
||||||
|
if (loginTab) {
|
||||||
|
(loginTab as HTMLElement).click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Registrasi gagal",
|
||||||
|
description: error instanceof Error ? error.message : 'Terjadi kesalahan saat registrasi',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Logout gagal');
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Logout berhasil",
|
||||||
|
description: "Sampai jumpa lagi!",
|
||||||
|
});
|
||||||
|
|
||||||
|
setUserData(null);
|
||||||
|
setIsLoggedIn(false);
|
||||||
|
router.push('/');
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Logout gagal",
|
||||||
|
description: error instanceof Error ? error.message : 'Terjadi kesalahan saat logout',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProfileClick = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/check');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && data.user) {
|
||||||
|
router.push('/mahasiswa/profile');
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Akses Ditolak",
|
||||||
|
description: "Silakan login terlebih dahulu untuk mengakses profil",
|
||||||
|
});
|
||||||
|
setDialogOpen(true);
|
||||||
|
router.push('/'); // Redirect to home if not logged in
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking auth status:', error);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error",
|
||||||
|
description: "Terjadi kesalahan saat memeriksa status login",
|
||||||
|
});
|
||||||
|
router.push('/');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
if (name.startsWith('login')) {
|
||||||
|
const loginField = name.replace('login', '').toLowerCase();
|
||||||
|
setLoginData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[loginField]: value
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setRegisterData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: value
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background border-b sticky top-0 z-50 py-2 px-5 flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Mobile Menu Button */}
|
||||||
|
<div className="md:hidden">
|
||||||
|
<Sheet>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon">
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
<span className="sr-only">Toggle menu</span>
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="left" className="p-0 w-[250px] overflow-y-auto">
|
||||||
|
<DialogTitle className="sr-only">Menu Navigasi</DialogTitle>
|
||||||
|
<SidebarContent />
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
{isLoggedIn ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger className="focus:outline-none">
|
||||||
|
<Avatar>
|
||||||
|
<AvatarImage src="" alt={userData?.username || 'User'} />
|
||||||
|
<AvatarFallback className="bg-primary/10">
|
||||||
|
<User className="h-5 w-5" />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" side="bottom" sideOffset={9} alignOffset={0}>
|
||||||
|
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={handleProfileClick}>
|
||||||
|
Profile
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={handleLogout}>Logout</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : (
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="secondary">
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Portal Data Informatika</DialogTitle>
|
||||||
|
<DialogDescription>Masuk atau daftar untuk mengakses portal</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Tabs defaultValue="login" className="w-full mt-4">
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="login">Login</TabsTrigger>
|
||||||
|
<TabsTrigger value="register">Register</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="login">
|
||||||
|
<form onSubmit={handleLogin} className="space-y-4">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="loginNim"
|
||||||
|
placeholder="NIM"
|
||||||
|
value={loginData.nim}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
name="loginPassword"
|
||||||
|
placeholder="Password"
|
||||||
|
value={loginData.password}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button className="w-full" type="submit">
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="register">
|
||||||
|
<form onSubmit={handleRegister} className="space-y-4">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
placeholder="Nama Lengkap"
|
||||||
|
value={registerData.username}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="nim"
|
||||||
|
placeholder="NIM"
|
||||||
|
value={registerData.nim}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
placeholder="Password"
|
||||||
|
value={registerData.password}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
name="confirmPassword"
|
||||||
|
placeholder="Konfirmasi Password"
|
||||||
|
value={registerData.confirmPassword}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button className="w-full" type="submit">
|
||||||
|
Register
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Navbar;
|
||||||
32
components/ui/ProfileMenuItem.tsx
Normal file
32
components/ui/ProfileMenuItem.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
interface ProfileMenuItemProps {
|
||||||
|
isLoggedIn: boolean;
|
||||||
|
setDialogOpen: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProfileMenuItem = ({ isLoggedIn, setDialogOpen }: ProfileMenuItemProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Akses Ditolak",
|
||||||
|
description: "Silakan login terlebih dahulu untuk mengakses profil",
|
||||||
|
});
|
||||||
|
setDialogOpen(true);
|
||||||
|
} else {
|
||||||
|
router.push('/mahasiswa/profile');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem onClick={handleClick}>
|
||||||
|
Profile
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
13
components/ui/Sidebar.tsx
Normal file
13
components/ui/Sidebar.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import SidebarContent from './SidebarContent';
|
||||||
|
|
||||||
|
const Sidebar = () => {
|
||||||
|
return (
|
||||||
|
<div className="hidden md:block h-[calc(100vh-4rem)] w-[250px] fixed">
|
||||||
|
<SidebarContent />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Sidebar;
|
||||||
89
components/ui/SidebarContent.tsx
Normal file
89
components/ui/SidebarContent.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
CommandSeparator,
|
||||||
|
CommandShortcut,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { School, Settings, User, GraduationCap, Award, Users, Clock, BookOpen, Home } from "lucide-react";
|
||||||
|
|
||||||
|
const SidebarContent = () => {
|
||||||
|
return (
|
||||||
|
<Command className="bg-background border-r h-full">
|
||||||
|
<CommandList className="overflow-visible">
|
||||||
|
<CommandGroup heading="Dashboard PODIF" className="mt-2">
|
||||||
|
<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>
|
||||||
|
</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">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<div className="pl-6 flex flex-col space-y-1">
|
||||||
|
<Link href="/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="/mahasiswa/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>Mahasiswa Status</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/mahasiswa/lulustepatwaktu" 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>
|
||||||
|
</Link>
|
||||||
|
<Link href="/mahasiswa/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>
|
||||||
|
</Link>
|
||||||
|
<Link href="/mahasiswa/berprestasi" 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>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandGroup heading="Data Diri">
|
||||||
|
<Link href="/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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SidebarContent;
|
||||||
72
components/ui/accordion.tsx
Normal file
72
components/ui/accordion.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||||
|
import { ChevronDownIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Accordion({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||||
|
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
data-slot="accordion-item"
|
||||||
|
className={cn("border-b last:border-b-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
data-slot="accordion-trigger"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 items-center justify-between gap-2 rounded-md py-2 text-left transition-all outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 transition-transform duration-200" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Content
|
||||||
|
data-slot="accordion-content"
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden max-h-0 transition-[max-height] duration-300 ease-in-out data-[state=open]:max-h-[500px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="pt-1 pb-2">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</AccordionPrimitive.Content>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||||
41
components/ui/avatar.tsx
Normal file
41
components/ui/avatar.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Avatar({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
className={cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn('aspect-square size-full', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn('bg-muted flex size-full items-center justify-center rounded-full', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback };
|
||||||
56
components/ui/button.tsx
Normal file
56
components/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
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',
|
||||||
|
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',
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'button'> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
92
components/ui/card.tsx
Normal file
92
components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
177
components/ui/command.tsx
Normal file
177
components/ui/command.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
|
import { SearchIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
|
||||||
|
function Command({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive
|
||||||
|
data-slot="command"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandDialog({
|
||||||
|
title = "Command Palette",
|
||||||
|
description = "Search for a command to run...",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Dialog> & {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogContent className="overflow-hidden p-0">
|
||||||
|
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
|
{children}
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="command-input-wrapper"
|
||||||
|
className="flex h-9 items-center gap-2 border-b px-3"
|
||||||
|
>
|
||||||
|
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
data-slot="command-input"
|
||||||
|
className={cn(
|
||||||
|
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
data-slot="command-list"
|
||||||
|
className={cn(
|
||||||
|
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandEmpty({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
data-slot="command-empty"
|
||||||
|
className="py-6 text-center text-sm"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
data-slot="command-group"
|
||||||
|
className={cn(
|
||||||
|
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
data-slot="command-separator"
|
||||||
|
className={cn("bg-border -mx-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
data-slot="command-item"
|
||||||
|
className={cn(
|
||||||
|
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-3 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="command-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user