Compare commits
10 Commits
a86456ee1c
...
5a96adfc30
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a96adfc30 | ||
|
|
5a0be32d2a | ||
|
|
211fd9c955 | ||
|
|
3797daa557 | ||
|
|
1e30a9ccfc | ||
|
|
5e2d0a92de | ||
|
|
e2076ba580 | ||
|
|
781a7c1be3 | ||
|
|
7a181be69b | ||
|
|
92edad08d9 |
@@ -88,7 +88,7 @@ export async function POST(request: NextRequest) {
|
|||||||
// Set secure session cookie
|
// Set secure session cookie
|
||||||
response.cookies.set('user_session', JSON.stringify(userWithoutPassword), {
|
response.cookies.set('user_session', JSON.stringify(userWithoutPassword), {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: false, // Set to false to allow HTTP (for Coolify deployment)
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
maxAge: 24 * 60 * 60, // 24 hours
|
maxAge: 24 * 60 * 60, // 24 hours
|
||||||
path: '/',
|
path: '/',
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ export async function POST() {
|
|||||||
|
|
||||||
// Clear the session cookie
|
// Clear the session cookie
|
||||||
response.cookies.set('user_session', '', {
|
response.cookies.set('user_session', '', {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: false, // Set to false to allow HTTP (for Coolify deployment)
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
maxAge: 0, // Expire immediately
|
maxAge: 0, // Expire immediately
|
||||||
path: '/',
|
path: '/',
|
||||||
});
|
});
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,11 +78,6 @@ export async function GET(request: NextRequest) {
|
|||||||
mata_kuliah!inner(kode_mk, nama_mk)
|
mata_kuliah!inner(kode_mk, nama_mk)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Add search condition if provided
|
|
||||||
if (search) {
|
|
||||||
query = query.or(`mahasiswa.nim.ilike.%${search}%,mahasiswa.nama.ilike.%${search}%,mata_kuliah.kode_mk.ilike.%${search}%,mata_kuliah.nama_mk.ilike.%${search}%`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add semester filter if provided
|
// Add semester filter if provided
|
||||||
if (semester && semester !== 'all') {
|
if (semester && semester !== 'all') {
|
||||||
query = query.eq('semester', parseInt(semester));
|
query = query.eq('semester', parseInt(semester));
|
||||||
@@ -94,7 +89,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add order by
|
// Add order by
|
||||||
query = query.order('semester', { ascending: true }).order('mahasiswa(nim)', { ascending: true });
|
query = query.order('semester', { ascending: true });
|
||||||
|
|
||||||
const { data, error } = await query;
|
const { data, error } = await query;
|
||||||
|
|
||||||
@@ -104,7 +99,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Transformasi data untuk meratakan field yang di-join
|
// Transformasi data untuk meratakan field yang di-join
|
||||||
const transformedData = data.map((item: any) => ({
|
let transformedData = data.map((item: any) => ({
|
||||||
...item,
|
...item,
|
||||||
nim: item.mahasiswa?.nim || '',
|
nim: item.mahasiswa?.nim || '',
|
||||||
nama: item.mahasiswa?.nama || '',
|
nama: item.mahasiswa?.nama || '',
|
||||||
@@ -115,6 +110,27 @@ export async function GET(request: NextRequest) {
|
|||||||
return rest;
|
return rest;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Client-side search filtering (untuk joined tables)
|
||||||
|
if (search) {
|
||||||
|
const searchLower = search.toLowerCase();
|
||||||
|
transformedData = transformedData.filter((item: any) => {
|
||||||
|
return (
|
||||||
|
item.nim?.toLowerCase().includes(searchLower) ||
|
||||||
|
item.nama?.toLowerCase().includes(searchLower) ||
|
||||||
|
item.kode_mk?.toLowerCase().includes(searchLower) ||
|
||||||
|
item.nama_mk?.toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by nim after filtering
|
||||||
|
transformedData.sort((a: any, b: any) => {
|
||||||
|
if (a.semester !== b.semester) {
|
||||||
|
return a.semester - b.semester;
|
||||||
|
}
|
||||||
|
return a.nim.localeCompare(b.nim);
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json(transformedData);
|
return NextResponse.json(transformedData);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -230,7 +246,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
message: 'Nilai mahasiswa berhasil ditambahkan',
|
message: 'Nilai mahasiswa berhasil ditambahkan',
|
||||||
id: data.id_nilai,
|
id: data.id_nilai,
|
||||||
mahasiswa: mahasiswa.nama,
|
mahasiswa: mahasiswa.nama,
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export async function POST(request: NextRequest) {
|
|||||||
// Handle CSV file
|
// Handle CSV file
|
||||||
const text = new TextDecoder().decode(buffer);
|
const text = new TextDecoder().decode(buffer);
|
||||||
const lines = text.split('\n').filter(line => line.trim());
|
const lines = text.split('\n').filter(line => line.trim());
|
||||||
|
|
||||||
if (lines.length < 2) {
|
if (lines.length < 2) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ message: 'File must contain at least header and one data row' },
|
{ message: 'File must contain at least header and one data row' },
|
||||||
@@ -63,7 +63,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
|
const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
|
||||||
|
|
||||||
for (let i = 1; i < lines.length; i++) {
|
for (let i = 1; i < lines.length; i++) {
|
||||||
const values = lines[i].split(',').map(v => v.trim().replace(/"/g, ''));
|
const values = lines[i].split(',').map(v => v.trim().replace(/"/g, ''));
|
||||||
const row: any = {};
|
const row: any = {};
|
||||||
@@ -111,33 +111,33 @@ export async function POST(request: NextRequest) {
|
|||||||
// Further normalize data with column mappings
|
// Further normalize data with column mappings
|
||||||
const finalData = normalizedData.map(row => {
|
const finalData = normalizedData.map(row => {
|
||||||
const finalRow: any = {};
|
const finalRow: any = {};
|
||||||
|
|
||||||
Object.keys(columnMappings).forEach(standardKey => {
|
Object.keys(columnMappings).forEach(standardKey => {
|
||||||
const variations = columnMappings[standardKey as keyof typeof columnMappings];
|
const variations = columnMappings[standardKey as keyof typeof columnMappings];
|
||||||
let value = null;
|
let value = null;
|
||||||
|
|
||||||
// Try to find the value using different variations
|
// Try to find the value using different variations
|
||||||
for (const variation of variations) {
|
for (const variation of variations) {
|
||||||
const normalizedVariation = variation.toLowerCase()
|
const normalizedVariation = variation.toLowerCase()
|
||||||
.replace(/\s+/g, '_')
|
.replace(/\s+/g, '_')
|
||||||
.replace(/[^a-z0-9_]/g, '');
|
.replace(/[^a-z0-9_]/g, '');
|
||||||
|
|
||||||
if (row[normalizedVariation] !== undefined && row[normalizedVariation] !== null && row[normalizedVariation] !== '') {
|
if (row[normalizedVariation] !== undefined && row[normalizedVariation] !== null && row[normalizedVariation] !== '') {
|
||||||
value = row[normalizedVariation];
|
value = row[normalizedVariation];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
finalRow[standardKey] = value;
|
finalRow[standardKey] = value;
|
||||||
});
|
});
|
||||||
|
|
||||||
return finalRow;
|
return finalRow;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Validate required columns
|
// Validate required columns
|
||||||
const requiredColumns = ['nim', 'kode_mk', 'semester', 'nilai_huruf', 'nilai_angka'];
|
const requiredColumns = ['nim', 'kode_mk', 'semester', 'nilai_huruf', 'nilai_angka'];
|
||||||
const firstRow = finalData[0];
|
const firstRow = finalData[0];
|
||||||
const missingColumns = requiredColumns.filter(col =>
|
const missingColumns = requiredColumns.filter(col =>
|
||||||
firstRow[col] === undefined || firstRow[col] === null || firstRow[col] === ''
|
firstRow[col] === undefined || firstRow[col] === null || firstRow[col] === ''
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -162,7 +162,7 @@ export async function POST(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
// Validate and convert data types
|
// Validate and convert data types
|
||||||
const semester = parseInt(row.semester);
|
const semester = parseInt(row.semester);
|
||||||
|
|
||||||
// Handle decimal comma (Indonesian format) and convert to dot
|
// Handle decimal comma (Indonesian format) and convert to dot
|
||||||
const nilaiAngkaStr = row.nilai_angka.toString().replace(',', '.');
|
const nilaiAngkaStr = row.nilai_angka.toString().replace(',', '.');
|
||||||
const nilai_angka = parseFloat(nilaiAngkaStr);
|
const nilai_angka = parseFloat(nilaiAngkaStr);
|
||||||
@@ -207,7 +207,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
message: 'Validation errors found',
|
message: 'Validation errors found',
|
||||||
errors: errors.slice(0, 10) // Limit to first 10 errors
|
errors: errors.slice(0, 10) // Limit to first 10 errors
|
||||||
},
|
},
|
||||||
@@ -241,7 +241,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const nimToIdMap = new Map(existingMahasiswa?.map(m => [m.nim, m.id_mahasiswa]) || []);
|
const nimToIdMap = new Map(existingMahasiswa?.map(m => [m.nim, m.id_mahasiswa]) || []);
|
||||||
const kodeMKToIdMap = new Map(existingMataKuliah?.map(mk => [mk.kode_mk, mk.id_mk]) || []);
|
const kodeMKToIdMap = new Map(existingMataKuliah?.map(mk => [mk.kode_mk, mk.id_mk]) || []);
|
||||||
|
|
||||||
// Process data for insertion
|
// Process data for insertion/update
|
||||||
const insertData: any[] = [];
|
const insertData: any[] = [];
|
||||||
const validationErrors: string[] = [];
|
const validationErrors: string[] = [];
|
||||||
|
|
||||||
@@ -271,7 +271,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
if (validationErrors.length > 0) {
|
if (validationErrors.length > 0) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
message: 'Data validation errors',
|
message: 'Data validation errors',
|
||||||
errors: validationErrors.slice(0, 10)
|
errors: validationErrors.slice(0, 10)
|
||||||
},
|
},
|
||||||
@@ -279,26 +279,88 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert data to database
|
// Fetch existing nilai_mahasiswa to determine which to update vs insert
|
||||||
let successCount = 0;
|
const { data: existingNilai, error: fetchNilaiError } = await supabase
|
||||||
if (insertData.length > 0) {
|
.from('nilai_mahasiswa')
|
||||||
|
.select('id_nilai, id_mahasiswa, id_mk');
|
||||||
|
|
||||||
|
if (fetchNilaiError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Failed to fetch existing nilai data' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create map of existing nilai: "id_mahasiswa-id_mk" -> id_nilai
|
||||||
|
const existingNilaiMap = new Map(
|
||||||
|
(existingNilai || []).map(n => [`${n.id_mahasiswa}-${n.id_mk}`, n.id_nilai])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Separate data into updates and inserts
|
||||||
|
const dataToUpdate: any[] = [];
|
||||||
|
const dataToInsert: any[] = [];
|
||||||
|
|
||||||
|
for (const data of insertData) {
|
||||||
|
const key = `${data.id_mahasiswa}-${data.id_mk}`;
|
||||||
|
const existingId = existingNilaiMap.get(key);
|
||||||
|
|
||||||
|
if (existingId) {
|
||||||
|
// Data exists, prepare for update
|
||||||
|
dataToUpdate.push({
|
||||||
|
id_nilai: existingId,
|
||||||
|
...data,
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Data doesn't exist, prepare for insert
|
||||||
|
dataToInsert.push(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let insertCount = 0;
|
||||||
|
let updateCount = 0;
|
||||||
|
|
||||||
|
// Perform batch insert
|
||||||
|
if (dataToInsert.length > 0) {
|
||||||
const { error: insertError } = await supabase
|
const { error: insertError } = await supabase
|
||||||
.from('nilai_mahasiswa')
|
.from('nilai_mahasiswa')
|
||||||
.insert(insertData);
|
.insert(dataToInsert);
|
||||||
|
|
||||||
if (insertError) {
|
if (insertError) {
|
||||||
|
console.error('Insert error:', insertError);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ message: 'Failed to insert data to database' },
|
{ message: 'Failed to insert data to database', error: insertError.message },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
successCount = insertData.length;
|
insertCount = dataToInsert.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform batch update (one by one because Supabase doesn't support batch update easily)
|
||||||
|
if (dataToUpdate.length > 0) {
|
||||||
|
for (const data of dataToUpdate) {
|
||||||
|
const { id_nilai, ...updateFields } = data;
|
||||||
|
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from('nilai_mahasiswa')
|
||||||
|
.update(updateFields)
|
||||||
|
.eq('id_nilai', id_nilai);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
console.error('Update error:', updateError);
|
||||||
|
// Continue with other updates even if one fails
|
||||||
|
} else {
|
||||||
|
updateCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: 'Upload completed',
|
message: 'Upload completed',
|
||||||
successCount,
|
inserted: insertCount,
|
||||||
|
updated: updateCount,
|
||||||
|
totalProcessed: insertCount + updateCount,
|
||||||
totalRows: processedData.length
|
totalRows: processedData.length
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -147,10 +147,10 @@ export async function GET() {
|
|||||||
) {
|
) {
|
||||||
isDO = 1;
|
isDO = 1;
|
||||||
}
|
}
|
||||||
// Evaluasi akhir masa studi: semester = 12 AND (sks_total < 144 OR ipk <= 2.00 OR jumlah_e > 0 OR sks_d > 14 OR min_wajib < 2.00 OR lulus_ta1 = 0 OR lulus_ta2 = 0)
|
// Evaluasi akhir masa studi: semester lebih besar atau sama dengan 12 AND (sks_total < 144 OR ipk <= 2.00 OR jumlah_e > 0 OR sks_d > 14 OR min_wajib < 2.00 OR lulus_ta1 = 0 OR lulus_ta2 = 0)
|
||||||
// Di SQL: lulus_ta1 = 0 akan TRUE jika lulus_ta1 adalah 0, FALSE jika NULL atau 1
|
// Di SQL: lulus_ta1 = 0 akan TRUE jika lulus_ta1 adalah 0, FALSE jika NULL atau 1
|
||||||
else if (
|
else if (
|
||||||
sem === 12 &&
|
sem >= 12 &&
|
||||||
sksTotal !== null && // Pastikan ada data nilai
|
sksTotal !== null && // Pastikan ada data nilai
|
||||||
(
|
(
|
||||||
sksTotal < 144 ||
|
sksTotal < 144 ||
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
// Evaluasi akhir masa studi: semester = 14
|
// Evaluasi akhir masa studi: semester = 14
|
||||||
else if (
|
else if (
|
||||||
sem === 12 &&
|
sem >= 12 &&
|
||||||
sksTotal !== null &&
|
sksTotal !== null &&
|
||||||
(
|
(
|
||||||
sksTotal < 144 ||
|
sksTotal < 144 ||
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ export default function TerancamDODetailPage() {
|
|||||||
<li>• Menampilkan jumlah mahasiswa yang terancam drop out (DO) per tahun angkatan</li>
|
<li>• Menampilkan jumlah mahasiswa yang terancam drop out (DO) per tahun angkatan</li>
|
||||||
<li>• Evaluasi dilakukan berdasarkan pedoman akademik UNTAN tahun 2023/2024</li>
|
<li>• Evaluasi dilakukan berdasarkan pedoman akademik UNTAN tahun 2023/2024</li>
|
||||||
<li>• Kriteria evaluasi terdiri dari tiga tahap (evaluasi dilakukan sebelum semester target):</li>
|
<li>• Kriteria evaluasi terdiri dari tiga tahap (evaluasi dilakukan sebelum semester target):</li>
|
||||||
<li className="ml-4">- <strong>Evaluasi semester 3</strong>: SKS minimal 40 dan IPK > 2.50 (sebelum masuk semester 4)</li>
|
<li className="ml-4">- <strong>Evaluasi semester 3</strong>: SKS minimal 40 dan IPK ≤ 2.50 (sebelum masuk semester 4)</li>
|
||||||
<li className="ml-4">- <strong>Evaluasi semester 7</strong>: SKS minimal 80 dan IPK > 2.50 (sebelum masuk semester 8)</li>
|
<li className="ml-4">- <strong>Evaluasi semester 7</strong>: SKS minimal 80 dan IPK ≤ 2.50 (sebelum masuk semester 8)</li>
|
||||||
<li className="ml-4">- <strong>Evaluasi semester 12</strong>: SKS minimal 144, IPK > 2.00, tidak ada nilai E, nilai D maksimal 10%, nilai mata kuliah wajib minimal C, dan lulus tugas akhir (sebelum semester 14)</li>
|
<li className="ml-4">- <strong>Evaluasi semester 12</strong>: SKS minimal 144, IPK ≤ 2.00, tidak ada nilai E, nilai D maksimal 10%, nilai mata kuliah wajib minimal C, dan lulus tugas akhir (sebelum semester 14)</li>
|
||||||
<li>• Grafik batang vertikal yang menunjukkan jumlah mahasiswa terancam DO per tahun angkatan</li>
|
<li>• Grafik batang vertikal yang menunjukkan jumlah mahasiswa terancam DO per tahun angkatan</li>
|
||||||
<li>• Data dapat di-download dan dianalisis untuk monitoring akademik</li>
|
<li>• Data dapat di-download dan dianalisis untuk monitoring akademik</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
181
app/globals.css
181
app/globals.css
@@ -3,6 +3,37 @@
|
|||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
/* Custom properties for gradient animation */
|
||||||
|
@property --bg-1-x {
|
||||||
|
syntax: "<number>";
|
||||||
|
inherits: true;
|
||||||
|
initial-value: 25;
|
||||||
|
}
|
||||||
|
|
||||||
|
@property --bg-2-x {
|
||||||
|
syntax: "<number>";
|
||||||
|
inherits: true;
|
||||||
|
initial-value: 35;
|
||||||
|
}
|
||||||
|
|
||||||
|
@property --bg-2-y {
|
||||||
|
syntax: "<number>";
|
||||||
|
inherits: true;
|
||||||
|
initial-value: 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
@property --bg-3-x {
|
||||||
|
syntax: "<number>";
|
||||||
|
inherits: true;
|
||||||
|
initial-value: 45;
|
||||||
|
}
|
||||||
|
|
||||||
|
@property --bg-3-y {
|
||||||
|
syntax: "<number>";
|
||||||
|
inherits: true;
|
||||||
|
initial-value: 20;
|
||||||
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
@@ -120,3 +151,153 @@
|
|||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Gradient text animation keyframes */
|
||||||
|
@keyframes gradient-bg-animate {
|
||||||
|
0% {
|
||||||
|
--bg-1-x: 25;
|
||||||
|
--bg-2-y: 40;
|
||||||
|
--bg-2-x: 35;
|
||||||
|
--bg-3-y: 20;
|
||||||
|
--bg-3-x: 45;
|
||||||
|
}
|
||||||
|
|
||||||
|
25% {
|
||||||
|
--bg-1-x: 30;
|
||||||
|
--bg-2-y: 50;
|
||||||
|
--bg-2-x: 80;
|
||||||
|
--bg-3-y: 20;
|
||||||
|
--bg-3-x: 70;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
--bg-1-x: 10;
|
||||||
|
--bg-2-y: 40;
|
||||||
|
--bg-2-x: 30;
|
||||||
|
--bg-3-y: 80;
|
||||||
|
--bg-3-x: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
75% {
|
||||||
|
--bg-1-x: 70;
|
||||||
|
--bg-2-y: 10;
|
||||||
|
--bg-2-x: 50;
|
||||||
|
--bg-3-y: 30;
|
||||||
|
--bg-3-x: 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
--bg-1-x: 25;
|
||||||
|
--bg-2-y: 40;
|
||||||
|
--bg-2-x: 35;
|
||||||
|
--bg-3-y: 20;
|
||||||
|
--bg-3-x: 45;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient text utility class */
|
||||||
|
.gradient-text-animated {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(
|
||||||
|
135deg,
|
||||||
|
#ffffff 0%,
|
||||||
|
#3b82f6 12%,
|
||||||
|
#10b981 24%,
|
||||||
|
#f59e0b 36%,
|
||||||
|
#ef4444 48%,
|
||||||
|
#8b5cf6 60%,
|
||||||
|
#06b6d4 72%,
|
||||||
|
#ec4899 84%,
|
||||||
|
#ffffff 100%
|
||||||
|
);
|
||||||
|
background-size: 300% 300%;
|
||||||
|
background-position: calc(var(--bg-1-x) * 2%) 50%;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
animation: gradient-bg-animate 20s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glass effect with grain texture */
|
||||||
|
.glass-effect {
|
||||||
|
background: rgba(30, 41, 59, 0.7);
|
||||||
|
backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 32px 0 rgba(0, 0, 0, 0.37),
|
||||||
|
inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-effect::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 600 600' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
||||||
|
background-size: 200px;
|
||||||
|
opacity: 0.03;
|
||||||
|
pointer-events: none;
|
||||||
|
mix-blend-mode: overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glass input field */
|
||||||
|
.glass-input {
|
||||||
|
background: rgba(15, 23, 42, 0.5);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-input:focus {
|
||||||
|
background: rgba(15, 23, 42, 0.7);
|
||||||
|
border-color: rgba(59, 130, 246, 0.5);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 3px rgba(59, 130, 246, 0.1),
|
||||||
|
inset 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glass button */
|
||||||
|
.glass-button {
|
||||||
|
background: linear-gradient(135deg, rgba(59, 130, 246, 0.7), rgba(37, 99, 235, 0.8));
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||||
|
box-shadow:
|
||||||
|
0 2px 8px rgba(59, 130, 246, 0.2),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-button:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, rgba(37, 99, 235, 0.8), rgba(29, 78, 216, 0.9));
|
||||||
|
box-shadow:
|
||||||
|
0 4px 12px rgba(59, 130, 246, 0.3),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-button:disabled {
|
||||||
|
background: linear-gradient(135deg, rgba(59, 130, 246, 0.4), rgba(37, 99, 235, 0.5));
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glass tab button */
|
||||||
|
.glass-tab {
|
||||||
|
background: rgba(15, 23, 42, 0.3);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-tab-active {
|
||||||
|
background: linear-gradient(135deg, rgba(59, 130, 246, 0.6), rgba(37, 99, 235, 0.7));
|
||||||
|
border-color: rgba(59, 130, 246, 0.3);
|
||||||
|
box-shadow:
|
||||||
|
0 2px 8px rgba(59, 130, 246, 0.2),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|||||||
43
app/page.tsx
43
app/page.tsx
@@ -95,7 +95,7 @@ export default function HomePage() {
|
|||||||
<div className="w-full max-w-sm mx-auto p-4 relative z-10">
|
<div className="w-full max-w-sm mx-auto p-4 relative z-10">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="text-center space-y-3 mb-6">
|
<div className="text-center space-y-3 mb-6">
|
||||||
<h1 className="text-3xl font-bold text-white">
|
<h1 className="text-2xl md:text-2xl lg:text-3xl font-bold gradient-text-animated leading-tight">
|
||||||
Portal Data Informatika
|
Portal Data Informatika
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -195,10 +195,7 @@ function AutoLoginDialog({ onLoginSuccess }: AutoLoginDialogProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-slate-800/95 backdrop-blur-md rounded-xl shadow-xl p-6 w-full border border-slate-600/50 relative">
|
<div className="glass-effect rounded-2xl shadow-2xl p-6 w-full relative overflow-hidden">
|
||||||
{/* Subtle glow effect */}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-blue-500/5 to-purple-500/5 rounded-xl"></div>
|
|
||||||
|
|
||||||
<div className="relative z-10">
|
<div className="relative z-10">
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
<h2 className="text-xl font-bold text-white mb-1">
|
<h2 className="text-xl font-bold text-white mb-1">
|
||||||
@@ -210,24 +207,24 @@ function AutoLoginDialog({ onLoginSuccess }: AutoLoginDialogProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Tab buttons */}
|
{/* Tab buttons with glass effect */}
|
||||||
<div className="flex rounded-lg bg-slate-700/50 p-1 border border-slate-600">
|
<div className="flex rounded-xl glass-tab p-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("ketua")}
|
onClick={() => setActiveTab("ketua")}
|
||||||
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-all duration-200 ${
|
className={`flex-1 py-2 px-3 rounded-md text-sm font-semibold transition-all duration-200 ${
|
||||||
activeTab === "ketua"
|
activeTab === "ketua"
|
||||||
? "bg-blue-600 text-white shadow-md"
|
? "glass-tab-active text-white"
|
||||||
: "text-slate-300 hover:text-white hover:bg-slate-600/50"
|
: "text-slate-300 hover:text-white hover:bg-white/5"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Pimpinan
|
Pimpinan
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("admin")}
|
onClick={() => setActiveTab("admin")}
|
||||||
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-all duration-200 ${
|
className={`flex-1 py-2 px-3 rounded-md text-sm font-semibold transition-all duration-200 ${
|
||||||
activeTab === "admin"
|
activeTab === "admin"
|
||||||
? "bg-blue-600 text-white shadow-md"
|
? "glass-tab-active text-white"
|
||||||
: "text-slate-300 hover:text-white hover:bg-slate-600/50"
|
: "text-slate-300 hover:text-white hover:bg-white/5"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Admin
|
Admin
|
||||||
@@ -238,7 +235,7 @@ function AutoLoginDialog({ onLoginSuccess }: AutoLoginDialogProps) {
|
|||||||
{activeTab === "ketua" && (
|
{activeTab === "ketua" && (
|
||||||
<form onSubmit={handleKetuaLogin} className="space-y-4">
|
<form onSubmit={handleKetuaLogin} className="space-y-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label htmlFor="nip" className="block text-sm font-medium text-slate-200">
|
<label htmlFor="nip" className="block text-sm font-medium text-white/90">
|
||||||
NIP
|
NIP
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -248,11 +245,11 @@ function AutoLoginDialog({ onLoginSuccess }: AutoLoginDialogProps) {
|
|||||||
value={ketuaForm.nip}
|
value={ketuaForm.nip}
|
||||||
onChange={(e) => setKetuaForm({ ...ketuaForm, nip: e.target.value })}
|
onChange={(e) => setKetuaForm({ ...ketuaForm, nip: e.target.value })}
|
||||||
required
|
required
|
||||||
className="w-full px-3 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
className="glass-input w-full px-3 py-2 rounded-lg text-white placeholder-slate-400 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label htmlFor="ketua-password" className="block text-sm font-medium text-slate-200">
|
<label htmlFor="ketua-password" className="block text-sm font-medium text-white/90">
|
||||||
Password
|
Password
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -262,13 +259,13 @@ function AutoLoginDialog({ onLoginSuccess }: AutoLoginDialogProps) {
|
|||||||
value={ketuaForm.password}
|
value={ketuaForm.password}
|
||||||
onChange={(e) => setKetuaForm({ ...ketuaForm, password: e.target.value })}
|
onChange={(e) => setKetuaForm({ ...ketuaForm, password: e.target.value })}
|
||||||
required
|
required
|
||||||
className="w-full px-3 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
className="glass-input w-full px-3 py-2 rounded-lg text-white placeholder-slate-400 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="w-full bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 disabled:from-blue-400 disabled:to-blue-500 text-white font-medium py-2.5 px-4 rounded-lg transition-all duration-200 shadow-md"
|
className="glass-button w-full text-white font-medium py-2.5 px-4 rounded-lg"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
@@ -286,7 +283,7 @@ function AutoLoginDialog({ onLoginSuccess }: AutoLoginDialogProps) {
|
|||||||
{activeTab === "admin" && (
|
{activeTab === "admin" && (
|
||||||
<form onSubmit={handleAdminLogin} className="space-y-4">
|
<form onSubmit={handleAdminLogin} className="space-y-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label htmlFor="username" className="block text-sm font-medium text-slate-200">
|
<label htmlFor="username" className="block text-sm font-medium text-white/90">
|
||||||
Username
|
Username
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -296,11 +293,11 @@ function AutoLoginDialog({ onLoginSuccess }: AutoLoginDialogProps) {
|
|||||||
value={adminForm.username}
|
value={adminForm.username}
|
||||||
onChange={(e) => setAdminForm({ ...adminForm, username: e.target.value })}
|
onChange={(e) => setAdminForm({ ...adminForm, username: e.target.value })}
|
||||||
required
|
required
|
||||||
className="w-full px-3 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
className="glass-input w-full px-3 py-2 rounded-lg text-white placeholder-slate-400 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label htmlFor="admin-password" className="block text-sm font-medium text-slate-200">
|
<label htmlFor="admin-password" className="block text-sm font-medium text-white/90">
|
||||||
Password
|
Password
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -310,13 +307,13 @@ function AutoLoginDialog({ onLoginSuccess }: AutoLoginDialogProps) {
|
|||||||
value={adminForm.password}
|
value={adminForm.password}
|
||||||
onChange={(e) => setAdminForm({ ...adminForm, password: e.target.value })}
|
onChange={(e) => setAdminForm({ ...adminForm, password: e.target.value })}
|
||||||
required
|
required
|
||||||
className="w-full px-3 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
className="glass-input w-full px-3 py-2 rounded-lg text-white placeholder-slate-400 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="w-full bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 disabled:from-blue-400 disabled:to-blue-500 text-white font-medium py-2.5 px-4 rounded-lg transition-all duration-200 shadow-md"
|
className="glass-button w-full text-white font-medium py-2.5 px-4 rounded-lg"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export default function JenisPendaftaranPerAngkatanChart({ tahunAngkatan }: Prop
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [data, setData] = useState<JenisPendaftaranData[]>([]);
|
const [data, setData] = useState<JenisPendaftaranData[]>([]);
|
||||||
const [series, setSeries] = useState<number[]>([]);
|
const [series, setSeries] = useState<number[]>([]);
|
||||||
|
const [chartColors, setChartColors] = useState<string[]>([]);
|
||||||
const [options, setOptions] = useState<ApexOptions>({
|
const [options, setOptions] = useState<ApexOptions>({
|
||||||
chart: {
|
chart: {
|
||||||
type: 'pie',
|
type: 'pie',
|
||||||
@@ -43,7 +44,7 @@ export default function JenisPendaftaranPerAngkatanChart({ tahunAngkatan }: Prop
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
labels: [],
|
labels: [],
|
||||||
colors: ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4', '#F97316'],
|
colors: [],
|
||||||
legend: {
|
legend: {
|
||||||
position: 'bottom',
|
position: 'bottom',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
@@ -101,9 +102,11 @@ export default function JenisPendaftaranPerAngkatanChart({ tahunAngkatan }: Prop
|
|||||||
tooltip: {
|
tooltip: {
|
||||||
...prev.tooltip,
|
...prev.tooltip,
|
||||||
theme: theme === 'dark' ? 'dark' : 'light'
|
theme: theme === 'dark' ? 'dark' : 'light'
|
||||||
}
|
},
|
||||||
|
// Preserve computed colors across theme changes
|
||||||
|
colors: chartColors.length > 0 ? chartColors : prev.colors,
|
||||||
}));
|
}));
|
||||||
}, [theme]);
|
}, [theme, chartColors]);
|
||||||
|
|
||||||
// Update dataLabels formatter when data changes
|
// Update dataLabels formatter when data changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -141,29 +144,46 @@ export default function JenisPendaftaranPerAngkatanChart({ tahunAngkatan }: Prop
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const response = await fetch(`/api/mahasiswa/jenis-pendaftaran?tahun_angkatan=${tahunAngkatan}`);
|
// Fetch year-filtered data and global data in parallel
|
||||||
|
const [response, allResponse] = await Promise.all([
|
||||||
|
fetch(`/api/mahasiswa/jenis-pendaftaran?tahun_angkatan=${tahunAngkatan}`),
|
||||||
|
fetch('/api/mahasiswa/jenis-pendaftaran'),
|
||||||
|
]);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
|
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
const allResult = allResponse.ok ? await allResponse.json() : result;
|
||||||
|
|
||||||
if (!Array.isArray(result)) {
|
if (!Array.isArray(result)) {
|
||||||
throw new Error('Invalid data format received from server');
|
throw new Error('Invalid data format received from server');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build global sorted jenis list (same order as bar chart) for stable color assignment
|
||||||
|
const colorPalette = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4', '#F97316'];
|
||||||
|
const allJenis: string[] = [...new Set((allResult as any[]).map(item => item.jenis_pendaftaran as string))].sort() as string[];
|
||||||
|
|
||||||
// Process data for pie chart
|
// Process data for pie chart
|
||||||
const jenisPendaftaran = [...new Set(result.map(item => item.jenis_pendaftaran))].sort();
|
const jenisPendaftaran = [...new Set(result.map(item => item.jenis_pendaftaran))].sort() as string[];
|
||||||
const jumlahData = jenisPendaftaran.map(jenis => {
|
const jumlahData = jenisPendaftaran.map((jenis: string) => {
|
||||||
const item = result.find(d => d.jenis_pendaftaran === jenis);
|
const item = result.find((d: any) => d.jenis_pendaftaran === jenis);
|
||||||
return item ? item.jumlah : 0;
|
return item ? item.jumlah : 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Assign colors based on global sorted position to match bar chart colors
|
||||||
|
const computedColors = jenisPendaftaran.map((jenis: string) => {
|
||||||
|
const idx = allJenis.indexOf(jenis);
|
||||||
|
return idx >= 0 ? colorPalette[idx % colorPalette.length] : '#999999';
|
||||||
|
});
|
||||||
|
|
||||||
|
setChartColors(computedColors);
|
||||||
setSeries(jumlahData);
|
setSeries(jumlahData);
|
||||||
setOptions(prev => ({
|
setOptions(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
labels: jenisPendaftaran,
|
labels: jenisPendaftaran,
|
||||||
|
colors: computedColors,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Store processed data
|
// Store processed data
|
||||||
|
|||||||
@@ -73,15 +73,10 @@ export default function StatistikMahasiswaChart({
|
|||||||
const seriesIndex = opts.seriesIndex;
|
const seriesIndex = opts.seriesIndex;
|
||||||
const dataPointIndex = opts.dataPointIndex;
|
const dataPointIndex = opts.dataPointIndex;
|
||||||
|
|
||||||
// Jika series Total (index 2), tampilkan angka
|
// Hitung total dari Laki-laki (index 0) dan Perempuan (index 1)
|
||||||
if (seriesIndex === 2) {
|
const lakiLakiData = opts.w.config.series[0]?.data || [];
|
||||||
return val.toString();
|
const perempuanData = opts.w.config.series[1]?.data || [];
|
||||||
}
|
const totalValue = (lakiLakiData[dataPointIndex] || 0) + (perempuanData[dataPointIndex] || 0);
|
||||||
|
|
||||||
// Untuk Laki-laki (index 0) dan Perempuan (index 1), hitung persentase
|
|
||||||
// Ambil data total dari series Total (index 2)
|
|
||||||
const totalSeriesData = opts.w.config.series[2]?.data || [];
|
|
||||||
const totalValue = totalSeriesData[dataPointIndex] || 0;
|
|
||||||
|
|
||||||
if (totalValue === 0 || val === 0) return '0%';
|
if (totalValue === 0 || val === 0) return '0%';
|
||||||
|
|
||||||
@@ -97,7 +92,7 @@ export default function StatistikMahasiswaChart({
|
|||||||
stroke: {
|
stroke: {
|
||||||
show: true,
|
show: true,
|
||||||
width: 2,
|
width: 2,
|
||||||
colors: ['transparent', 'transparent', 'transparent'],
|
colors: ['transparent', 'transparent'],
|
||||||
curve: 'straight' as const
|
curve: 'straight' as const
|
||||||
},
|
},
|
||||||
xaxis: {
|
xaxis: {
|
||||||
@@ -153,7 +148,7 @@ export default function StatistikMahasiswaChart({
|
|||||||
colors: '#000'
|
colors: '#000'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors: ['#3B82F6', '#EC4899', '#10B981'],
|
colors: ['#3B82F6', '#EC4899'],
|
||||||
tooltip: {
|
tooltip: {
|
||||||
theme: 'light',
|
theme: 'light',
|
||||||
shared: true,
|
shared: true,
|
||||||
@@ -161,7 +156,7 @@ export default function StatistikMahasiswaChart({
|
|||||||
custom: function({ series, seriesIndex, dataPointIndex, w }: any) {
|
custom: function({ series, seriesIndex, dataPointIndex, w }: any) {
|
||||||
const lakiLaki = series[0][dataPointIndex];
|
const lakiLaki = series[0][dataPointIndex];
|
||||||
const perempuan = series[1][dataPointIndex];
|
const perempuan = series[1][dataPointIndex];
|
||||||
const total = series[2][dataPointIndex];
|
const total = lakiLaki + perempuan;
|
||||||
const tahun = w.globals.labels[dataPointIndex];
|
const tahun = w.globals.labels[dataPointIndex];
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -199,7 +194,6 @@ export default function StatistikMahasiswaChart({
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
">
|
">
|
||||||
<div style="width: 8px; height: 8px; background: #10B981; border-radius: 50%; margin-right: 8px;"></div>
|
|
||||||
<span style="font-size: 12px; font-weight: 600; color: #1f2937;">Total</span>
|
<span style="font-size: 12px; font-weight: 600; color: #1f2937;">Total</span>
|
||||||
<span style="font-size: 13px; font-weight: 700; color: #10B981; margin-left: auto;">${total}</span>
|
<span style="font-size: 13px; font-weight: 700; color: #10B981; margin-left: auto;">${total}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -248,18 +242,11 @@ export default function StatistikMahasiswaChart({
|
|||||||
dataLabels: {
|
dataLabels: {
|
||||||
...prev.dataLabels,
|
...prev.dataLabels,
|
||||||
formatter: function (val: number, opts: any) {
|
formatter: function (val: number, opts: any) {
|
||||||
const seriesIndex = opts.seriesIndex;
|
|
||||||
const dataPointIndex = opts.dataPointIndex;
|
const dataPointIndex = opts.dataPointIndex;
|
||||||
|
|
||||||
// Jika series Total (index 2), tampilkan angka
|
const lakiLakiData = opts.w.config.series[0]?.data || [];
|
||||||
if (seriesIndex === 2) {
|
const perempuanData = opts.w.config.series[1]?.data || [];
|
||||||
return val.toString();
|
const totalValue = (lakiLakiData[dataPointIndex] || 0) + (perempuanData[dataPointIndex] || 0);
|
||||||
}
|
|
||||||
|
|
||||||
// Untuk Laki-laki (index 0) dan Perempuan (index 1), hitung persentase
|
|
||||||
// Ambil data total dari series Total (index 2)
|
|
||||||
const totalSeriesData = opts.w.config.series[2]?.data || [];
|
|
||||||
const totalValue = totalSeriesData[dataPointIndex] || 0;
|
|
||||||
|
|
||||||
if (totalValue === 0 || val === 0) return '0%';
|
if (totalValue === 0 || val === 0) return '0%';
|
||||||
|
|
||||||
@@ -320,13 +307,9 @@ export default function StatistikMahasiswaChart({
|
|||||||
custom: function({ series, seriesIndex, dataPointIndex, w }: any) {
|
custom: function({ series, seriesIndex, dataPointIndex, w }: any) {
|
||||||
const lakiLaki = series[0][dataPointIndex];
|
const lakiLaki = series[0][dataPointIndex];
|
||||||
const perempuan = series[1][dataPointIndex];
|
const perempuan = series[1][dataPointIndex];
|
||||||
const total = series[2][dataPointIndex];
|
const total = lakiLaki + perempuan;
|
||||||
const tahun = w.globals.labels[dataPointIndex];
|
const tahun = w.globals.labels[dataPointIndex];
|
||||||
|
|
||||||
const bgColor = currentTheme === 'dark' ? '#1e293b' : 'white';
|
|
||||||
const textColor = currentTheme === 'dark' ? '#fff' : '#000';
|
|
||||||
const borderColor = currentTheme === 'dark' ? '#475569' : '#ccc';
|
|
||||||
|
|
||||||
const isDark = currentTheme === 'dark';
|
const isDark = currentTheme === 'dark';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -364,7 +347,6 @@ export default function StatistikMahasiswaChart({
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
">
|
">
|
||||||
<div style="width: 8px; height: 8px; background: #10B981; border-radius: 50%; margin-right: 8px;"></div>
|
|
||||||
<span style="font-size: 12px; font-weight: 600; color: ${isDark ? '#f1f5f9' : '#1f2937'};">Total</span>
|
<span style="font-size: 12px; font-weight: 600; color: ${isDark ? '#f1f5f9' : '#1f2937'};">Total</span>
|
||||||
<span style="font-size: 13px; font-weight: 700; color: #10B981; margin-left: auto;">${total}</span>
|
<span style="font-size: 13px; font-weight: 700; color: #10B981; margin-left: auto;">${total}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -404,11 +386,6 @@ export default function StatistikMahasiswaChart({
|
|||||||
name: 'Perempuan',
|
name: 'Perempuan',
|
||||||
type: 'bar' as const,
|
type: 'bar' as const,
|
||||||
data: statistikData.map(item => item.wanita)
|
data: statistikData.map(item => item.wanita)
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Total',
|
|
||||||
type: 'bar' as const,
|
|
||||||
data: statistikData.map(item => item.total_mahasiswa)
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export default function StatusMahasiswaFilterPieChart({ selectedYear, selectedSt
|
|||||||
background: theme === 'dark' ? '#0F172B' : '#fff',
|
background: theme === 'dark' ? '#0F172B' : '#fff',
|
||||||
},
|
},
|
||||||
labels: ['Laki-laki', 'Perempuan'],
|
labels: ['Laki-laki', 'Perempuan'],
|
||||||
colors: ['#3B82F6', '#EC4899'],
|
colors: ['#008FFB', '#EC4899'],
|
||||||
legend: {
|
legend: {
|
||||||
position: 'bottom',
|
position: 'bottom',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
|
|||||||
@@ -81,9 +81,18 @@ export default function DistribusiIPKChartPerangkatan({ selectedYear }: Props) {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [selectedYear]);
|
}, [selectedYear]);
|
||||||
|
|
||||||
|
// Fixed color map consistent with bar chart (DistribusiIPKChart)
|
||||||
|
const KATEGORI_COLOR_MAP: { [key: string]: string } = {
|
||||||
|
'Sangat Baik': '#008FFB',
|
||||||
|
'Baik': '#00E396',
|
||||||
|
'Cukup': '#FEB019',
|
||||||
|
'Kurang': '#EF4444',
|
||||||
|
};
|
||||||
|
|
||||||
// Prepare data for pie chart
|
// Prepare data for pie chart
|
||||||
const series = data.map(item => item.jumlah);
|
const series = data.map(item => item.jumlah);
|
||||||
const labels = data.map(item => kategoriLabelsMap[item.kategori_ipk] || item.kategori_ipk);
|
const labels = data.map(item => kategoriLabelsMap[item.kategori_ipk] || item.kategori_ipk);
|
||||||
|
const colors = data.map(item => KATEGORI_COLOR_MAP[item.kategori_ipk] || '#999999');
|
||||||
|
|
||||||
const chartOptions: ApexOptions = {
|
const chartOptions: ApexOptions = {
|
||||||
chart: {
|
chart: {
|
||||||
@@ -122,12 +131,7 @@ export default function DistribusiIPKChartPerangkatan({ selectedYear }: Props) {
|
|||||||
return seriesName;
|
return seriesName;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors: [
|
colors: colors,
|
||||||
'#008FFB', // Sangat Baik - Blue
|
|
||||||
'#00E396', // Baik - Green
|
|
||||||
'#FEB019', // Cukup - Orange
|
|
||||||
'#EF4444', // Kurang - Red
|
|
||||||
],
|
|
||||||
tooltip: {
|
tooltip: {
|
||||||
theme: theme === 'dark' ? 'dark' : 'light',
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
y: {
|
y: {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface Props {
|
|||||||
export default function NamaBeasiswaDashPieChartPerangkatan({ selectedYear }: Props) {
|
export default function NamaBeasiswaDashPieChartPerangkatan({ selectedYear }: Props) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const [data, setData] = useState<NamaBeasiswaPerAngkatanData[]>([]);
|
const [data, setData] = useState<NamaBeasiswaPerAngkatanData[]>([]);
|
||||||
|
const [chartColors, setChartColors] = useState<string[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -43,6 +44,11 @@ export default function NamaBeasiswaDashPieChartPerangkatan({ selectedYear }: Pr
|
|||||||
throw new Error('Invalid data format received from server');
|
throw new Error('Invalid data format received from server');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build global alphabetical list from all data (same order as bar chart)
|
||||||
|
const colorPalette = ['#3B82F6', '#EC4899', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#06B6D4'];
|
||||||
|
const allBeasiswa: string[] = [...new Set((result as any[]).map((item: any) => item.nama_beasiswa as string))]
|
||||||
|
.sort();
|
||||||
|
|
||||||
// Filter data for selected year and group by nama_beasiswa
|
// Filter data for selected year and group by nama_beasiswa
|
||||||
const yearData = result.filter((item: any) =>
|
const yearData = result.filter((item: any) =>
|
||||||
item.tahun_angkatan.toString() === selectedYear
|
item.tahun_angkatan.toString() === selectedYear
|
||||||
@@ -55,14 +61,20 @@ export default function NamaBeasiswaDashPieChartPerangkatan({ selectedYear }: Pr
|
|||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
// Convert to array format
|
// Convert to array format and sort by count descending (display order)
|
||||||
const chartData = Object.entries(groupedData).map(([nama_beasiswa, jumlah_nama_beasiswa]) => ({
|
const chartData = Object.entries(groupedData).map(([nama_beasiswa, jumlah_nama_beasiswa]) => ({
|
||||||
nama_beasiswa,
|
nama_beasiswa,
|
||||||
jumlah_nama_beasiswa: jumlah_nama_beasiswa as number
|
jumlah_nama_beasiswa: jumlah_nama_beasiswa as number
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Sort by jumlah_nama_beasiswa descending
|
|
||||||
const sortedData = chartData.sort((a, b) => (b.jumlah_nama_beasiswa as number) - (a.jumlah_nama_beasiswa as number));
|
const sortedData = chartData.sort((a, b) => (b.jumlah_nama_beasiswa as number) - (a.jumlah_nama_beasiswa as number));
|
||||||
|
|
||||||
|
// Assign colors based on global alphabetical position to match bar chart
|
||||||
|
const computedColors = sortedData.map(item => {
|
||||||
|
const idx = allBeasiswa.indexOf(item.nama_beasiswa);
|
||||||
|
return idx >= 0 ? colorPalette[idx % colorPalette.length] : '#999999';
|
||||||
|
});
|
||||||
|
|
||||||
|
setChartColors(computedColors);
|
||||||
setData(sortedData);
|
setData(sortedData);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error in fetchData:', err);
|
console.error('Error in fetchData:', err);
|
||||||
@@ -122,7 +134,7 @@ export default function NamaBeasiswaDashPieChartPerangkatan({ selectedYear }: Pr
|
|||||||
colors: theme === 'dark' ? '#fff' : '#000'
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors: [
|
colors: chartColors.length > 0 ? chartColors : [
|
||||||
'#3B82F6', // Blue
|
'#3B82F6', // Blue
|
||||||
'#EC4899', // Pink
|
'#EC4899', // Pink
|
||||||
'#10B981', // Green
|
'#10B981', // Green
|
||||||
|
|||||||
@@ -65,9 +65,18 @@ export default function StatusMahasiswaPieChartPerangkatan({ selectedYear }: Pro
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [selectedYear]);
|
}, [selectedYear]);
|
||||||
|
|
||||||
|
// Color mapping consistent with StatusMahasiswaChart bar chart
|
||||||
|
const STATUS_COLOR_MAP: { [key: string]: string } = {
|
||||||
|
'Aktif': '#008FFB',
|
||||||
|
'Lulus': '#00E396',
|
||||||
|
'Cuti': '#FEB019',
|
||||||
|
'Non Aktif': '#EF4444',
|
||||||
|
};
|
||||||
|
|
||||||
// Prepare data for pie chart
|
// Prepare data for pie chart
|
||||||
const series = data.map(item => item.jumlah);
|
const series = data.map(item => item.jumlah);
|
||||||
const labels = data.map(item => item.status_kuliah);
|
const labels = data.map(item => item.status_kuliah);
|
||||||
|
const colors = data.map(item => STATUS_COLOR_MAP[item.status_kuliah] || '#775DD0');
|
||||||
|
|
||||||
const chartOptions: ApexOptions = {
|
const chartOptions: ApexOptions = {
|
||||||
chart: {
|
chart: {
|
||||||
@@ -103,13 +112,7 @@ export default function StatusMahasiswaPieChartPerangkatan({ selectedYear }: Pro
|
|||||||
colors: theme === 'dark' ? '#fff' : '#000'
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors: [
|
colors: colors,
|
||||||
'#00E396', // Aktif - Green
|
|
||||||
'#008FFB', // Lulus - Blue
|
|
||||||
'#FEB019', // Cuti - Orange
|
|
||||||
'#FF4560', // Non-Aktif - Red
|
|
||||||
'#775DD0', // Lainnya - Purple
|
|
||||||
],
|
|
||||||
tooltip: {
|
tooltip: {
|
||||||
theme: theme === 'dark' ? 'dark' : 'light',
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
y: {
|
y: {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface Props {
|
|||||||
export default function KelompokKeahlianPieChartPerAngkatan({ selectedYear }: Props) {
|
export default function KelompokKeahlianPieChartPerAngkatan({ selectedYear }: Props) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const [data, setData] = useState<KelompokKeahlianPerAngkatanData[]>([]);
|
const [data, setData] = useState<KelompokKeahlianPerAngkatanData[]>([]);
|
||||||
|
const [chartColors, setChartColors] = useState<string[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -27,14 +28,23 @@ export default function KelompokKeahlianPieChartPerAngkatan({ selectedYear }: Pr
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await fetch(
|
|
||||||
`/api/mahasiswa/kk-dashboard?tahun_angkatan=${selectedYear}`
|
// Fetch year-filtered data and global data in parallel
|
||||||
);
|
const [response, allResponse] = await Promise.all([
|
||||||
|
fetch(`/api/mahasiswa/kk-dashboard?tahun_angkatan=${selectedYear}`),
|
||||||
|
fetch('/api/mahasiswa/kk-dashboard'),
|
||||||
|
]);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch data');
|
throw new Error('Failed to fetch data');
|
||||||
}
|
}
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
const allResult = allResponse.ok ? await allResponse.json() : result;
|
||||||
|
|
||||||
|
// Build global alphabetical list of kelompok (same order as bar chart)
|
||||||
|
const colorPalette = ['#008FFB', '#00E396', '#FEB019', '#FF4560', '#775DD0', '#8B5CF6', '#EC4899', '#06B6D4', '#F97316'];
|
||||||
|
const allKelompok: string[] = [...new Set((allResult as any[]).map((item: any) => item.nama_kelompok as string))]
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
// Group by nama_kelompok and sum jumlah_mahasiswa
|
// Group by nama_kelompok and sum jumlah_mahasiswa
|
||||||
const groupedData = result.reduce((acc: { [key: string]: number }, item: any) => {
|
const groupedData = result.reduce((acc: { [key: string]: number }, item: any) => {
|
||||||
const namaKelompok = item.nama_kelompok || 'Tidak Diketahui';
|
const namaKelompok = item.nama_kelompok || 'Tidak Diketahui';
|
||||||
@@ -42,14 +52,20 @@ export default function KelompokKeahlianPieChartPerAngkatan({ selectedYear }: Pr
|
|||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
// Convert to array format
|
// Convert to array format and sort by jumlah_mahasiswa descending (display order)
|
||||||
const chartData = Object.entries(groupedData).map(([nama_kelompok, jumlah_mahasiswa]) => ({
|
const chartData = Object.entries(groupedData).map(([nama_kelompok, jumlah_mahasiswa]) => ({
|
||||||
nama_kelompok,
|
nama_kelompok,
|
||||||
jumlah_mahasiswa: jumlah_mahasiswa as number
|
jumlah_mahasiswa: jumlah_mahasiswa as number
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Sort by jumlah_mahasiswa descending
|
|
||||||
const sortedData = chartData.sort((a, b) => (b.jumlah_mahasiswa as number) - (a.jumlah_mahasiswa as number));
|
const sortedData = chartData.sort((a, b) => (b.jumlah_mahasiswa as number) - (a.jumlah_mahasiswa as number));
|
||||||
|
|
||||||
|
// Assign colors based on global alphabetical position to match bar chart
|
||||||
|
const computedColors = sortedData.map(item => {
|
||||||
|
const idx = allKelompok.indexOf(item.nama_kelompok);
|
||||||
|
return idx >= 0 ? colorPalette[idx % colorPalette.length] : '#999999';
|
||||||
|
});
|
||||||
|
|
||||||
|
setChartColors(computedColors);
|
||||||
setData(sortedData);
|
setData(sortedData);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
@@ -98,9 +114,7 @@ export default function KelompokKeahlianPieChartPerAngkatan({ selectedYear }: Pr
|
|||||||
colors: theme === 'dark' ? '#fff' : '#000'
|
colors: theme === 'dark' ? '#fff' : '#000'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors: [
|
colors: chartColors,
|
||||||
'#008FFB', '#00E396', '#FEB019', '#FF4560', '#775DD0', '#8B5CF6', '#EC4899', '#06B6D4', '#F97316'
|
|
||||||
],
|
|
||||||
tooltip: {
|
tooltip: {
|
||||||
theme: theme === 'dark' ? 'dark' : 'light',
|
theme: theme === 'dark' ? 'dark' : 'light',
|
||||||
y: {
|
y: {
|
||||||
|
|||||||
@@ -199,12 +199,12 @@ export default function TabelKategoriIPKMahasiswa({ selectedYear }: TabelKategor
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
{/* Filter Kategori IPK */}
|
{/* Filter Kategori IPK */}
|
||||||
<div className="flex items-center gap-2 mt-4">
|
<div className="flex items-center gap-2 mt-4">
|
||||||
<span className="text-sm dark:text-white">Filter Kategori IPK:</span>
|
<span className="text-sm text-gray-900 dark:text-white">Filter Kategori IPK:</span>
|
||||||
<Select value={selectedKategori} onValueChange={handleKategoriChange}>
|
<Select value={selectedKategori} onValueChange={handleKategoriChange}>
|
||||||
<SelectTrigger className="w-[220px]">
|
<SelectTrigger className="w-[220px] dark:bg-slate-800 dark:text-white dark:border-slate-700">
|
||||||
<SelectValue placeholder="Pilih Kategori IPK" />
|
<SelectValue placeholder="Pilih Kategori IPK" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent className="dark:bg-slate-800 dark:text-white dark:border-slate-700">
|
||||||
<SelectItem value="all">Semua Kategori</SelectItem>
|
<SelectItem value="all">Semua Kategori</SelectItem>
|
||||||
<SelectItem value="Sangat Baik">4.00 - 3.00 Sangat Baik</SelectItem>
|
<SelectItem value="Sangat Baik">4.00 - 3.00 Sangat Baik</SelectItem>
|
||||||
<SelectItem value="Baik">2.99 - 2.50 Baik</SelectItem>
|
<SelectItem value="Baik">2.99 - 2.50 Baik</SelectItem>
|
||||||
@@ -254,15 +254,15 @@ export default function TabelKategoriIPKMahasiswa({ selectedYear }: TabelKategor
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
{/* Show entries selector */}
|
{/* Show entries selector */}
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<span className="text-sm">Show</span>
|
<span className="text-sm text-gray-900 dark:text-white">Show</span>
|
||||||
<Select
|
<Select
|
||||||
value={pageSize.toString()}
|
value={pageSize.toString()}
|
||||||
onValueChange={handlePageSizeChange}
|
onValueChange={handlePageSizeChange}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[80px]">
|
<SelectTrigger className="w-[80px] dark:bg-slate-800 dark:text-white dark:border-slate-700">
|
||||||
<SelectValue placeholder={pageSize.toString()} />
|
<SelectValue placeholder={pageSize.toString()} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent className="dark:bg-slate-800 dark:text-white dark:border-slate-700">
|
||||||
<SelectItem value="5">5</SelectItem>
|
<SelectItem value="5">5</SelectItem>
|
||||||
<SelectItem value="10">10</SelectItem>
|
<SelectItem value="10">10</SelectItem>
|
||||||
<SelectItem value="25">25</SelectItem>
|
<SelectItem value="25">25</SelectItem>
|
||||||
@@ -270,7 +270,7 @@ export default function TabelKategoriIPKMahasiswa({ selectedYear }: TabelKategor
|
|||||||
<SelectItem value="100">100</SelectItem>
|
<SelectItem value="100">100</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<span className="text-sm">entries</span>
|
<span className="text-sm text-gray-900 dark:text-white">entries</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border rounded-md overflow-hidden">
|
<div className="border rounded-md overflow-hidden">
|
||||||
@@ -340,7 +340,7 @@ export default function TabelKategoriIPKMahasiswa({ selectedYear }: TabelKategor
|
|||||||
{/* Pagination info and controls */}
|
{/* Pagination info and controls */}
|
||||||
{!loading && !error && filteredData.length > 0 && (
|
{!loading && !error && filteredData.length > 0 && (
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-center gap-4 mt-4">
|
<div className="flex flex-col sm:flex-row justify-between items-center gap-4 mt-4">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
Showing {getDisplayRange().start} to {getDisplayRange().end} of {filteredData.length} entries
|
Showing {getDisplayRange().start} to {getDisplayRange().end} of {filteredData.length} entries
|
||||||
</div>
|
</div>
|
||||||
<Pagination>
|
<Pagination>
|
||||||
@@ -348,7 +348,7 @@ export default function TabelKategoriIPKMahasiswa({ selectedYear }: TabelKategor
|
|||||||
<PaginationItem>
|
<PaginationItem>
|
||||||
<PaginationPrevious
|
<PaginationPrevious
|
||||||
onClick={() => handlePageChange(Math.max(1, currentPage - 1))}
|
onClick={() => handlePageChange(Math.max(1, currentPage - 1))}
|
||||||
className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
className={`${currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"} dark:text-white dark:hover:bg-slate-800`}
|
||||||
/>
|
/>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
|
|
||||||
@@ -357,7 +357,7 @@ export default function TabelKategoriIPKMahasiswa({ selectedYear }: TabelKategor
|
|||||||
<PaginationItem>
|
<PaginationItem>
|
||||||
<PaginationNext
|
<PaginationNext
|
||||||
onClick={() => handlePageChange(Math.min(getTotalPages(), currentPage + 1))}
|
onClick={() => handlePageChange(Math.min(getTotalPages(), currentPage + 1))}
|
||||||
className={currentPage === getTotalPages() ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
className={`${currentPage === getTotalPages() ? "pointer-events-none opacity-50" : "cursor-pointer"} dark:text-white dark:hover:bg-slate-800`}
|
||||||
/>
|
/>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
</PaginationContent>
|
</PaginationContent>
|
||||||
@@ -389,7 +389,7 @@ export default function TabelKategoriIPKMahasiswa({ selectedYear }: TabelKategor
|
|||||||
<PaginationLink
|
<PaginationLink
|
||||||
isActive={currentPage === 1}
|
isActive={currentPage === 1}
|
||||||
onClick={() => handlePageChange(1)}
|
onClick={() => handlePageChange(1)}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer dark:text-white dark:hover:bg-slate-800"
|
||||||
>
|
>
|
||||||
1
|
1
|
||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
@@ -413,7 +413,7 @@ export default function TabelKategoriIPKMahasiswa({ selectedYear }: TabelKategor
|
|||||||
<PaginationLink
|
<PaginationLink
|
||||||
isActive={currentPage === i}
|
isActive={currentPage === i}
|
||||||
onClick={() => handlePageChange(i)}
|
onClick={() => handlePageChange(i)}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer dark:text-white dark:hover:bg-slate-800"
|
||||||
>
|
>
|
||||||
{i}
|
{i}
|
||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
@@ -437,7 +437,7 @@ export default function TabelKategoriIPKMahasiswa({ selectedYear }: TabelKategor
|
|||||||
<PaginationLink
|
<PaginationLink
|
||||||
isActive={currentPage === totalPages}
|
isActive={currentPage === totalPages}
|
||||||
onClick={() => handlePageChange(totalPages)}
|
onClick={() => handlePageChange(totalPages)}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer dark:text-white dark:hover:bg-slate-800"
|
||||||
>
|
>
|
||||||
{totalPages}
|
{totalPages}
|
||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogClose
|
DialogClose
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
@@ -34,12 +34,12 @@ export default function UploadFileNilaiMahasiswa({ onUploadSuccess }: UploadFile
|
|||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
'text/csv'
|
'text/csv'
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!validTypes.includes(file.type)) {
|
if (!validTypes.includes(file.type)) {
|
||||||
showError("Error!", "File harus berformat Excel (.xlsx, .xls) atau CSV (.csv)");
|
showError("Error!", "File harus berformat Excel (.xlsx, .xls) atau CSV (.csv)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedFile(file);
|
setSelectedFile(file);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -53,17 +53,17 @@ export default function UploadFileNilaiMahasiswa({ onUploadSuccess }: UploadFile
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', selectedFile);
|
formData.append('file', selectedFile);
|
||||||
|
|
||||||
const response = await fetch('/api/keloladata/data-nilai-mahasiswa/upload', {
|
const response = await fetch('/api/keloladata/data-nilai-mahasiswa/upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let errorMessage = result.message || 'Upload failed';
|
let errorMessage = result.message || 'Upload failed';
|
||||||
if (result.errors && result.errors.length > 0) {
|
if (result.errors && result.errors.length > 0) {
|
||||||
@@ -71,12 +71,26 @@ export default function UploadFileNilaiMahasiswa({ onUploadSuccess }: UploadFile
|
|||||||
}
|
}
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
showSuccess("Berhasil!", `${result.successCount} nilai mahasiswa berhasil diimport`);
|
// Create success message based on insert and update counts
|
||||||
|
const inserted = result.inserted || 0;
|
||||||
|
const updated = result.updated || 0;
|
||||||
|
const total = inserted + updated;
|
||||||
|
|
||||||
|
let successMessage = `${total} nilai mahasiswa berhasil diproses`;
|
||||||
|
if (inserted > 0 && updated > 0) {
|
||||||
|
successMessage += ` (${inserted} ditambahkan, ${updated} diperbarui)`;
|
||||||
|
} else if (inserted > 0) {
|
||||||
|
successMessage += ` (${inserted} ditambahkan)`;
|
||||||
|
} else if (updated > 0) {
|
||||||
|
successMessage += ` (${updated} diperbarui)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
showSuccess("Berhasil!", successMessage);
|
||||||
onUploadSuccess();
|
onUploadSuccess();
|
||||||
setIsDialogOpen(false);
|
setIsDialogOpen(false);
|
||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
|
|
||||||
// Reset file input
|
// Reset file input
|
||||||
const fileInput = document.getElementById('file-upload-nilai') as HTMLInputElement;
|
const fileInput = document.getElementById('file-upload-nilai') as HTMLInputElement;
|
||||||
if (fileInput) {
|
if (fileInput) {
|
||||||
@@ -105,7 +119,7 @@ export default function UploadFileNilaiMahasiswa({ onUploadSuccess }: UploadFile
|
|||||||
Import Data Nilai Mahasiswa
|
Import Data Nilai Mahasiswa
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="file-upload-nilai" className="text-sm font-medium">
|
<label htmlFor="file-upload-nilai" className="text-sm font-medium">
|
||||||
@@ -122,7 +136,7 @@ export default function UploadFileNilaiMahasiswa({ onUploadSuccess }: UploadFile
|
|||||||
Format yang didukung: .xlsx, .xls, .csv (Max: 10MB)
|
Format yang didukung: .xlsx, .xls, .csv (Max: 10MB)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedFile && (
|
{selectedFile && (
|
||||||
<div className="p-3 bg-muted rounded-md">
|
<div className="p-3 bg-muted rounded-md">
|
||||||
<p className="text-sm font-medium">File terpilih:</p>
|
<p className="text-sm font-medium">File terpilih:</p>
|
||||||
@@ -132,7 +146,7 @@ export default function UploadFileNilaiMahasiswa({ onUploadSuccess }: UploadFile
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="border-t pt-4">
|
<div className="border-t pt-4">
|
||||||
<h4 className="text-sm font-medium mb-2">Format File:</h4>
|
<h4 className="text-sm font-medium mb-2">Format File:</h4>
|
||||||
<div className="text-xs text-muted-foreground space-y-1">
|
<div className="text-xs text-muted-foreground space-y-1">
|
||||||
@@ -145,15 +159,15 @@ export default function UploadFileNilaiMahasiswa({ onUploadSuccess }: UploadFile
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
<Button variant="outline">
|
<Button variant="outline">
|
||||||
Batal
|
Batal
|
||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleUpload}
|
onClick={handleUpload}
|
||||||
disabled={!selectedFile || isUploading}
|
disabled={!selectedFile || isUploading}
|
||||||
>
|
>
|
||||||
{isUploading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{isUploading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
|||||||
@@ -61,9 +61,13 @@ const Navbar = () => {
|
|||||||
const checkUserSession = async () => {
|
const checkUserSession = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/auth/user');
|
const response = await fetch('/api/auth/user');
|
||||||
|
console.log('Session check response:', response.status);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
console.log('User data:', data);
|
||||||
setUser(data.user);
|
setUser(data.user);
|
||||||
|
} else {
|
||||||
|
console.log('No session found or session check failed');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error checking session:', error);
|
console.error('Error checking session:', error);
|
||||||
@@ -83,8 +87,10 @@ const Navbar = () => {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
showSuccess("Berhasil!", "Anda telah keluar dari sistem");
|
showSuccess("Berhasil!", "Anda telah keluar dari sistem");
|
||||||
// Redirect to root page after successful logout
|
// Redirect to root page after successful logout - using window.location for hard redirect
|
||||||
router.push('/');
|
setTimeout(() => {
|
||||||
|
window.location.href = '/';
|
||||||
|
}, 500); // Small delay to show toast notification
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Logout error:', error);
|
console.error('Logout error:', error);
|
||||||
|
|||||||
Reference in New Issue
Block a user