This commit is contained in:
Randa Firman Putra
2025-11-03 17:47:24 +07:00
parent db3db43434
commit 133ec36510
26 changed files with 3350 additions and 57 deletions

View File

@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from 'next/server';
import supabase from '@/lib/db';
// GET - Ambil data mahasiswa dengan IPK dan filter tahun angkatan untuk tabel detail
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const tahunAngkatan = searchParams.get('tahun_angkatan');
let query = supabase
.from('mahasiswa')
.select('nim, nama, ipk, tahun_angkatan')
.not('ipk', 'is', null) // Hanya ambil mahasiswa yang memiliki IPK
.order('ipk', { ascending: false }); // Urutkan berdasarkan IPK tertinggi
// Jika ada filter tahun angkatan, terapkan filter
if (tahunAngkatan && tahunAngkatan !== 'all') {
query = query.eq('tahun_angkatan', parseInt(tahunAngkatan));
}
const { data, error } = await query;
if (error) {
console.error('Error fetching data:', error);
return NextResponse.json(
{ message: 'Failed to fetch data', error: error.message },
{ status: 500 }
);
}
return NextResponse.json(data || [], {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
},
});
} catch (error) {
console.error('Error in tabeldetail/ipk API:', error);
return NextResponse.json(
{ message: 'Internal Server Error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from 'next/server';
import supabase from '@/lib/db';
// GET - Ambil data mahasiswa dengan jenis pendaftaran dan filter tahun angkatan untuk tabel detail
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const tahunAngkatan = searchParams.get('tahun_angkatan');
let query = supabase
.from('mahasiswa')
.select('nim, nama, jenis_pendaftaran, tahun_angkatan')
.not('jenis_pendaftaran', 'is', null) // Hanya ambil mahasiswa yang memiliki jenis pendaftaran
.order('nama'); // Urutkan berdasarkan nama
// Jika ada filter tahun angkatan, terapkan filter
if (tahunAngkatan && tahunAngkatan !== 'all') {
query = query.eq('tahun_angkatan', parseInt(tahunAngkatan));
}
const { data, error } = await query;
if (error) {
console.error('Error fetching data:', error);
return NextResponse.json(
{ message: 'Failed to fetch data', error: error.message },
{ status: 500 }
);
}
return NextResponse.json(data || [], {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
},
});
} catch (error) {
console.error('Error in tabeldetail/jenis-pendaftaran API:', error);
return NextResponse.json(
{ message: 'Internal Server Error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from 'next/server';
import supabase from '@/lib/db';
interface MahasiswaKelompokKeahlian {
nim: string;
nama: string;
tahun_angkatan: number;
nama_kelompok_keahlian: string;
}
// GET - Ambil data mahasiswa dengan kelompok keahlian dan filter tahun angkatan untuk tabel detail
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const tahunAngkatan = searchParams.get('tahun_angkatan');
let query = supabase
.from('mahasiswa')
.select(`
nim,
nama,
tahun_angkatan,
kelompok_keahlian!id_kelompok_keahlian(nama_kelompok)
`)
.not('id_kelompok_keahlian', 'is', null) // Hanya ambil mahasiswa yang sudah memiliki kelompok keahlian
.order('nama', { ascending: true }); // Urutkan berdasarkan nama mahasiswa
// Jika ada filter tahun angkatan, terapkan filter
if (tahunAngkatan && tahunAngkatan !== 'all') {
query = query.eq('tahun_angkatan', parseInt(tahunAngkatan));
}
const { data, error } = await query;
if (error) {
console.error('Error fetching data:', error);
return NextResponse.json(
{ message: 'Failed to fetch data', error: error.message },
{ status: 500 }
);
}
// Transform data untuk meratakan field yang di-join
const transformedData: MahasiswaKelompokKeahlian[] = (data || []).map((item: any) => ({
nim: item.nim,
nama: item.nama,
tahun_angkatan: item.tahun_angkatan,
nama_kelompok_keahlian: item.kelompok_keahlian?.nama_kelompok || ''
}));
return NextResponse.json(transformedData, {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
},
});
} catch (error) {
console.error('Error in tabeldetail/kelompok-keahlian API:', error);
return NextResponse.json(
{ message: 'Internal Server Error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from 'next/server';
import supabase from '@/lib/db';
interface MahasiswaKKLulusTepat {
nim: string;
nama: string;
tahun_angkatan: number;
nama_kelompok_keahlian: string;
semester: number;
}
// GET - Ambil data mahasiswa lulus tepat waktu dengan kelompok keahlian dan filter tahun angkatan untuk tabel detail
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const tahunAngkatan = searchParams.get('tahun_angkatan');
let query = supabase
.from('mahasiswa')
.select(`
nim,
nama,
tahun_angkatan,
semester,
kelompok_keahlian!inner(nama_kelompok)
`)
.eq('status_kuliah', 'Lulus') // Hanya mahasiswa yang sudah lulus
.lte('semester', 8) // Lulus tepat waktu (maksimal 8 semester)
.order('semester', { ascending: true }); // Urutkan berdasarkan semester tercepat
// Jika ada filter tahun angkatan, terapkan filter
if (tahunAngkatan && tahunAngkatan !== 'all') {
query = query.eq('tahun_angkatan', parseInt(tahunAngkatan));
}
const { data, error } = await query;
if (error) {
console.error('Error fetching data:', error);
return NextResponse.json(
{ message: 'Failed to fetch data', error: error.message },
{ status: 500 }
);
}
// Transform data untuk meratakan field yang di-join
const transformedData: MahasiswaKKLulusTepat[] = (data || []).map((item: any) => ({
nim: item.nim,
nama: item.nama,
tahun_angkatan: item.tahun_angkatan,
semester: item.semester,
nama_kelompok_keahlian: item.kelompok_keahlian?.nama_kelompok || ''
}));
return NextResponse.json(transformedData, {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
},
});
} catch (error) {
console.error('Error in tabeldetail/kk-lulus-tepat API:', error);
return NextResponse.json(
{ message: 'Internal Server Error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from 'next/server';
import supabase from '@/lib/db';
// GET - Ambil data mahasiswa lulus tepat waktu dengan filter tahun angkatan untuk tabel detail
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const tahunAngkatan = searchParams.get('tahun_angkatan');
let query = supabase
.from('mahasiswa')
.select('nim, nama, tahun_angkatan, ipk, semester')
.eq('status_kuliah', 'Lulus')
.lte('semester', 8) // Lulus tepat waktu maksimal 8 semester (4 tahun)
.not('ipk', 'is', null) // Hanya ambil mahasiswa yang memiliki IPK
.order('ipk', { ascending: false }); // Urutkan berdasarkan IPK tertinggi
// Jika ada filter tahun angkatan, terapkan filter
if (tahunAngkatan && tahunAngkatan !== 'all') {
query = query.eq('tahun_angkatan', parseInt(tahunAngkatan));
}
const { data, error } = await query;
if (error) {
console.error('Error fetching data:', error);
return NextResponse.json(
{ message: 'Failed to fetch data', error: error.message },
{ status: 500 }
);
}
return NextResponse.json(data || [], {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
},
});
} catch (error) {
console.error('Error in tabeldetail/lulus-tepat-waktu API:', error);
return NextResponse.json(
{ message: 'Internal Server Error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from 'next/server';
import supabase from '@/lib/db';
interface MahasiswaMasaStudi {
nim: string;
nama: string;
tahun_angkatan: number;
semester: number;
status_kuliah: string;
masa_studi_tahun: number;
}
// GET - Ambil data mahasiswa masa studi dengan filter tahun angkatan untuk tabel detail
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const tahunAngkatan = searchParams.get('tahun_angkatan');
let query = supabase
.from('mahasiswa')
.select('nim, nama, tahun_angkatan, semester, status_kuliah')
.eq('status_kuliah', 'Lulus') // Hanya ambil mahasiswa yang sudah lulus
.not('semester', 'is', null) // Hanya ambil mahasiswa yang memiliki data semester
.order('semester', { ascending: false }); // Urutkan berdasarkan semester tertinggi
// Jika ada filter tahun angkatan, terapkan filter
if (tahunAngkatan && tahunAngkatan !== 'all') {
query = query.eq('tahun_angkatan', parseInt(tahunAngkatan));
}
const { data, error } = await query;
if (error) {
console.error('Error fetching data:', error);
return NextResponse.json(
{ message: 'Failed to fetch data', error: error.message },
{ status: 500 }
);
}
// Transform data untuk menghitung masa studi dalam tahun
const transformedData: MahasiswaMasaStudi[] = (data || []).map((item: any) => ({
nim: item.nim,
nama: item.nama,
tahun_angkatan: item.tahun_angkatan,
semester: item.semester,
status_kuliah: item.status_kuliah,
// Hitung masa studi dalam tahun berdasarkan semester (semester / 2)
masa_studi_tahun: Math.round(((item.semester || 0) / 2) * 10) / 10
}));
// Urutkan berdasarkan masa studi terlama
transformedData.sort((a, b) => b.masa_studi_tahun - a.masa_studi_tahun);
return NextResponse.json(transformedData, {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
},
});
} catch (error) {
console.error('Error in tabeldetail/masa-studi API:', error);
return NextResponse.json(
{ message: 'Internal Server Error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server';
import supabase from '@/lib/db';
// GET - Ambil data mahasiswa dengan filter tahun angkatan untuk tabel detail
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const tahunAngkatan = searchParams.get('tahun_angkatan');
let query = supabase
.from('mahasiswa')
.select('nim, nama, status_kuliah, tahun_angkatan')
.order('nama');
// Jika ada filter tahun angkatan, terapkan filter
if (tahunAngkatan && tahunAngkatan !== 'all') {
query = query.eq('tahun_angkatan', parseInt(tahunAngkatan));
}
const { data, error } = await query;
if (error) {
console.error('Error fetching data:', error);
return NextResponse.json(
{ message: 'Failed to fetch data', error: error.message },
{ status: 500 }
);
}
return NextResponse.json(data || [], {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
},
});
} catch (error) {
console.error('Error in tabeldetail API:', error);
return NextResponse.json(
{ message: 'Internal Server Error' },
{ status: 500 }
);
}
}

View File

@@ -4,6 +4,7 @@ import { useState } from "react";
import JenisPendaftaranChart from "@/components/charts/JenisPendaftaranChart";
import JenisPendaftaranPerAngkatanChart from "@/components/charts/JenisPendaftaranPerAngkatanChart";
import FilterTahunAngkatan from "@/components/FilterTahunAngkatan";
import TabelJenisPendaftaranMahasiswa from "@/components/chartstable/tabeljenisPendaftaranmahasiswa";
export default function JenisPendaftaranDetailPage() {
const [selectedYear, setSelectedYear] = useState<string>("all");
@@ -36,6 +37,9 @@ export default function JenisPendaftaranDetailPage() {
)}
</div>
{/* Tabel Section */}
<TabelJenisPendaftaranMahasiswa selectedYear={selectedYear} />
{/* Information Section */}
<div className="bg-white dark:bg-slate-900 rounded-lg shadow-sm p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">

View File

@@ -1,11 +1,22 @@
'use client';
import { useState } from "react";
import KelompokKeahlianLulusTepatPieChart from "@/components/chartsDashboard/kkdashboardtepatpiechart";
import FilterTahunAngkatan from "@/components/FilterTahunAngkatan";
import TabelKKLulusTepatMahasiswa from "@/components/chartstable/tabelkklulustepatmahasiswa";
export default function KelompokKeahlianLulusTepatDetailPage() {
const [selectedYear, setSelectedYear] = useState<string>("all");
return (
<div className="min-h-screen bg-gray-50 dark:bg-[var(--background)] p-4">
<div className="container mx-auto max-w-7xl space-y-2">
{/* Filter Section */}
<FilterTahunAngkatan
selectedYear={selectedYear}
onYearChange={setSelectedYear}
/>
{/* Chart Section - Enhanced Size */}
<div className="grid grid-cols-1 lg:grid-cols-1 gap-6">
{/* Kelompok Keahlian Lulus Tepat Chart dengan ukuran lebih besar */}
@@ -17,6 +28,9 @@ export default function KelompokKeahlianLulusTepatDetailPage() {
</div>
</div>
{/* Tabel Section */}
<TabelKKLulusTepatMahasiswa selectedYear={selectedYear} />
{/* Information Section */}
<div className="bg-white dark:bg-slate-900 rounded-lg shadow-sm p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">

View File

@@ -4,6 +4,7 @@ import { useState } from "react";
import KelompokKeahlianStatusChart from "@/components/chartsDashboard/kkdashboardchart";
import KelompokKeahlianPieChartPerAngkatan from "@/components/chartsDashboard/kkdashboardpiechartperangkatan";
import FilterTahunAngkatan from "@/components/FilterTahunAngkatan";
import TabelKelompokKeahlianMahasiswa from "@/components/chartstable/tabelkelompokkeahliamahasiswa";
export default function KelompokKeahlianDetailPage() {
const [selectedYear, setSelectedYear] = useState<string>("all");
@@ -37,6 +38,9 @@ export default function KelompokKeahlianDetailPage() {
)}
</div>
{/* Tabel Section */}
<TabelKelompokKeahlianMahasiswa selectedYear={selectedYear} />
{/* Information Section */}
<div className="bg-white dark:bg-slate-900 rounded-lg shadow-sm p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">

View File

@@ -1,23 +1,37 @@
'use client';
import { useState } from "react";
import LulusTepatWaktuChart from "@/components/charts/LulusTepatWaktuChart";
import FilterTahunAngkatan from "@/components/FilterTahunAngkatan";
import TabelLulusTepatWaktuMahasiswa from "@/components/chartstable/tabellulustepatwaktumahasiswa";
export default function LulusTepatWaktuDetailPage() {
const [selectedYear, setSelectedYear] = useState<string>("all");
return (
<div className="min-h-screen bg-gray-50 dark:bg-[var(--background)] p-4">
<div className="container mx-auto max-w-7xl space-y-2">
{/* Filter Section */}
<FilterTahunAngkatan
selectedYear={selectedYear}
onYearChange={setSelectedYear}
/>
{/* Chart Section - Enhanced Size */}
<div className="grid grid-cols-1 lg:grid-cols-1 gap-6">
{/* Lulus Tepat Waktu Chart dengan ukuran lebih besar */}
<div className="lg:col-span-2">
<LulusTepatWaktuChart
selectedYear="all"
selectedYear={selectedYear}
height="h-[400px] sm:h-[400px] lg:h-[400px]"
showDetailButton={false}
/>
</div>
</div>
{/* Tabel Section */}
<TabelLulusTepatWaktuMahasiswa selectedYear={selectedYear} />
{/* Information Section */}
<div className="bg-white dark:bg-slate-900 rounded-lg shadow-sm p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">

View File

@@ -1,23 +1,37 @@
'use client';
import { useState } from "react";
import MasaStudiLulusChart from "@/components/chartsDashboard/masastudiluluschart";
import FilterTahunAngkatan from "@/components/FilterTahunAngkatan";
import TabelMasaStudiMahasiswa from "@/components/chartstable/tabelmasastudimahasiswa";
export default function MasaStudiLulusDetailPage() {
const [selectedYear, setSelectedYear] = useState<string>("all");
return (
<div className="min-h-screen bg-gray-50 dark:bg-[var(--background)] p-4">
<div className="container mx-auto max-w-7xl space-y-2">
{/* Filter Section */}
<FilterTahunAngkatan
selectedYear={selectedYear}
onYearChange={setSelectedYear}
/>
{/* Chart Section - Enhanced Size */}
<div className="grid grid-cols-1 lg:grid-cols-1 gap-6">
{/* Masa Studi Lulus Chart dengan ukuran lebih besar */}
<div className="lg:col-span-2">
<MasaStudiLulusChart
selectedYear="all"
selectedYear={selectedYear}
height="h-[400px] sm:h-[400px] lg:h-[400px]"
showDetailButton={false}
/>
</div>
</div>
{/* Tabel Section */}
<TabelMasaStudiMahasiswa selectedYear={selectedYear} />
{/* Information Section */}
<div className="bg-white dark:bg-slate-900 rounded-lg shadow-sm p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">

View File

@@ -1,11 +1,22 @@
'use client';
import { useState } from "react";
import IPKChart from "@/components/charts/IPKChart";
import FilterTahunAngkatan from "@/components/FilterTahunAngkatan";
import TabelIPKMahasiswa from "@/components/chartstable/tabelipkmahasiswa";
export default function RataRataIPKDetailPage() {
const [selectedYear, setSelectedYear] = useState<string>("all");
return (
<div className="min-h-screen bg-gray-50 dark:bg-[var(--background)] p-4">
<div className="container mx-auto max-w-7xl space-y-2">
{/* Filter Section */}
<FilterTahunAngkatan
selectedYear={selectedYear}
onYearChange={setSelectedYear}
/>
{/* Chart Section - Enhanced Size */}
<div className="grid grid-cols-1 lg:grid-cols-1 gap-6">
{/* IPK Chart dengan ukuran lebih besar */}
@@ -17,6 +28,9 @@ export default function RataRataIPKDetailPage() {
</div>
</div>
{/* Tabel Section */}
<TabelIPKMahasiswa selectedYear={selectedYear} />
{/* Information Section */}
<div className="bg-white dark:bg-slate-900 rounded-lg shadow-sm p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">

View File

@@ -4,6 +4,7 @@ import { useState } from "react";
import StatusMahasiswaChart from "@/components/charts/StatusMahasiswaChart";
import StatusMahasiswaPieChartPerangkatan from "@/components/chartsDashboard/StatusMahasiswaPieChartPerangkatan";
import FilterTahunAngkatan from "@/components/FilterTahunAngkatan";
import TabelStatusMahasiswa from "@/components/chartstable/tabelstatusmahasiswa";
export default function StatusMahasiswaDetailPage() {
const [selectedYear, setSelectedYear] = useState<string>("all");
@@ -38,6 +39,9 @@ export default function StatusMahasiswaDetailPage() {
)}
</div>
{/* Tabel Section */}
<TabelStatusMahasiswa selectedYear={selectedYear} />
{/* Information Section */}
<div className="bg-white dark:bg-slate-900 rounded-lg shadow-sm p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">

View File

@@ -42,7 +42,7 @@ export default function ClientLayout({ children }: ClientLayoutProps) {
};
// Don't show navbar on the root page (login page)
const showNavbar = pathname !== '/' && user;
const showNavbar = pathname !== '/' && !isLoading;
return (
<ThemeProvider

View File

@@ -35,7 +35,7 @@ export default function JenisPendaftaranChart({
const [options, setOptions] = useState<ApexOptions>({
chart: {
type: 'bar',
stacked: false,
stacked: true,
toolbar: {
show: true,
},
@@ -43,14 +43,27 @@ export default function JenisPendaftaranChart({
},
plotOptions: {
bar: {
horizontal: false,
columnWidth: '55%',
horizontal: true,
barHeight: '70%',
},
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return val.toString();
formatter: function (val: number, opts: any) {
const seriesIndex = opts.seriesIndex;
const dataPointIndex = opts.dataPointIndex;
// Hitung total untuk tahun angkatan ini
const allSeries = opts.w.config.series;
let totalForYear = 0;
allSeries.forEach((series: any) => {
totalForYear += series.data[dataPointIndex] || 0;
});
if (totalForYear === 0 || val === 0) return '0%';
const percentage = ((val / totalForYear) * 100).toFixed(0);
return percentage + '%';
},
style: {
fontSize: '12px',
@@ -64,22 +77,6 @@ export default function JenisPendaftaranChart({
},
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'
}
},
},
yaxis: {
title: {
text: 'Jumlah Mahasiswa',
style: {
@@ -94,9 +91,25 @@ export default function JenisPendaftaranChart({
colors: theme === 'dark' ? '#fff' : '#000'
}
},
min:0,
min: 0,
tickAmount: 5
},
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,
},
@@ -116,10 +129,68 @@ export default function JenisPendaftaranChart({
colors: ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4', '#F97316'],
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val + " mahasiswa";
}
shared: true,
intersect: false,
custom: function({ series, seriesIndex, dataPointIndex, w }: any) {
const tahun = w.globals.labels[dataPointIndex];
const isDark = theme === 'dark';
// Hitung total untuk tahun ini
let total = 0;
series.forEach((seriesData: number[]) => {
total += seriesData[dataPointIndex] || 0;
});
const jenisPendaftaranNames = w.config.series.map((s: any) => s.name);
const jenisPendaftaranColors = w.config.colors;
let tooltipContent = `
<div style="
padding: 12px 16px;
background: ${isDark ? 'rgba(30, 41, 59, 0.95)' : 'rgba(255, 255, 255, 0.95)'};
backdrop-filter: blur(10px);
border: none;
border-radius: 12px;
box-shadow: 0 8px 32px ${isDark ? 'rgba(0, 0, 0, 0.3)' : 'rgba(0, 0, 0, 0.1)'};
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
min-width: 180px;
">
<div style="
font-size: 13px;
font-weight: 600;
color: ${isDark ? '#f1f5f9' : '#1f2937'};
margin-bottom: 8px;
text-align: center;
">Angkatan ${tahun}</div>`;
// Tambahkan setiap jenis pendaftaran
series.forEach((seriesData: number[], index: number) => {
const value = seriesData[dataPointIndex] || 0;
tooltipContent += `
<div style="display: flex; align-items: center; margin-bottom: 4px;">
<div style="width: 8px; height: 8px; background: ${jenisPendaftaranColors[index]}; border-radius: 50%; margin-right: 8px;"></div>
<span style="font-size: 12px; color: ${isDark ? '#cbd5e1' : '#374151'};">${jenisPendaftaranNames[index]}</span>
<span style="font-size: 12px; font-weight: 600; color: ${isDark ? '#f1f5f9' : '#1f2937'}; margin-left: auto;">${value}</span>
</div>`;
});
// Tambahkan total
tooltipContent += `
<div style="
border-top: 1px solid ${isDark ? '#475569' : '#e5e7eb'};
margin-top: 8px;
padding-top: 8px;
display: flex;
align-items: center;
">
<div style="width: 8px; height: 8px; background: #6366f1; 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: 13px; font-weight: 700; color: #6366f1; margin-left: auto;">${total}</span>
</div>
</div>
`;
return tooltipContent;
}
}
});
@@ -135,6 +206,22 @@ export default function JenisPendaftaranChart({
},
dataLabels: {
...prev.dataLabels,
formatter: function (val: number, opts: any) {
const seriesIndex = opts.seriesIndex;
const dataPointIndex = opts.dataPointIndex;
// Hitung total untuk tahun angkatan ini
const allSeries = opts.w.config.series;
let totalForYear = 0;
allSeries.forEach((series: any) => {
totalForYear += series.data[dataPointIndex] || 0;
});
if (totalForYear === 0 || val === 0) return '0%';
const percentage = ((val / totalForYear) * 100).toFixed(0);
return percentage + '%';
},
style: {
...prev.dataLabels?.style,
colors: [currentTheme === 'dark' ? '#fff' : '#000']
@@ -183,7 +270,68 @@ export default function JenisPendaftaranChart({
},
tooltip: {
...prev.tooltip,
theme: currentTheme === 'dark' ? 'dark' : 'light'
theme: currentTheme === 'dark' ? 'dark' : 'light',
custom: function({ series, seriesIndex, dataPointIndex, w }: any) {
const tahun = w.globals.labels[dataPointIndex];
const isDark = currentTheme === 'dark';
// Hitung total untuk tahun ini
let total = 0;
series.forEach((seriesData: number[]) => {
total += seriesData[dataPointIndex] || 0;
});
const jenisPendaftaranNames = w.config.series.map((s: any) => s.name);
const jenisPendaftaranColors = w.config.colors;
let tooltipContent = `
<div style="
padding: 12px 16px;
background: ${isDark ? 'rgba(30, 41, 59, 0.95)' : 'rgba(255, 255, 255, 0.95)'};
backdrop-filter: blur(10px);
border: none;
border-radius: 12px;
box-shadow: 0 8px 32px ${isDark ? 'rgba(0, 0, 0, 0.3)' : 'rgba(0, 0, 0, 0.1)'};
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
min-width: 180px;
">
<div style="
font-size: 13px;
font-weight: 600;
color: ${isDark ? '#f1f5f9' : '#1f2937'};
margin-bottom: 8px;
text-align: center;
">Angkatan ${tahun}</div>`;
// Tambahkan setiap jenis pendaftaran
series.forEach((seriesData: number[], index: number) => {
const value = seriesData[dataPointIndex] || 0;
tooltipContent += `
<div style="display: flex; align-items: center; margin-bottom: 4px;">
<div style="width: 8px; height: 8px; background: ${jenisPendaftaranColors[index]}; border-radius: 50%; margin-right: 8px;"></div>
<span style="font-size: 12px; color: ${isDark ? '#cbd5e1' : '#374151'};">${jenisPendaftaranNames[index]}</span>
<span style="font-size: 12px; font-weight: 600; color: ${isDark ? '#f1f5f9' : '#1f2937'}; margin-left: auto;">${value}</span>
</div>`;
});
// Tambahkan total
tooltipContent += `
<div style="
border-top: 1px solid ${isDark ? '#475569' : '#e5e7eb'};
margin-top: 8px;
padding-top: 8px;
display: flex;
align-items: center;
">
<div style="width: 8px; height: 8px; background: #6366f1; 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: 13px; font-weight: 700; color: #6366f1; margin-left: auto;">${total}</span>
</div>
</div>
`;
return tooltipContent;
}
}
}));
}, [theme]);

View File

@@ -36,7 +36,7 @@ export default function StatusMahasiswaChart({
const chartOptions: ApexOptions = {
chart: {
type: 'bar',
stacked: false,
stacked: true,
toolbar: {
show: true,
tools: {
@@ -53,14 +53,27 @@ export default function StatusMahasiswaChart({
},
plotOptions: {
bar: {
horizontal: false,
columnWidth: '65%',
horizontal: true,
barHeight: '70%',
},
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return val.toString();
formatter: function (val: number, opts: any) {
const seriesIndex = opts.seriesIndex;
const dataPointIndex = opts.dataPointIndex;
// Hitung total untuk tahun angkatan ini
const allSeries = opts.w.config.series;
let totalForYear = 0;
allSeries.forEach((series: any) => {
totalForYear += series.data[dataPointIndex] || 0;
});
if (totalForYear === 0 || val === 0) return '0%';
const percentage = ((val / totalForYear) * 100).toFixed(0);
return percentage + '%';
},
style: {
fontSize: '12px',
@@ -74,22 +87,6 @@ export default function StatusMahasiswaChart({
},
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'
}
}
},
yaxis: {
title: {
text: 'Jumlah Mahasiswa',
style: {
@@ -104,9 +101,25 @@ export default function StatusMahasiswaChart({
colors: theme === 'dark' ? '#fff' : '#000'
}
},
min:0,
min: 0,
tickAmount: 5
},
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,
},
@@ -126,10 +139,68 @@ export default function StatusMahasiswaChart({
colors: ['#008FFB', '#00E396', '#FEB019', '#EF4444'],
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val + " mahasiswa";
}
shared: true,
intersect: false,
custom: function({ series, seriesIndex, dataPointIndex, w }: any) {
const tahun = w.globals.labels[dataPointIndex];
const isDark = theme === 'dark';
// Hitung total untuk tahun ini
let total = 0;
series.forEach((seriesData: number[]) => {
total += seriesData[dataPointIndex] || 0;
});
const statusNames = ['Aktif', 'Lulus', 'Cuti', 'Non Aktif'];
const statusColors = ['#008FFB', '#00E396', '#FEB019', '#EF4444'];
let tooltipContent = `
<div style="
padding: 12px 16px;
background: ${isDark ? 'rgba(30, 41, 59, 0.95)' : 'rgba(255, 255, 255, 0.95)'};
backdrop-filter: blur(10px);
border: none;
border-radius: 12px;
box-shadow: 0 8px 32px ${isDark ? 'rgba(0, 0, 0, 0.3)' : 'rgba(0, 0, 0, 0.1)'};
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
min-width: 180px;
">
<div style="
font-size: 13px;
font-weight: 600;
color: ${isDark ? '#f1f5f9' : '#1f2937'};
margin-bottom: 8px;
text-align: center;
">Angkatan ${tahun}</div>`;
// Tambahkan setiap status
series.forEach((seriesData: number[], index: number) => {
const value = seriesData[dataPointIndex] || 0;
tooltipContent += `
<div style="display: flex; align-items: center; margin-bottom: 4px;">
<div style="width: 8px; height: 8px; background: ${statusColors[index]}; border-radius: 50%; margin-right: 8px;"></div>
<span style="font-size: 12px; color: ${isDark ? '#cbd5e1' : '#374151'};">${statusNames[index]}</span>
<span style="font-size: 12px; font-weight: 600; color: ${isDark ? '#f1f5f9' : '#1f2937'}; margin-left: auto;">${value}</span>
</div>`;
});
// Tambahkan total
tooltipContent += `
<div style="
border-top: 1px solid ${isDark ? '#475569' : '#e5e7eb'};
margin-top: 8px;
padding-top: 8px;
display: flex;
align-items: center;
">
<div style="width: 8px; height: 8px; background: #6366f1; 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: 13px; font-weight: 700; color: #6366f1; margin-left: auto;">${total}</span>
</div>
</div>
`;
return tooltipContent;
}
}
};

View File

@@ -0,0 +1,366 @@
'use client';
import { useEffect, useState } from 'react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { Loader2, Trophy, Medal, Award } from "lucide-react";
interface MahasiswaIPK {
nim: string;
nama: string;
ipk: number;
tahun_angkatan: number;
}
interface TabelIPKMahasiswaProps {
selectedYear: string;
}
export default function TabelIPKMahasiswa({ selectedYear }: TabelIPKMahasiswaProps) {
const [mahasiswaData, setMahasiswaData] = useState<MahasiswaIPK[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// State for pagination
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [paginatedData, setPaginatedData] = useState<MahasiswaIPK[]>([]);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const url = selectedYear === 'all'
? '/api/tabeldetail/ipk'
: `/api/tabeldetail/ipk?tahun_angkatan=${selectedYear}`;
const response = await fetch(url, {
cache: 'no-store',
});
if (!response.ok) {
throw new Error('Failed to fetch mahasiswa IPK data');
}
const data = await response.json();
setMahasiswaData(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Terjadi kesalahan');
console.error('Error fetching data:', err);
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedYear]);
// Update paginated data when data or pagination settings change
useEffect(() => {
paginateData();
}, [mahasiswaData, currentPage, pageSize]);
// Paginate data
const paginateData = () => {
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
setPaginatedData(mahasiswaData.slice(startIndex, endIndex));
};
// Get total number of pages
const getTotalPages = () => {
return Math.ceil(mahasiswaData.length / pageSize);
};
// Handle page change
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
// Handle page size change
const handlePageSizeChange = (size: string) => {
setPageSize(Number(size));
setCurrentPage(1); // Reset to first page when changing page size
};
// Fungsi untuk mendapatkan icon berdasarkan ranking
const getRankingIcon = (index: number) => {
if (index === 0) return <Trophy className="h-4 w-4 text-yellow-500" />;
if (index === 1) return <Medal className="h-4 w-4 text-gray-400" />;
if (index === 2) return <Award className="h-4 w-4 text-amber-600" />;
return null;
};
// Hitung statistik IPK
const ipkStats = {
highest: mahasiswaData.length > 0 ? Math.max(...mahasiswaData.map(m => m.ipk)) : 0,
lowest: mahasiswaData.length > 0 ? Math.min(...mahasiswaData.map(m => m.ipk)) : 0,
average: mahasiswaData.length > 0 ?
mahasiswaData.reduce((sum, m) => sum + m.ipk, 0) / mahasiswaData.length : 0,
total: mahasiswaData.length
};
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
<div className="flex items-center gap-2">
<Loader2 className="h-5 w-5 animate-spin" />
Loading...
</div>
</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">
Tabel IPK Mahasiswa {selectedYear !== 'all' ? `Angkatan ${selectedYear}` : 'Semua Angkatan'}
</CardTitle>
{/* Tampilkan ringkasan statistik IPK */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mt-2">
<div className="px-3 py-2 rounded-lg bg-green-50 dark:bg-green-900/20">
<div className="text-xs text-green-600 dark:text-green-400">Tertinggi</div>
<div className="font-bold text-green-800 dark:text-green-300">{ipkStats.highest.toFixed(2)}</div>
</div>
<div className="px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20">
<div className="text-xs text-red-600 dark:text-red-400">Terendah</div>
<div className="font-bold text-red-800 dark:text-red-300">{ipkStats.lowest.toFixed(2)}</div>
</div>
<div className="px-3 py-2 rounded-lg bg-blue-50 dark:bg-blue-900/20">
<div className="text-xs text-blue-600 dark:text-blue-400">Rata-rata</div>
<div className="font-bold text-blue-800 dark:text-blue-300">{ipkStats.average.toFixed(2)}</div>
</div>
<div className="px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-800">
<div className="text-xs text-gray-600 dark:text-gray-400">Total</div>
<div className="font-bold text-gray-800 dark:text-gray-200">{ipkStats.total}</div>
</div>
</div>
</CardHeader>
<CardContent>
{/* Show entries selector */}
<div className="flex items-center gap-2 mb-4">
<span className="text-sm">Show</span>
<Select
value={pageSize.toString()}
onValueChange={handlePageSizeChange}
>
<SelectTrigger className="w-[80px]">
<SelectValue placeholder={pageSize.toString()} />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
<span className="text-sm">entries</span>
</div>
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50 dark:bg-slate-800">
<TableHead className="font-semibold text-center">Ranking</TableHead>
<TableHead className="font-semibold">NIM</TableHead>
<TableHead className="font-semibold">Nama Mahasiswa</TableHead>
<TableHead className="font-semibold text-center">Tahun Angkatan</TableHead>
<TableHead className="font-semibold text-center">IPK</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedData.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
Tidak ada data yang tersedia
</TableCell>
</TableRow>
) : (
paginatedData.map((mahasiswa, index) => {
const globalIndex = (currentPage - 1) * pageSize + index;
return (
<TableRow
key={mahasiswa.nim}
className={index % 2 === 0 ? "bg-white dark:bg-slate-900" : "bg-gray-50/50 dark:bg-slate-800/50"}
>
<TableCell className="text-center font-medium dark:text-white">
<div className="flex items-center justify-center gap-1">
{getRankingIcon(globalIndex)}
<span>{globalIndex + 1}</span>
</div>
</TableCell>
<TableCell className="font-medium dark:text-white">
{mahasiswa.nim}
</TableCell>
<TableCell className="dark:text-white">
{mahasiswa.nama}
</TableCell>
<TableCell className="text-center dark:text-white">
{mahasiswa.tahun_angkatan}
</TableCell>
<TableCell className="text-center font-medium dark:text-white">
{mahasiswa.ipk.toFixed(2)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
{/* Pagination info and controls */}
{!loading && !error && mahasiswaData.length > 0 && (
<div className="flex flex-col sm:flex-row justify-between items-center gap-4 mt-4">
<div className="text-sm text-muted-foreground">
Showing {getDisplayRange().start} to {getDisplayRange().end} of {mahasiswaData.length} entries
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => handlePageChange(Math.max(1, currentPage - 1))}
className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
{renderPaginationItems()}
<PaginationItem>
<PaginationNext
onClick={() => handlePageChange(Math.min(getTotalPages(), currentPage + 1))}
className={currentPage === getTotalPages() ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</CardContent>
</Card>
);
// Calculate the range of entries being displayed
function getDisplayRange() {
if (mahasiswaData.length === 0) return { start: 0, end: 0 };
const start = (currentPage - 1) * pageSize + 1;
const end = Math.min(currentPage * pageSize, mahasiswaData.length);
return { start, end };
}
// Generate pagination items
function renderPaginationItems() {
const totalPages = getTotalPages();
const items = [];
// Always show first page
items.push(
<PaginationItem key="first">
<PaginationLink
isActive={currentPage === 1}
onClick={() => handlePageChange(1)}
className="cursor-pointer"
>
1
</PaginationLink>
</PaginationItem>
);
// Show ellipsis if needed
if (currentPage > 3) {
items.push(
<PaginationItem key="ellipsis-start">
<PaginationEllipsis />
</PaginationItem>
);
}
// Show pages around current page
for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) {
if (i === 1 || i === totalPages) continue; // Skip first and last pages as they're always shown
items.push(
<PaginationItem key={i}>
<PaginationLink
isActive={currentPage === i}
onClick={() => handlePageChange(i)}
className="cursor-pointer"
>
{i}
</PaginationLink>
</PaginationItem>
);
}
// Show ellipsis if needed
if (currentPage < totalPages - 2) {
items.push(
<PaginationItem key="ellipsis-end">
<PaginationEllipsis />
</PaginationItem>
);
}
// Always show last page if there's more than one page
if (totalPages > 1) {
items.push(
<PaginationItem key="last">
<PaginationLink
isActive={currentPage === totalPages}
onClick={() => handlePageChange(totalPages)}
className="cursor-pointer"
>
{totalPages}
</PaginationLink>
</PaginationItem>
);
}
return items;
}
}

View File

@@ -0,0 +1,346 @@
'use client';
import { useEffect, useState } from 'react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { Loader2, GraduationCap } from "lucide-react";
interface MahasiswaJenisPendaftaran {
nim: string;
nama: string;
jenis_pendaftaran: string;
tahun_angkatan: number;
}
interface TabelJenisPendaftaranMahasiswaProps {
selectedYear: string;
}
export default function TabelJenisPendaftaranMahasiswa({ selectedYear }: TabelJenisPendaftaranMahasiswaProps) {
const [mahasiswaData, setMahasiswaData] = useState<MahasiswaJenisPendaftaran[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// State for pagination
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [paginatedData, setPaginatedData] = useState<MahasiswaJenisPendaftaran[]>([]);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const url = selectedYear === 'all'
? '/api/tabeldetail/jenis-pendaftaran'
: `/api/tabeldetail/jenis-pendaftaran?tahun_angkatan=${selectedYear}`;
const response = await fetch(url, {
cache: 'no-store',
});
if (!response.ok) {
throw new Error('Failed to fetch mahasiswa jenis pendaftaran data');
}
const data = await response.json();
setMahasiswaData(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Terjadi kesalahan');
console.error('Error fetching data:', err);
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedYear]);
// Update paginated data when data or pagination settings change
useEffect(() => {
paginateData();
}, [mahasiswaData, currentPage, pageSize]);
// Paginate data
const paginateData = () => {
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
setPaginatedData(mahasiswaData.slice(startIndex, endIndex));
};
// Get total number of pages
const getTotalPages = () => {
return Math.ceil(mahasiswaData.length / pageSize);
};
// Handle page change
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
// Handle page size change
const handlePageSizeChange = (size: string) => {
setPageSize(Number(size));
setCurrentPage(1); // Reset to first page when changing page size
};
// Hitung statistik berdasarkan jenis pendaftaran
const jenisPendaftaranStats = mahasiswaData.reduce((acc, mahasiswa) => {
const jenis = mahasiswa.jenis_pendaftaran || 'Tidak Diketahui';
acc[jenis] = (acc[jenis] || 0) + 1;
return acc;
}, {} as Record<string, number>);
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
<div className="flex items-center gap-2">
<Loader2 className="h-5 w-5 animate-spin" />
Loading...
</div>
</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 flex items-center gap-2">
<GraduationCap className="h-5 w-5" />
Tabel Jenis Pendaftaran Mahasiswa {selectedYear !== 'all' ? `Angkatan ${selectedYear}` : 'Semua Angkatan'}
</CardTitle>
{/* Tampilkan ringkasan statistik jenis pendaftaran */}
<div className="flex flex-wrap gap-2 mt-2">
{Object.entries(jenisPendaftaranStats).map(([jenis, count]) => (
<span
key={jenis}
className="px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
>
{jenis}: {count}
</span>
))}
<span className="px-2 py-1 rounded-full text-xs font-semibold bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200">
Total: {mahasiswaData.length}
</span>
</div>
</CardHeader>
<CardContent>
{/* Show entries selector */}
<div className="flex items-center gap-2 mb-4">
<span className="text-sm">Show</span>
<Select
value={pageSize.toString()}
onValueChange={handlePageSizeChange}
>
<SelectTrigger className="w-[80px]">
<SelectValue placeholder={pageSize.toString()} />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
<span className="text-sm">entries</span>
</div>
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50 dark:bg-slate-800">
<TableHead className="font-semibold text-center">No</TableHead>
<TableHead className="font-semibold">NIM</TableHead>
<TableHead className="font-semibold">Nama Mahasiswa</TableHead>
<TableHead className="font-semibold text-center">Tahun Angkatan</TableHead>
<TableHead className="font-semibold text-center">Jenis Pendaftaran</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedData.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
Tidak ada data yang tersedia
</TableCell>
</TableRow>
) : (
paginatedData.map((mahasiswa, index) => (
<TableRow
key={mahasiswa.nim}
className={index % 2 === 0 ? "bg-white dark:bg-slate-900" : "bg-gray-50/50 dark:bg-slate-800/50"}
>
<TableCell className="text-center font-medium dark:text-white">
{(currentPage - 1) * pageSize + index + 1}
</TableCell>
<TableCell className="font-medium dark:text-white">
{mahasiswa.nim}
</TableCell>
<TableCell className="dark:text-white">
{mahasiswa.nama}
</TableCell>
<TableCell className="text-center dark:text-white">
{mahasiswa.tahun_angkatan}
</TableCell>
<TableCell className="text-center font-medium dark:text-white">
{mahasiswa.jenis_pendaftaran || 'Tidak Diketahui'}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination info and controls */}
{!loading && !error && mahasiswaData.length > 0 && (
<div className="flex flex-col sm:flex-row justify-between items-center gap-4 mt-4">
<div className="text-sm text-muted-foreground">
Showing {getDisplayRange().start} to {getDisplayRange().end} of {mahasiswaData.length} entries
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => handlePageChange(Math.max(1, currentPage - 1))}
className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
{renderPaginationItems()}
<PaginationItem>
<PaginationNext
onClick={() => handlePageChange(Math.min(getTotalPages(), currentPage + 1))}
className={currentPage === getTotalPages() ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</CardContent>
</Card>
);
// Calculate the range of entries being displayed
function getDisplayRange() {
if (mahasiswaData.length === 0) return { start: 0, end: 0 };
const start = (currentPage - 1) * pageSize + 1;
const end = Math.min(currentPage * pageSize, mahasiswaData.length);
return { start, end };
}
// Generate pagination items
function renderPaginationItems() {
const totalPages = getTotalPages();
const items = [];
// Always show first page
items.push(
<PaginationItem key="first">
<PaginationLink
isActive={currentPage === 1}
onClick={() => handlePageChange(1)}
className="cursor-pointer"
>
1
</PaginationLink>
</PaginationItem>
);
// Show ellipsis if needed
if (currentPage > 3) {
items.push(
<PaginationItem key="ellipsis-start">
<PaginationEllipsis />
</PaginationItem>
);
}
// Show pages around current page
for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) {
if (i === 1 || i === totalPages) continue; // Skip first and last pages as they're always shown
items.push(
<PaginationItem key={i}>
<PaginationLink
isActive={currentPage === i}
onClick={() => handlePageChange(i)}
className="cursor-pointer"
>
{i}
</PaginationLink>
</PaginationItem>
);
}
// Show ellipsis if needed
if (currentPage < totalPages - 2) {
items.push(
<PaginationItem key="ellipsis-end">
<PaginationEllipsis />
</PaginationItem>
);
}
// Always show last page if there's more than one page
if (totalPages > 1) {
items.push(
<PaginationItem key="last">
<PaginationLink
isActive={currentPage === totalPages}
onClick={() => handlePageChange(totalPages)}
className="cursor-pointer"
>
{totalPages}
</PaginationLink>
</PaginationItem>
);
}
return items;
}
}

View File

@@ -0,0 +1,360 @@
'use client';
import { useEffect, useState } from 'react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { Loader2, Users } from "lucide-react";
interface MahasiswaKelompokKeahlian {
nim: string;
nama: string;
tahun_angkatan: number;
nama_kelompok_keahlian: string;
}
interface TabelKelompokKeahlianMahasiswaProps {
selectedYear: string;
}
export default function TabelKelompokKeahlianMahasiswa({ selectedYear }: TabelKelompokKeahlianMahasiswaProps) {
const [mahasiswaData, setMahasiswaData] = useState<MahasiswaKelompokKeahlian[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// State for pagination
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [paginatedData, setPaginatedData] = useState<MahasiswaKelompokKeahlian[]>([]);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const url = selectedYear === 'all'
? '/api/tabeldetail/kelompok-keahlian'
: `/api/tabeldetail/kelompok-keahlian?tahun_angkatan=${selectedYear}`;
const response = await fetch(url, {
cache: 'no-store',
});
if (!response.ok) {
throw new Error('Failed to fetch mahasiswa kelompok keahlian data');
}
const data = await response.json();
setMahasiswaData(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Terjadi kesalahan');
console.error('Error fetching data:', err);
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedYear]);
// Update paginated data when data or pagination settings change
useEffect(() => {
paginateData();
}, [mahasiswaData, currentPage, pageSize]);
// Paginate data
const paginateData = () => {
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
setPaginatedData(mahasiswaData.slice(startIndex, endIndex));
};
// Get total number of pages
const getTotalPages = () => {
return Math.ceil(mahasiswaData.length / pageSize);
};
// Handle page change
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
// Handle page size change
const handlePageSizeChange = (size: string) => {
setPageSize(Number(size));
setCurrentPage(1); // Reset to first page when changing page size
};
// Hitung statistik kelompok keahlian
const kelompokKeahlianStats = mahasiswaData.reduce((acc, mahasiswa) => {
const kelompok = mahasiswa.nama_kelompok_keahlian;
acc[kelompok] = (acc[kelompok] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const stats = {
total: mahasiswaData.length,
total_kelompok: Object.keys(kelompokKeahlianStats).length
};
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
<div className="flex items-center gap-2">
<Loader2 className="h-5 w-5 animate-spin" />
Loading...
</div>
</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 flex items-center gap-2">
<Users className="h-5 w-5" />
Tabel Kelompok Keahlian Mahasiswa {selectedYear !== 'all' ? `Angkatan ${selectedYear}` : 'Semua Angkatan'}
</CardTitle>
{/* Tampilkan ringkasan statistik */}
<div className="grid grid-cols-2 md:grid-cols-2 gap-2 mt-2">
<div className="px-3 py-2 rounded-lg bg-blue-50 dark:bg-blue-900/20">
<div className="text-xs text-blue-600 dark:text-blue-400">Total Mahasiswa</div>
<div className="font-bold text-blue-800 dark:text-blue-300">{stats.total}</div>
</div>
<div className="px-3 py-2 rounded-lg bg-purple-50 dark:bg-purple-900/20">
<div className="text-xs text-purple-600 dark:text-purple-400">Total Kelompok Keahlian</div>
<div className="font-bold text-purple-800 dark:text-purple-300">{stats.total_kelompok}</div>
</div>
</div>
{/* Tampilkan ringkasan kelompok keahlian */}
<div className="flex flex-wrap gap-2 mt-2">
{Object.entries(kelompokKeahlianStats)
.sort(([,a], [,b]) => b - a) // Urutkan berdasarkan jumlah mahasiswa terbanyak
.map(([kelompok, count]) => (
<span
key={kelompok}
className="px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
>
{kelompok}: {count}
</span>
))}
</div>
</CardHeader>
<CardContent>
{/* Show entries selector */}
<div className="flex items-center gap-2 mb-4">
<span className="text-sm">Show</span>
<Select
value={pageSize.toString()}
onValueChange={handlePageSizeChange}
>
<SelectTrigger className="w-[80px]">
<SelectValue placeholder={pageSize.toString()} />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
<span className="text-sm">entries</span>
</div>
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50 dark:bg-slate-800">
<TableHead className="font-semibold text-center">No</TableHead>
<TableHead className="font-semibold">NIM</TableHead>
<TableHead className="font-semibold">Nama Mahasiswa</TableHead>
<TableHead className="font-semibold text-center">Tahun Angkatan</TableHead>
<TableHead className="font-semibold text-center">Kelompok Keahlian</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedData.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
Tidak ada data yang tersedia
</TableCell>
</TableRow>
) : (
paginatedData.map((mahasiswa, index) => (
<TableRow
key={mahasiswa.nim}
className={index % 2 === 0 ? "bg-white dark:bg-slate-900" : "bg-gray-50/50 dark:bg-slate-800/50"}
>
<TableCell className="text-center font-medium dark:text-white">
{(currentPage - 1) * pageSize + index + 1}
</TableCell>
<TableCell className="font-medium dark:text-white">
{mahasiswa.nim}
</TableCell>
<TableCell className="dark:text-white">
{mahasiswa.nama}
</TableCell>
<TableCell className="text-center dark:text-white">
{mahasiswa.tahun_angkatan}
</TableCell>
<TableCell className="text-center font-medium dark:text-white">
{mahasiswa.nama_kelompok_keahlian}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination info and controls */}
{!loading && !error && mahasiswaData.length > 0 && (
<div className="flex flex-col sm:flex-row justify-between items-center gap-4 mt-4">
<div className="text-sm text-muted-foreground">
Showing {getDisplayRange().start} to {getDisplayRange().end} of {mahasiswaData.length} entries
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => handlePageChange(Math.max(1, currentPage - 1))}
className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
{renderPaginationItems()}
<PaginationItem>
<PaginationNext
onClick={() => handlePageChange(Math.min(getTotalPages(), currentPage + 1))}
className={currentPage === getTotalPages() ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</CardContent>
</Card>
);
// Calculate the range of entries being displayed
function getDisplayRange() {
if (mahasiswaData.length === 0) return { start: 0, end: 0 };
const start = (currentPage - 1) * pageSize + 1;
const end = Math.min(currentPage * pageSize, mahasiswaData.length);
return { start, end };
}
// Generate pagination items
function renderPaginationItems() {
const totalPages = getTotalPages();
const items = [];
// Always show first page
items.push(
<PaginationItem key="first">
<PaginationLink
isActive={currentPage === 1}
onClick={() => handlePageChange(1)}
className="cursor-pointer"
>
1
</PaginationLink>
</PaginationItem>
);
// Show ellipsis if needed
if (currentPage > 3) {
items.push(
<PaginationItem key="ellipsis-start">
<PaginationEllipsis />
</PaginationItem>
);
}
// Show pages around current page
for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) {
if (i === 1 || i === totalPages) continue; // Skip first and last pages as they're always shown
items.push(
<PaginationItem key={i}>
<PaginationLink
isActive={currentPage === i}
onClick={() => handlePageChange(i)}
className="cursor-pointer"
>
{i}
</PaginationLink>
</PaginationItem>
);
}
// Show ellipsis if needed
if (currentPage < totalPages - 2) {
items.push(
<PaginationItem key="ellipsis-end">
<PaginationEllipsis />
</PaginationItem>
);
}
// Always show last page if there's more than one page
if (totalPages > 1) {
items.push(
<PaginationItem key="last">
<PaginationLink
isActive={currentPage === totalPages}
onClick={() => handlePageChange(totalPages)}
className="cursor-pointer"
>
{totalPages}
</PaginationLink>
</PaginationItem>
);
}
return items;
}
}

View File

@@ -0,0 +1,386 @@
'use client';
import { useEffect, useState } from 'react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { Loader2, GraduationCap, Trophy, Medal, Award } from "lucide-react";
interface MahasiswaKKLulusTepat {
nim: string;
nama: string;
tahun_angkatan: number;
nama_kelompok_keahlian: string;
semester: number;
}
interface TabelKKLulusTepatMahasiswaProps {
selectedYear: string;
}
export default function TabelKKLulusTepatMahasiswa({ selectedYear }: TabelKKLulusTepatMahasiswaProps) {
const [mahasiswaData, setMahasiswaData] = useState<MahasiswaKKLulusTepat[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// State for pagination
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [paginatedData, setPaginatedData] = useState<MahasiswaKKLulusTepat[]>([]);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const url = selectedYear === 'all'
? '/api/tabeldetail/kk-lulus-tepat'
: `/api/tabeldetail/kk-lulus-tepat?tahun_angkatan=${selectedYear}`;
const response = await fetch(url, {
cache: 'no-store',
});
if (!response.ok) {
throw new Error('Failed to fetch mahasiswa kelompok keahlian lulus tepat data');
}
const data = await response.json();
setMahasiswaData(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Terjadi kesalahan');
console.error('Error fetching data:', err);
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedYear]);
// Update paginated data when data or pagination settings change
useEffect(() => {
paginateData();
}, [mahasiswaData, currentPage, pageSize]);
// Paginate data
const paginateData = () => {
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
setPaginatedData(mahasiswaData.slice(startIndex, endIndex));
};
// Get total number of pages
const getTotalPages = () => {
return Math.ceil(mahasiswaData.length / pageSize);
};
// Handle page change
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
// Handle page size change
const handlePageSizeChange = (size: string) => {
setPageSize(Number(size));
setCurrentPage(1); // Reset to first page when changing page size
};
// Fungsi untuk mendapatkan icon berdasarkan ranking semester
const getRankingIcon = (index: number) => {
if (index === 0) return <Trophy className="h-4 w-4 text-yellow-500" />;
if (index === 1) return <Medal className="h-4 w-4 text-gray-400" />;
if (index === 2) return <Award className="h-4 w-4 text-amber-600" />;
return null;
};
// Hitung statistik kelompok keahlian dan semester
const kelompokKeahlianStats = mahasiswaData.reduce((acc, mahasiswa) => {
const kelompok = mahasiswa.nama_kelompok_keahlian;
acc[kelompok] = (acc[kelompok] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const stats = {
total: mahasiswaData.length,
total_kelompok: Object.keys(kelompokKeahlianStats).length,
fastest_semester: mahasiswaData.length > 0 ? Math.min(...mahasiswaData.map(m => m.semester)) : 0
};
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
<div className="flex items-center gap-2">
<Loader2 className="h-5 w-5 animate-spin" />
Loading...
</div>
</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 flex items-center gap-2">
<GraduationCap className="h-5 w-5" />
Tabel Mahasiswa Lulus Tepat Waktu per Kelompok Keahlian {selectedYear !== 'all' ? `Angkatan ${selectedYear}` : 'Semua Angkatan'}
</CardTitle>
{/* Tampilkan ringkasan statistik */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mt-2">
<div className="px-3 py-2 rounded-lg bg-green-50 dark:bg-green-900/20">
<div className="text-xs text-green-600 dark:text-green-400">Total Lulus Tepat Waktu</div>
<div className="font-bold text-green-800 dark:text-green-300">{stats.total}</div>
</div>
<div className="px-3 py-2 rounded-lg bg-purple-50 dark:bg-purple-900/20">
<div className="text-xs text-purple-600 dark:text-purple-400">Total Kelompok Keahlian</div>
<div className="font-bold text-purple-800 dark:text-purple-300">{stats.total_kelompok}</div>
</div>
<div className="px-3 py-2 rounded-lg bg-blue-50 dark:bg-blue-900/20">
<div className="text-xs text-blue-600 dark:text-blue-400">Semester Tercepat</div>
<div className="font-bold text-blue-800 dark:text-blue-300">{stats.fastest_semester}</div>
</div>
</div>
{/* Tampilkan ringkasan kelompok keahlian */}
<div className="flex flex-wrap gap-2 mt-2">
{Object.entries(kelompokKeahlianStats)
.sort(([,a], [,b]) => b - a) // Urutkan berdasarkan jumlah mahasiswa terbanyak
.map(([kelompok, count]) => (
<span
key={kelompok}
className="px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
>
{kelompok}: {count}
</span>
))}
</div>
</CardHeader>
<CardContent>
{/* Show entries selector */}
<div className="flex items-center gap-2 mb-4">
<span className="text-sm">Show</span>
<Select
value={pageSize.toString()}
onValueChange={handlePageSizeChange}
>
<SelectTrigger className="w-[80px]">
<SelectValue placeholder={pageSize.toString()} />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
<span className="text-sm">entries</span>
</div>
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50 dark:bg-slate-800">
<TableHead className="font-semibold text-center">Ranking</TableHead>
<TableHead className="font-semibold">NIM</TableHead>
<TableHead className="font-semibold">Nama Mahasiswa</TableHead>
<TableHead className="font-semibold text-center">Tahun Angkatan</TableHead>
<TableHead className="font-semibold text-center">Kelompok Keahlian</TableHead>
<TableHead className="font-semibold text-center">Semester Lulus</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedData.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
Tidak ada data yang tersedia
</TableCell>
</TableRow>
) : (
paginatedData.map((mahasiswa, index) => {
const globalIndex = (currentPage - 1) * pageSize + index;
return (
<TableRow
key={mahasiswa.nim}
className={index % 2 === 0 ? "bg-white dark:bg-slate-900" : "bg-gray-50/50 dark:bg-slate-800/50"}
>
<TableCell className="text-center font-medium dark:text-white">
<div className="flex items-center justify-center gap-1">
{getRankingIcon(globalIndex)}
<span>{globalIndex + 1}</span>
</div>
</TableCell>
<TableCell className="font-medium dark:text-white">
{mahasiswa.nim}
</TableCell>
<TableCell className="dark:text-white">
{mahasiswa.nama}
</TableCell>
<TableCell className="text-center dark:text-white">
{mahasiswa.tahun_angkatan}
</TableCell>
<TableCell className="text-center font-medium dark:text-white">
{mahasiswa.nama_kelompok_keahlian}
</TableCell>
<TableCell className="text-center font-medium dark:text-white">
<span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300">
{mahasiswa.semester} semester
</span>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
{/* Pagination info and controls */}
{!loading && !error && mahasiswaData.length > 0 && (
<div className="flex flex-col sm:flex-row justify-between items-center gap-4 mt-4">
<div className="text-sm text-muted-foreground">
Showing {getDisplayRange().start} to {getDisplayRange().end} of {mahasiswaData.length} entries
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => handlePageChange(Math.max(1, currentPage - 1))}
className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
{renderPaginationItems()}
<PaginationItem>
<PaginationNext
onClick={() => handlePageChange(Math.min(getTotalPages(), currentPage + 1))}
className={currentPage === getTotalPages() ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</CardContent>
</Card>
);
// Calculate the range of entries being displayed
function getDisplayRange() {
if (mahasiswaData.length === 0) return { start: 0, end: 0 };
const start = (currentPage - 1) * pageSize + 1;
const end = Math.min(currentPage * pageSize, mahasiswaData.length);
return { start, end };
}
// Generate pagination items
function renderPaginationItems() {
const totalPages = getTotalPages();
const items = [];
// Always show first page
items.push(
<PaginationItem key="first">
<PaginationLink
isActive={currentPage === 1}
onClick={() => handlePageChange(1)}
className="cursor-pointer"
>
1
</PaginationLink>
</PaginationItem>
);
// Show ellipsis if needed
if (currentPage > 3) {
items.push(
<PaginationItem key="ellipsis-start">
<PaginationEllipsis />
</PaginationItem>
);
}
// Show pages around current page
for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) {
if (i === 1 || i === totalPages) continue; // Skip first and last pages as they're always shown
items.push(
<PaginationItem key={i}>
<PaginationLink
isActive={currentPage === i}
onClick={() => handlePageChange(i)}
className="cursor-pointer"
>
{i}
</PaginationLink>
</PaginationItem>
);
}
// Show ellipsis if needed
if (currentPage < totalPages - 2) {
items.push(
<PaginationItem key="ellipsis-end">
<PaginationEllipsis />
</PaginationItem>
);
}
// Always show last page if there's more than one page
if (totalPages > 1) {
items.push(
<PaginationItem key="last">
<PaginationLink
isActive={currentPage === totalPages}
onClick={() => handlePageChange(totalPages)}
className="cursor-pointer"
>
{totalPages}
</PaginationLink>
</PaginationItem>
);
}
return items;
}
}

View File

@@ -0,0 +1,371 @@
'use client';
import { useEffect, useState } from 'react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { Loader2, Clock, Trophy, Medal, Award } from "lucide-react";
interface MahasiswaLulusTepatWaktu {
nim: string;
nama: string;
tahun_angkatan: number;
ipk: number;
semester: number;
}
interface TabelLulusTepatWaktuMahasiswaProps {
selectedYear: string;
}
export default function TabelLulusTepatWaktuMahasiswa({ selectedYear }: TabelLulusTepatWaktuMahasiswaProps) {
const [mahasiswaData, setMahasiswaData] = useState<MahasiswaLulusTepatWaktu[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// State for pagination
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [paginatedData, setPaginatedData] = useState<MahasiswaLulusTepatWaktu[]>([]);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const url = selectedYear === 'all'
? '/api/tabeldetail/lulus-tepat-waktu'
: `/api/tabeldetail/lulus-tepat-waktu?tahun_angkatan=${selectedYear}`;
const response = await fetch(url, {
cache: 'no-store',
});
if (!response.ok) {
throw new Error('Failed to fetch mahasiswa lulus tepat waktu data');
}
const data = await response.json();
setMahasiswaData(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Terjadi kesalahan');
console.error('Error fetching data:', err);
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedYear]);
// Update paginated data when data or pagination settings change
useEffect(() => {
paginateData();
}, [mahasiswaData, currentPage, pageSize]);
// Paginate data
const paginateData = () => {
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
setPaginatedData(mahasiswaData.slice(startIndex, endIndex));
};
// Get total number of pages
const getTotalPages = () => {
return Math.ceil(mahasiswaData.length / pageSize);
};
// Handle page change
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
// Handle page size change
const handlePageSizeChange = (size: string) => {
setPageSize(Number(size));
setCurrentPage(1); // Reset to first page when changing page size
};
// Fungsi untuk mendapatkan icon berdasarkan ranking
const getRankingIcon = (index: number) => {
if (index === 0) return <Trophy className="h-4 w-4 text-yellow-500" />;
if (index === 1) return <Medal className="h-4 w-4 text-gray-400" />;
if (index === 2) return <Award className="h-4 w-4 text-amber-600" />;
return null;
};
// Hitung statistik IPK dan semester
const stats = {
highest_ipk: mahasiswaData.length > 0 ? Math.max(...mahasiswaData.map(m => m.ipk)) : 0,
lowest_ipk: mahasiswaData.length > 0 ? Math.min(...mahasiswaData.map(m => m.ipk)) : 0,
average_ipk: mahasiswaData.length > 0 ?
mahasiswaData.reduce((sum, m) => sum + m.ipk, 0) / mahasiswaData.length : 0,
total: mahasiswaData.length
};
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
<div className="flex items-center gap-2">
<Loader2 className="h-5 w-5 animate-spin" />
Loading...
</div>
</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 flex items-center gap-2">
<Clock className="h-5 w-5" />
Tabel Mahasiswa Lulus Tepat Waktu {selectedYear !== 'all' ? `Angkatan ${selectedYear}` : 'Semua Angkatan'}
</CardTitle>
{/* Tampilkan ringkasan statistik */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-2 mt-2">
<div className="px-3 py-2 rounded-lg bg-green-50 dark:bg-green-900/20">
<div className="text-xs text-green-600 dark:text-green-400">IPK Tertinggi</div>
<div className="font-bold text-green-800 dark:text-green-300">{stats.highest_ipk.toFixed(2)}</div>
</div>
<div className="px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20">
<div className="text-xs text-red-600 dark:text-red-400">IPK Terendah</div>
<div className="font-bold text-red-800 dark:text-red-300">{stats.lowest_ipk.toFixed(2)}</div>
</div>
<div className="px-3 py-2 rounded-lg bg-blue-50 dark:bg-blue-900/20">
<div className="text-xs text-blue-600 dark:text-blue-400">Rata-rata IPK</div>
<div className="font-bold text-blue-800 dark:text-blue-300">{stats.average_ipk.toFixed(2)}</div>
</div>
<div className="px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-800">
<div className="text-xs text-gray-600 dark:text-gray-400">Total</div>
<div className="font-bold text-gray-800 dark:text-gray-200">{stats.total}</div>
</div>
</div>
</CardHeader>
<CardContent>
{/* Show entries selector */}
<div className="flex items-center gap-2 mb-4">
<span className="text-sm">Show</span>
<Select
value={pageSize.toString()}
onValueChange={handlePageSizeChange}
>
<SelectTrigger className="w-[80px]">
<SelectValue placeholder={pageSize.toString()} />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
<span className="text-sm">entries</span>
</div>
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50 dark:bg-slate-800">
<TableHead className="font-semibold text-center">Ranking</TableHead>
<TableHead className="font-semibold">NIM</TableHead>
<TableHead className="font-semibold">Nama Mahasiswa</TableHead>
<TableHead className="font-semibold text-center">Tahun Angkatan</TableHead>
<TableHead className="font-semibold text-center">IPK</TableHead>
<TableHead className="font-semibold text-center">Semester Lulus</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedData.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
Tidak ada data yang tersedia
</TableCell>
</TableRow>
) : (
paginatedData.map((mahasiswa, index) => {
const globalIndex = (currentPage - 1) * pageSize + index;
return (
<TableRow
key={mahasiswa.nim}
className={index % 2 === 0 ? "bg-white dark:bg-slate-900" : "bg-gray-50/50 dark:bg-slate-800/50"}
>
<TableCell className="text-center font-medium dark:text-white">
<div className="flex items-center justify-center gap-1">
{getRankingIcon(globalIndex)}
<span>{globalIndex + 1}</span>
</div>
</TableCell>
<TableCell className="font-medium dark:text-white">
{mahasiswa.nim}
</TableCell>
<TableCell className="dark:text-white">
{mahasiswa.nama}
</TableCell>
<TableCell className="text-center dark:text-white">
{mahasiswa.tahun_angkatan}
</TableCell>
<TableCell className="text-center font-medium dark:text-white">
{mahasiswa.ipk.toFixed(2)}
</TableCell>
<TableCell className="text-center font-medium dark:text-white">
{mahasiswa.semester}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
{/* Pagination info and controls */}
{!loading && !error && mahasiswaData.length > 0 && (
<div className="flex flex-col sm:flex-row justify-between items-center gap-4 mt-4">
<div className="text-sm text-muted-foreground">
Showing {getDisplayRange().start} to {getDisplayRange().end} of {mahasiswaData.length} entries
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => handlePageChange(Math.max(1, currentPage - 1))}
className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
{renderPaginationItems()}
<PaginationItem>
<PaginationNext
onClick={() => handlePageChange(Math.min(getTotalPages(), currentPage + 1))}
className={currentPage === getTotalPages() ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</CardContent>
</Card>
);
// Calculate the range of entries being displayed
function getDisplayRange() {
if (mahasiswaData.length === 0) return { start: 0, end: 0 };
const start = (currentPage - 1) * pageSize + 1;
const end = Math.min(currentPage * pageSize, mahasiswaData.length);
return { start, end };
}
// Generate pagination items
function renderPaginationItems() {
const totalPages = getTotalPages();
const items = [];
// Always show first page
items.push(
<PaginationItem key="first">
<PaginationLink
isActive={currentPage === 1}
onClick={() => handlePageChange(1)}
className="cursor-pointer"
>
1
</PaginationLink>
</PaginationItem>
);
// Show ellipsis if needed
if (currentPage > 3) {
items.push(
<PaginationItem key="ellipsis-start">
<PaginationEllipsis />
</PaginationItem>
);
}
// Show pages around current page
for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) {
if (i === 1 || i === totalPages) continue; // Skip first and last pages as they're always shown
items.push(
<PaginationItem key={i}>
<PaginationLink
isActive={currentPage === i}
onClick={() => handlePageChange(i)}
className="cursor-pointer"
>
{i}
</PaginationLink>
</PaginationItem>
);
}
// Show ellipsis if needed
if (currentPage < totalPages - 2) {
items.push(
<PaginationItem key="ellipsis-end">
<PaginationEllipsis />
</PaginationItem>
);
}
// Always show last page if there's more than one page
if (totalPages > 1) {
items.push(
<PaginationItem key="last">
<PaginationLink
isActive={currentPage === totalPages}
onClick={() => handlePageChange(totalPages)}
className="cursor-pointer"
>
{totalPages}
</PaginationLink>
</PaginationItem>
);
}
return items;
}
}

View File

@@ -0,0 +1,355 @@
'use client';
import { useEffect, useState } from 'react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { Loader2, BookOpen } from "lucide-react";
interface MahasiswaMasaStudi {
nim: string;
nama: string;
tahun_angkatan: number;
semester: number;
status_kuliah: string;
masa_studi_tahun: number;
}
interface TabelMasaStudiMahasiswaProps {
selectedYear: string;
}
export default function TabelMasaStudiMahasiswa({ selectedYear }: TabelMasaStudiMahasiswaProps) {
const [mahasiswaData, setMahasiswaData] = useState<MahasiswaMasaStudi[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// State for pagination
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [paginatedData, setPaginatedData] = useState<MahasiswaMasaStudi[]>([]);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const url = selectedYear === 'all'
? '/api/tabeldetail/masa-studi'
: `/api/tabeldetail/masa-studi?tahun_angkatan=${selectedYear}`;
const response = await fetch(url, {
cache: 'no-store',
});
if (!response.ok) {
throw new Error('Failed to fetch mahasiswa masa studi data');
}
const data = await response.json();
setMahasiswaData(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Terjadi kesalahan');
console.error('Error fetching data:', err);
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedYear]);
// Update paginated data when data or pagination settings change
useEffect(() => {
paginateData();
}, [mahasiswaData, currentPage, pageSize]);
// Paginate data
const paginateData = () => {
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
setPaginatedData(mahasiswaData.slice(startIndex, endIndex));
};
// Get total number of pages
const getTotalPages = () => {
return Math.ceil(mahasiswaData.length / pageSize);
};
// Handle page change
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
// Handle page size change
const handlePageSizeChange = (size: string) => {
setPageSize(Number(size));
setCurrentPage(1); // Reset to first page when changing page size
};
// Hitung statistik masa studi
const stats = {
highest_masa_studi: mahasiswaData.length > 0 ? Math.max(...mahasiswaData.map(m => m.masa_studi_tahun)) : 0,
lowest_masa_studi: mahasiswaData.length > 0 ? Math.min(...mahasiswaData.map(m => m.masa_studi_tahun)) : 0,
average_masa_studi: mahasiswaData.length > 0 ?
mahasiswaData.reduce((sum, m) => sum + m.masa_studi_tahun, 0) / mahasiswaData.length : 0,
total: mahasiswaData.length
};
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
<div className="flex items-center gap-2">
<Loader2 className="h-5 w-5 animate-spin" />
Loading...
</div>
</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 flex items-center gap-2">
<BookOpen className="h-5 w-5" />
Tabel Masa Studi Mahasiswa Lulus {selectedYear !== 'all' ? `Angkatan ${selectedYear}` : 'Semua Angkatan'}
</CardTitle>
{/* Tampilkan ringkasan statistik */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mt-2">
<div className="px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20">
<div className="text-xs text-red-600 dark:text-red-400">Masa Studi Terlama</div>
<div className="font-bold text-red-800 dark:text-red-300">{stats.highest_masa_studi} tahun</div>
</div>
<div className="px-3 py-2 rounded-lg bg-green-50 dark:bg-green-900/20">
<div className="text-xs text-green-600 dark:text-green-400">Masa Studi Tercepat</div>
<div className="font-bold text-green-800 dark:text-green-300">{stats.lowest_masa_studi} tahun</div>
</div>
<div className="px-3 py-2 rounded-lg bg-blue-50 dark:bg-blue-900/20">
<div className="text-xs text-blue-600 dark:text-blue-400">Rata-rata Masa Studi</div>
<div className="font-bold text-blue-800 dark:text-blue-300">{stats.average_masa_studi.toFixed(1)} tahun</div>
</div>
<div className="px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-800">
<div className="text-xs text-gray-600 dark:text-gray-400">Total Mahasiswa Lulus</div>
<div className="font-bold text-gray-800 dark:text-gray-200">{stats.total}</div>
</div>
</div>
</CardHeader>
<CardContent>
{/* Show entries selector */}
<div className="flex items-center gap-2 mb-4">
<span className="text-sm">Show</span>
<Select
value={pageSize.toString()}
onValueChange={handlePageSizeChange}
>
<SelectTrigger className="w-[80px]">
<SelectValue placeholder={pageSize.toString()} />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
<span className="text-sm">entries</span>
</div>
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50 dark:bg-slate-800">
<TableHead className="font-semibold text-center">No</TableHead>
<TableHead className="font-semibold">NIM</TableHead>
<TableHead className="font-semibold">Nama Mahasiswa</TableHead>
<TableHead className="font-semibold text-center">Tahun Angkatan</TableHead>
<TableHead className="font-semibold text-center">Masa Studi (Tahun)</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedData.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
Tidak ada data yang tersedia
</TableCell>
</TableRow>
) : (
paginatedData.map((mahasiswa, index) => (
<TableRow
key={mahasiswa.nim}
className={index % 2 === 0 ? "bg-white dark:bg-slate-900" : "bg-gray-50/50 dark:bg-slate-800/50"}
>
<TableCell className="text-center font-medium dark:text-white">
{(currentPage - 1) * pageSize + index + 1}
</TableCell>
<TableCell className="font-medium dark:text-white">
{mahasiswa.nim}
</TableCell>
<TableCell className="dark:text-white">
{mahasiswa.nama}
</TableCell>
<TableCell className="text-center dark:text-white">
{mahasiswa.tahun_angkatan}
</TableCell>
<TableCell className="text-center font-medium dark:text-white">
{mahasiswa.masa_studi_tahun} tahun
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination info and controls */}
{!loading && !error && mahasiswaData.length > 0 && (
<div className="flex flex-col sm:flex-row justify-between items-center gap-4 mt-4">
<div className="text-sm text-muted-foreground">
Showing {getDisplayRange().start} to {getDisplayRange().end} of {mahasiswaData.length} entries
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => handlePageChange(Math.max(1, currentPage - 1))}
className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
{renderPaginationItems()}
<PaginationItem>
<PaginationNext
onClick={() => handlePageChange(Math.min(getTotalPages(), currentPage + 1))}
className={currentPage === getTotalPages() ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</CardContent>
</Card>
);
// Calculate the range of entries being displayed
function getDisplayRange() {
if (mahasiswaData.length === 0) return { start: 0, end: 0 };
const start = (currentPage - 1) * pageSize + 1;
const end = Math.min(currentPage * pageSize, mahasiswaData.length);
return { start, end };
}
// Generate pagination items
function renderPaginationItems() {
const totalPages = getTotalPages();
const items = [];
// Always show first page
items.push(
<PaginationItem key="first">
<PaginationLink
isActive={currentPage === 1}
onClick={() => handlePageChange(1)}
className="cursor-pointer"
>
1
</PaginationLink>
</PaginationItem>
);
// Show ellipsis if needed
if (currentPage > 3) {
items.push(
<PaginationItem key="ellipsis-start">
<PaginationEllipsis />
</PaginationItem>
);
}
// Show pages around current page
for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) {
if (i === 1 || i === totalPages) continue; // Skip first and last pages as they're always shown
items.push(
<PaginationItem key={i}>
<PaginationLink
isActive={currentPage === i}
onClick={() => handlePageChange(i)}
className="cursor-pointer"
>
{i}
</PaginationLink>
</PaginationItem>
);
}
// Show ellipsis if needed
if (currentPage < totalPages - 2) {
items.push(
<PaginationItem key="ellipsis-end">
<PaginationEllipsis />
</PaginationItem>
);
}
// Always show last page if there's more than one page
if (totalPages > 1) {
items.push(
<PaginationItem key="last">
<PaginationLink
isActive={currentPage === totalPages}
onClick={() => handlePageChange(totalPages)}
className="cursor-pointer"
>
{totalPages}
</PaginationLink>
</PaginationItem>
);
}
return items;
}
}

View File

@@ -0,0 +1,365 @@
'use client';
import { useEffect, useState } from 'react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { Loader2 } from "lucide-react";
interface MahasiswaStatus {
nim: string;
nama: string;
status_kuliah: string;
tahun_angkatan: number;
}
interface TabelStatusMahasiswaProps {
selectedYear: string;
}
export default function TabelStatusMahasiswa({ selectedYear }: TabelStatusMahasiswaProps) {
const [mahasiswaData, setMahasiswaData] = useState<MahasiswaStatus[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// State for pagination
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [paginatedData, setPaginatedData] = useState<MahasiswaStatus[]>([]);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const url = selectedYear === 'all'
? '/api/tabeldetail'
: `/api/tabeldetail?tahun_angkatan=${selectedYear}`;
const response = await fetch(url, {
cache: 'no-store',
});
if (!response.ok) {
throw new Error('Failed to fetch mahasiswa data');
}
const data = await response.json();
setMahasiswaData(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Terjadi kesalahan');
console.error('Error fetching data:', err);
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedYear]);
// Update paginated data when data or pagination settings change
useEffect(() => {
paginateData();
}, [mahasiswaData, currentPage, pageSize]);
// Paginate data
const paginateData = () => {
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
setPaginatedData(mahasiswaData.slice(startIndex, endIndex));
};
// Get total number of pages
const getTotalPages = () => {
return Math.ceil(mahasiswaData.length / pageSize);
};
// Handle page change
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
// Handle page size change
const handlePageSizeChange = (size: string) => {
setPageSize(Number(size));
setCurrentPage(1); // Reset to first page when changing page size
};
// Fungsi untuk mendapatkan styling badge berdasarkan status (sama seperti data-table-mahasiswa)
const getStatusBadgeStyle = (status: string) => {
switch (status) {
case "Aktif":
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300";
case "Cuti":
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300";
case "Lulus":
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300";
case "Non Aktif":
return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300";
default:
return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300";
}
};
// Hitung statistik berdasarkan status
const statusStats = mahasiswaData.reduce((acc, mahasiswa) => {
const status = mahasiswa.status_kuliah;
acc[status] = (acc[status] || 0) + 1;
return acc;
}, {} as Record<string, number>);
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
<div className="flex items-center gap-2">
<Loader2 className="h-5 w-5 animate-spin" />
Loading...
</div>
</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">
Tabel Status Mahasiswa {selectedYear !== 'all' ? `Angkatan ${selectedYear}` : 'Semua Angkatan'}
</CardTitle>
{/* Tampilkan ringkasan statistik */}
<div className="flex flex-wrap gap-2 mt-2">
{Object.entries(statusStats).map(([status, count]) => (
<span
key={status}
className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusBadgeStyle(status)}`}
>
{status}: {count}
</span>
))}
<span className="px-2 py-1 rounded-full text-xs font-semibold bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200">
Total: {mahasiswaData.length}
</span>
</div>
</CardHeader>
<CardContent>
{/* Show entries selector */}
<div className="flex items-center gap-2 mb-4">
<span className="text-sm">Show</span>
<Select
value={pageSize.toString()}
onValueChange={handlePageSizeChange}
>
<SelectTrigger className="w-[80px]">
<SelectValue placeholder={pageSize.toString()} />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
<span className="text-sm">entries</span>
</div>
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50 dark:bg-slate-800">
<TableHead className="font-semibold text-center">No</TableHead>
<TableHead className="font-semibold">NIM</TableHead>
<TableHead className="font-semibold">Nama Mahasiswa</TableHead>
<TableHead className="font-semibold text-center">Tahun Angkatan</TableHead>
<TableHead className="font-semibold text-center">Status Kuliah</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedData.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
Tidak ada data yang tersedia
</TableCell>
</TableRow>
) : (
paginatedData.map((mahasiswa, index) => (
<TableRow
key={mahasiswa.nim}
className={index % 2 === 0 ? "bg-white dark:bg-slate-900" : "bg-gray-50/50 dark:bg-slate-800/50"}
>
<TableCell className="text-center font-medium dark:text-white">
{(currentPage - 1) * pageSize + index + 1}
</TableCell>
<TableCell className="font-medium dark:text-white">
{mahasiswa.nim}
</TableCell>
<TableCell className="dark:text-white">
{mahasiswa.nama}
</TableCell>
<TableCell className="text-center dark:text-white">
{mahasiswa.tahun_angkatan}
</TableCell>
<TableCell className="text-center">
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusBadgeStyle(mahasiswa.status_kuliah)}`}
>
{mahasiswa.status_kuliah}
</span>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination info and controls */}
{!loading && !error && mahasiswaData.length > 0 && (
<div className="flex flex-col sm:flex-row justify-between items-center gap-4 mt-4">
<div className="text-sm text-muted-foreground">
Showing {getDisplayRange().start} to {getDisplayRange().end} of {mahasiswaData.length} entries
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => handlePageChange(Math.max(1, currentPage - 1))}
className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
{renderPaginationItems()}
<PaginationItem>
<PaginationNext
onClick={() => handlePageChange(Math.min(getTotalPages(), currentPage + 1))}
className={currentPage === getTotalPages() ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</CardContent>
</Card>
);
// Calculate the range of entries being displayed
function getDisplayRange() {
if (mahasiswaData.length === 0) return { start: 0, end: 0 };
const start = (currentPage - 1) * pageSize + 1;
const end = Math.min(currentPage * pageSize, mahasiswaData.length);
return { start, end };
}
// Generate pagination items
function renderPaginationItems() {
const totalPages = getTotalPages();
const items = [];
// Always show first page
items.push(
<PaginationItem key="first">
<PaginationLink
isActive={currentPage === 1}
onClick={() => handlePageChange(1)}
className="cursor-pointer"
>
1
</PaginationLink>
</PaginationItem>
);
// Show ellipsis if needed
if (currentPage > 3) {
items.push(
<PaginationItem key="ellipsis-start">
<PaginationEllipsis />
</PaginationItem>
);
}
// Show pages around current page
for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) {
if (i === 1 || i === totalPages) continue; // Skip first and last pages as they're always shown
items.push(
<PaginationItem key={i}>
<PaginationLink
isActive={currentPage === i}
onClick={() => handlePageChange(i)}
className="cursor-pointer"
>
{i}
</PaginationLink>
</PaginationItem>
);
}
// Show ellipsis if needed
if (currentPage < totalPages - 2) {
items.push(
<PaginationItem key="ellipsis-end">
<PaginationEllipsis />
</PaginationItem>
);
}
// Always show last page if there's more than one page
if (totalPages > 1) {
items.push(
<PaginationItem key="last">
<PaginationLink
isActive={currentPage === totalPages}
onClick={() => handlePageChange(totalPages)}
className="cursor-pointer"
>
{totalPages}
</PaginationLink>
</PaginationItem>
);
}
return items;
}
}

View File

@@ -26,6 +26,8 @@ interface UserData {
const Navbar = () => {
const [user, setUser] = useState<UserData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isVisible, setIsVisible] = useState(true);
const [lastScrollY, setLastScrollY] = useState(0);
const { showSuccess, showError } = useToast ();
const router = useRouter();
@@ -34,6 +36,29 @@ const Navbar = () => {
checkUserSession();
}, []);
// Handle scroll behavior
useEffect(() => {
const controlNavbar = () => {
if (typeof window !== 'undefined') {
if (window.scrollY > lastScrollY && window.scrollY > 100) {
// Scrolling down & past 100px
setIsVisible(false);
} else {
// Scrolling up
setIsVisible(true);
}
setLastScrollY(window.scrollY);
}
};
if (typeof window !== 'undefined') {
window.addEventListener('scroll', controlNavbar);
return () => {
window.removeEventListener('scroll', controlNavbar);
};
}
}, [lastScrollY]);
const checkUserSession = async () => {
try {
const response = await fetch('/api/auth/user');
@@ -89,7 +114,9 @@ const Navbar = () => {
}
return (
<div className="bg-background/95 border-b py-2 sticky top-0 z-30">
<div className={`bg-background/95 border-b py-2 sticky top-0 z-30 transition-transform duration-300 ${
isVisible ? 'translate-y-0' : '-translate-y-full'
}`}>
<div className="container mx-auto px-4 flex justify-between items-center">
{/* Logo */}
<div className="flex items-center">

46
components/ui/badge.tsx Normal file
View File

@@ -0,0 +1,46 @@
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 badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-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 transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }