First commit

This commit is contained in:
Randa Firman Putra
2025-06-18 22:03:32 +07:00
parent 852121be46
commit e028039ee2
123 changed files with 17506 additions and 144 deletions

View File

@@ -0,0 +1,237 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface AsalDaerahBeasiswaData {
tahun_angkatan: number;
kabupaten: string;
jumlah_mahasiswa: number;
}
interface Props {
selectedYear: string;
selectedJenisBeasiswa: string;
}
export default function AsalDaerahBeasiswaChart({ selectedYear, selectedJenisBeasiswa }: Props) {
const { theme, systemTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const [data, setData] = useState<AsalDaerahBeasiswaData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const url = `/api/mahasiswa/asal-daerah-beasiswa?tahunAngkatan=${selectedYear}&jenisBeasiswa=${selectedJenisBeasiswa}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
}
const result = await response.json();
if (!Array.isArray(result)) {
throw new Error('Invalid data format received from server');
}
setData(result);
} catch (err) {
console.error('Error in fetchData:', err);
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedYear, selectedJenisBeasiswa]);
const chartOptions: ApexOptions = {
chart: {
type: 'bar',
stacked: false,
toolbar: {
show: true,
tools: {
download: true,
selection: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true
}
},
background: theme === 'dark' ? '#0F172B' : '#fff',
},
plotOptions: {
bar: {
horizontal: true,
columnWidth: '85%',
distributed: false,
barHeight: '90%',
dataLabels: {
position: 'top'
}
},
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return val.toString();
},
style: {
fontSize: '14px',
colors: [theme === 'dark' ? '#fff' : '#000']
},
offsetX: 10,
},
stroke: {
show: true,
width: 1,
colors: [theme === 'dark' ? '#374151' : '#E5E7EB'],
},
xaxis: {
categories: [...new Set(data.map(item => item.kabupaten))].sort(),
title: {
text: 'Jumlah Mahasiswa',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
}
},
yaxis: {
title: {
text: 'Kabupaten',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '14px',
colors: theme === 'dark' ? '#fff' : '#000'
},
maxWidth: 200,
},
tickAmount: undefined,
},
grid: {
padding: {
top: 0,
right: 0,
bottom: 0,
left: 10
}
},
fill: {
opacity: 1,
},
colors: ['#3B82F6'],
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val + " mahasiswa";
}
}
}
};
const series = [{
name: 'Jumlah Mahasiswa',
data: [...new Set(data.map(item => item.kabupaten))].sort().map(kabupaten => {
const item = data.find(d => d.kabupaten === kabupaten);
return item ? item.jumlah_mahasiswa : 0;
})
}];
if (!mounted) {
return null;
}
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Asal Daerah Mahasiswa Beasiswa {selectedJenisBeasiswa}
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[600px] w-full">
<Chart
options={chartOptions}
series={series}
type="bar"
height="100%"
width="90%"
/>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,286 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface AsalDaerahData {
kabupaten: string;
jumlah: number;
}
export default function AsalDaerahChart() {
const { theme, systemTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<AsalDaerahData[]>([]);
const [series, setSeries] = useState<ApexAxisChartSeries>([{
name: 'Jumlah Mahasiswa',
data: []
}]);
const [options, setOptions] = useState<ApexOptions>({
chart: {
type: 'bar',
stacked: false,
toolbar: {
show: true,
},
},
plotOptions: {
bar: {
horizontal: true,
columnWidth: '85%',
distributed: false,
barHeight: '90%',
dataLabels: {
position: 'top',
},
},
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return val.toString();
},
style: {
fontSize: '14px',
colors: [theme === 'dark' ? '#fff' : '#000']
},
offsetX: 10,
},
stroke: {
show: true,
width: 1,
colors: [theme === 'dark' ? '#374151' : '#E5E7EB'],
},
xaxis: {
categories: [],
title: {
text: 'Jumlah Mahasiswa',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
}
},
yaxis: {
title: {
text: 'Kabupaten',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '14px',
colors: theme === 'dark' ? '#fff' : '#000'
},
maxWidth: 200,
},
tickAmount: undefined,
},
grid: {
padding: {
top: 0,
right: 0,
bottom: 0,
left: 0
}
},
fill: {
opacity: 1,
},
colors: ['#3B82F6'], // Blue color for bars
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val + " mahasiswa";
}
}
}
});
// Update theme when it changes
useEffect(() => {
const currentTheme = theme === 'system' ? systemTheme : theme;
setOptions(prev => ({
...prev,
chart: {
...prev.chart,
background: currentTheme === 'dark' ? '#0F172B' : '#fff',
},
dataLabels: {
...prev.dataLabels,
style: {
...prev.dataLabels?.style,
colors: [currentTheme === 'dark' ? '#fff' : '#000']
}
},
xaxis: {
...prev.xaxis,
title: {
...prev.xaxis?.title,
style: {
...prev.xaxis?.title?.style,
color: currentTheme === 'dark' ? '#fff' : '#000'
}
},
labels: {
...prev.xaxis?.labels,
style: {
...prev.xaxis?.labels?.style,
colors: currentTheme === 'dark' ? '#fff' : '#000'
}
}
},
yaxis: {
...prev.yaxis,
title: {
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title : prev.yaxis?.title),
style: {
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title?.style : prev.yaxis?.title?.style),
color: currentTheme === 'dark' ? '#fff' : '#000'
}
},
labels: {
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels : prev.yaxis?.labels),
style: {
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels?.style : prev.yaxis?.labels?.style),
colors: currentTheme === 'dark' ? '#fff' : '#000'
}
}
},
tooltip: {
...prev.tooltip,
theme: currentTheme === 'dark' ? 'dark' : 'light'
}
}));
}, [theme, systemTheme]);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch('/api/mahasiswa/asal-daerah');
if (!response.ok) {
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
}
const result = await response.json();
if (!Array.isArray(result)) {
throw new Error('Invalid data format received from server');
}
setData(result);
// Process data for chart
const kabupaten = result.map(item => item.kabupaten);
const jumlah = result.map(item => item.jumlah);
setSeries([{
name: 'Jumlah Mahasiswa',
data: jumlah
}]);
setOptions(prev => ({
...prev,
xaxis: {
...prev.xaxis,
categories: kabupaten,
},
}));
} catch (err) {
console.error('Error in fetchData:', err);
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
} finally {
setLoading(false);
}
};
fetchData();
}, []);
if (!mounted) {
return null;
}
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500 dark:text-red-400">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Asal Daerah Mahasiswa
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[600px] w-full">
<Chart
options={options}
series={series}
type="bar"
height="100%"
width="90%"
/>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,279 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface AsalDaerahLulusChartProps {
selectedYear: string;
}
interface ChartData {
tahun_angkatan: string;
kabupaten: string;
jumlah_lulus_tepat_waktu: number;
}
export default function AsalDaerahLulusChart({ selectedYear }: AsalDaerahLulusChartProps) {
const { theme, systemTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const [chartData, setChartData] = useState<ChartData[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [series, setSeries] = useState<ApexAxisChartSeries>([{
name: 'Jumlah Lulus Tepat Waktu',
data: []
}]);
const [options, setOptions] = useState<ApexOptions>({
chart: {
type: 'bar',
stacked: false,
toolbar: {
show: true,
},
background: theme === 'dark' ? '#0F172B' : '#fff',
},
plotOptions: {
bar: {
horizontal: true,
columnWidth: '55%',
dataLabels: {
position: 'top'
}
},
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return val.toString();
},
style: {
fontSize: '14px',
colors: [theme === 'dark' ? '#fff' : '#000']
},
offsetX: 10,
},
stroke: {
show: true,
width: 1,
colors: [theme === 'dark' ? '#374151' : '#E5E7EB'],
},
xaxis: {
categories: [],
title: {
text: 'Jumlah Mahasiswa',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
}
},
yaxis: {
title: {
text: 'Kabupaten',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '14px',
colors: theme === 'dark' ? '#fff' : '#000'
},
maxWidth: 200,
},
tickAmount: undefined,
},
grid: {
padding: {
top: 0,
right: 0,
bottom: 0,
left: 0
}
},
fill: {
opacity: 1,
},
colors: ['#3B82F6'], // Blue color for bars
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val + " mahasiswa";
}
}
}
});
// Update theme when it changes
useEffect(() => {
const currentTheme = theme === 'system' ? systemTheme : theme;
setOptions(prev => ({
...prev,
dataLabels: {
...prev.dataLabels,
style: {
...prev.dataLabels?.style,
colors: [currentTheme === 'dark' ? '#fff' : '#000']
}
},
xaxis: {
...prev.xaxis,
title: {
...prev.xaxis?.title,
style: {
...prev.xaxis?.title?.style,
color: currentTheme === 'dark' ? '#fff' : '#000'
}
},
labels: {
...prev.xaxis?.labels,
style: {
...prev.xaxis?.labels?.style,
colors: currentTheme === 'dark' ? '#fff' : '#000'
}
}
},
yaxis: {
...prev.yaxis,
title: {
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title : prev.yaxis?.title),
style: {
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title?.style : prev.yaxis?.title?.style),
color: currentTheme === 'dark' ? '#fff' : '#000'
}
},
labels: {
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels : prev.yaxis?.labels),
style: {
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels?.style : prev.yaxis?.labels?.style),
colors: currentTheme === 'dark' ? '#fff' : '#000'
}
}
},
tooltip: {
...prev.tooltip,
theme: currentTheme === 'dark' ? 'dark' : 'light'
}
}));
}, [theme, systemTheme]);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
const fetchData = async () => {
try {
setIsLoading(true);
setError(null);
const response = await fetch(`/api/mahasiswa/asal-daerah-lulus?tahunAngkatan=${selectedYear}`);
if (!response.ok) {
throw new Error('Failed to fetch data');
}
const data = await response.json();
setChartData(data);
// Process data for chart
const kabupaten = data.map((item: ChartData) => item.kabupaten);
const jumlah = data.map((item: ChartData) => item.jumlah_lulus_tepat_waktu);
setSeries([{
name: 'Jumlah Lulus Tepat Waktu',
data: jumlah
}]);
setOptions(prev => ({
...prev,
xaxis: {
...prev.xaxis,
categories: kabupaten,
},
}));
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
console.error('Error fetching data:', err);
} finally {
setIsLoading(false);
}
};
fetchData();
}, [selectedYear]);
if (!mounted) {
return null;
}
if (isLoading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">Loading...</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500 dark:text-red-400">Error: {error}</CardTitle>
</CardHeader>
</Card>
);
}
if (chartData.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Asal Daerah Mahasiswa Lulus Tepat Waktu
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[600px] w-full">
<Chart
options={options}
series={series}
type="bar"
height="100%"
width="90%"
/>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,323 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface AsalDaerahData {
kabupaten: string;
jumlah: number;
}
interface Props {
tahunAngkatan: string;
}
export default function AsalDaerahPerAngkatanChart({ tahunAngkatan }: Props) {
const { theme, systemTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<AsalDaerahData[]>([]);
const [series, setSeries] = useState<ApexAxisChartSeries>([{
name: 'Jumlah Mahasiswa',
data: []
}]);
const [options, setOptions] = useState<ApexOptions>({
chart: {
type: 'bar',
stacked: false,
toolbar: {
show: true,
tools: {
download: true,
selection: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true,
customIcons: []
},
export: {
csv: {
filename: `asal-daerah-angkatan`,
columnDelimiter: ',',
headerCategory: 'Asal Daerah',
headerValue: 'Jumlah Mahasiswa'
}
},
},
},
plotOptions: {
bar: {
horizontal: true,
columnWidth: '85%',
distributed: false,
barHeight: '90%',
dataLabels: {
position: 'top'
}
}
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return val.toString();
},
style: {
fontSize: '14px',
colors: [theme === 'dark' ? '#fff' : '#000']
},
offsetX: 10,
},
stroke: {
show: true,
width: 1,
colors: [theme === 'dark' ? '#374151' : '#E5E7EB'],
},
xaxis: {
categories: [],
title: {
text: 'Jumlah Mahasiswa',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
}
},
yaxis: {
title: {
text: 'Kabupaten',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '14px',
colors: theme === 'dark' ? '#fff' : '#000'
},
maxWidth: 200,
},
tickAmount: undefined,
},
grid: {
padding: {
top: 0,
right: 0,
bottom: 0,
left: 0
}
},
fill: {
opacity: 1,
},
colors: ['#3B82F6'], // Blue color for bars
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val + " mahasiswa";
}
}
}
});
// Update theme when it changes
useEffect(() => {
const currentTheme = theme === 'system' ? systemTheme : theme;
setOptions(prev => ({
...prev,
chart: {
...prev.chart,
background: currentTheme === 'dark' ? '#0F172B' : '#fff',
},
dataLabels: {
...prev.dataLabels,
style: {
...prev.dataLabels?.style,
colors: [currentTheme === 'dark' ? '#fff' : '#000']
}
},
xaxis: {
...prev.xaxis,
title: {
...prev.xaxis?.title,
style: {
...prev.xaxis?.title?.style,
color: currentTheme === 'dark' ? '#fff' : '#000'
}
},
labels: {
...prev.xaxis?.labels,
style: {
...prev.xaxis?.labels?.style,
colors: currentTheme === 'dark' ? '#fff' : '#000'
}
}
},
yaxis: {
...prev.yaxis,
title: {
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title : prev.yaxis?.title),
style: {
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title?.style : prev.yaxis?.title?.style),
color: currentTheme === 'dark' ? '#fff' : '#000'
}
},
labels: {
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels : prev.yaxis?.labels),
style: {
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels?.style : prev.yaxis?.labels?.style),
colors: currentTheme === 'dark' ? '#fff' : '#000'
}
}
},
tooltip: {
...prev.tooltip,
theme: currentTheme === 'dark' ? 'dark' : 'light'
}
}));
}, [theme, systemTheme]);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(`/api/mahasiswa/asal-daerah-angkatan?tahun_angkatan=${tahunAngkatan}`);
if (!response.ok) {
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
}
const result = await response.json();
if (!Array.isArray(result)) {
throw new Error('Invalid data format received from server');
}
setData(result);
// Process data for chart
const kabupaten = result.map(item => item.kabupaten);
const jumlah = result.map(item => item.jumlah);
setSeries([{
name: 'Jumlah Mahasiswa',
data: jumlah
}]);
setOptions(prev => ({
...prev,
xaxis: {
...prev.xaxis,
categories: kabupaten,
},
chart: {
...prev.chart,
toolbar: {
...prev.chart?.toolbar,
export: {
...prev.chart?.toolbar?.export,
csv: {
...prev.chart?.toolbar?.export?.csv,
filename: `asal-daerah-angkatan-${tahunAngkatan}`,
}
}
}
}
}));
} catch (err) {
console.error('Error in fetchData:', err);
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
} finally {
setLoading(false);
}
};
if (tahunAngkatan) {
fetchData();
}
}, [tahunAngkatan]);
if (!mounted) {
return null;
}
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500 dark:text-red-400">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Asal Daerah Mahasiswa Angkatan {tahunAngkatan}
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[600px] w-full">
<Chart
options={options}
series={series}
type="bar"
height="100%"
width="90%"
/>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,237 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface AsalDaerahPrestasiData {
tahun_angkatan: number;
kabupaten: string;
asal_daerah_mahasiswa_prestasi: number;
}
interface Props {
selectedYear: string;
selectedJenisPrestasi: string;
}
export default function AsalDaerahPrestasiChart({ selectedYear, selectedJenisPrestasi }: Props) {
const { theme, systemTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const [data, setData] = useState<AsalDaerahPrestasiData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const url = `/api/mahasiswa/asal-daerah-prestasi?tahunAngkatan=${selectedYear}&jenisPrestasi=${selectedJenisPrestasi}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
}
const result = await response.json();
if (!Array.isArray(result)) {
throw new Error('Invalid data format received from server');
}
setData(result);
} catch (err) {
console.error('Error in fetchData:', err);
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedYear, selectedJenisPrestasi]);
const chartOptions: ApexOptions = {
chart: {
type: 'bar',
stacked: false,
toolbar: {
show: true,
tools: {
download: true,
selection: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true
}
},
background: theme === 'dark' ? '#0F172B' : '#fff',
},
plotOptions: {
bar: {
horizontal: true,
columnWidth: '85%',
distributed: false,
barHeight: '90%',
dataLabels: {
position: 'top'
}
},
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return val.toString();
},
style: {
fontSize: '14px',
colors: [theme === 'dark' ? '#fff' : '#000']
},
offsetX: 10,
},
stroke: {
show: true,
width: 1,
colors: [theme === 'dark' ? '#374151' : '#E5E7EB'],
},
xaxis: {
categories: [...new Set(data.map(item => item.kabupaten))].sort(),
title: {
text: 'Jumlah Mahasiswa',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
}
},
yaxis: {
title: {
text: 'Kabupaten',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '14px',
colors: theme === 'dark' ? '#fff' : '#000'
},
maxWidth: 200,
},
tickAmount: undefined,
},
grid: {
padding: {
top: 0,
right: 0,
bottom: 0,
left: 10
}
},
fill: {
opacity: 1,
},
colors: ['#3B82F6'],
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val + " mahasiswa";
}
}
}
};
const series = [{
name: 'Jumlah Mahasiswa',
data: [...new Set(data.map(item => item.kabupaten))].sort().map(kabupaten => {
const item = data.find(d => d.kabupaten === kabupaten);
return item ? item.asal_daerah_mahasiswa_prestasi : 0;
})
}];
if (!mounted) {
return null;
}
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Asal Daerah Mahasiswa Prestasi {selectedJenisPrestasi}
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[600px] w-full">
<Chart
options={chartOptions}
series={series}
type="bar"
height="100%"
width="90%"
/>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,242 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface AsalDaerahStatusData {
kabupaten: string;
tahun_angkatan?: number;
status_kuliah: string;
total_mahasiswa: number;
}
interface Props {
selectedYear: string;
selectedStatus: string;
}
export default function AsalDaerahStatusChart({ selectedYear, selectedStatus }: Props) {
const { theme } = useTheme();
const [data, setData] = useState<AsalDaerahStatusData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
console.log('Fetching data with params:', { selectedYear, selectedStatus });
const response = await fetch(
`/api/mahasiswa/asal-daerah-status?tahun_angkatan=${selectedYear}&status_kuliah=${selectedStatus}`
);
if (!response.ok) {
throw new Error('Failed to fetch data');
}
const result = await response.json();
console.log('Received data:', result);
// Sort data by kabupaten
const sortedData = result.sort((a: AsalDaerahStatusData, b: AsalDaerahStatusData) =>
a.kabupaten.localeCompare(b.kabupaten)
);
setData(sortedData);
} catch (err) {
console.error('Error in fetchData:', err);
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedYear, selectedStatus]);
// Log data changes
useEffect(() => {
console.log('Current data state:', data);
}, [data]);
// Get unique kabupaten
const kabupaten = [...new Set(data.map(item => item.kabupaten))].sort();
console.log('Kabupaten:', kabupaten);
const chartOptions: ApexOptions = {
chart: {
type: 'bar',
stacked: false,
toolbar: {
show: true,
},
background: theme === 'dark' ? '#0F172B' : '#fff',
},
plotOptions: {
bar: {
horizontal: true,
columnWidth: '55%',
dataLabels: {
position: 'top'
}
},
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return val.toString();
},
style: {
fontSize: '12px',
colors: [theme === 'dark' ? '#fff' : '#000']
},
offsetX: 10,
},
stroke: {
show: true,
width: 2,
colors: ['transparent'],
},
xaxis: {
categories: kabupaten,
title: {
text: 'Jumlah Mahasiswa',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
}
},
yaxis: {
title: {
text: 'Kabupaten',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
}
},
fill: {
opacity: 1,
},
legend: {
position: 'top',
fontSize: '14px',
markers: {
size: 12,
},
itemMargin: {
horizontal: 10,
},
labels: {
colors: theme === 'dark' ? '#fff' : '#000'
}
},
colors: ['#008FFB'],
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val + " mahasiswa";
}
}
}
};
// Process data for series
const processSeriesData = () => {
const seriesData = kabupaten.map(kab => {
const item = data.find(d => d.kabupaten === kab);
return item ? item.total_mahasiswa : 0;
});
return [{
name: 'Jumlah Mahasiswa',
data: seriesData
}];
};
const series = processSeriesData();
console.log('Processed series data:', series);
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Asal Daerah Mahasiswa {selectedStatus}
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[500px] w-full max-w-5xl mx-auto">
{typeof window !== 'undefined' && (
<Chart
options={chartOptions}
series={series}
type="bar"
height="100%"
width="90%"
/>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,27 @@
'use client';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
interface Props {
selectedJenisBeasiswa: string;
onJenisBeasiswaChange: (jenisBeasiswa: string) => void;
}
export default function FilterJenisBeasiswa({ selectedJenisBeasiswa, onJenisBeasiswaChange }: Props) {
return (
<div className="w-64">
<Select
value={selectedJenisBeasiswa}
onValueChange={onJenisBeasiswaChange}
>
<SelectTrigger className="focus:outline-none focus:ring-0 focus:ring-offset-0">
<SelectValue placeholder="Pilih Jenis Beasiswa" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Pemerintah">Pemerintah</SelectItem>
<SelectItem value="Non-Pemerintah">Non-Pemerintah</SelectItem>
</SelectContent>
</Select>
</div>
);
}

View File

@@ -0,0 +1,27 @@
'use client';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
interface Props {
selectedJenisPrestasi: string;
onJenisPrestasiChange: (jenisPrestasi: string) => void;
}
export default function FilterJenisPrestasi({ selectedJenisPrestasi, onJenisPrestasiChange }: Props) {
return (
<div className="w-64">
<Select
value={selectedJenisPrestasi}
onValueChange={onJenisPrestasiChange}
>
<SelectTrigger className="focus:outline-none focus:ring-0 focus:ring-offset-0">
<SelectValue placeholder="Pilih Jenis Prestasi" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Akademik">Akademik</SelectItem>
<SelectItem value="Non-Akademik">Non-Akademik</SelectItem>
</SelectContent>
</Select>
</div>
);
}

View File

@@ -0,0 +1,38 @@
'use client';
import { useState, useEffect } from 'react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
interface Props {
selectedStatus: string;
onStatusChange: (status: string) => void;
}
export default function FilterStatusKuliah({ selectedStatus, onStatusChange }: Props) {
const statusOptions = [
{ value: 'Aktif', label: 'Aktif' },
{ value: 'Lulus', label: 'Lulus' },
{ value: 'Cuti', label: 'Cuti' },
{ value: 'DO', label: 'DO' }
];
return (
<div className="w-64">
<Select
value={selectedStatus}
onValueChange={onStatusChange}
>
<SelectTrigger className="focus:outline-none focus:ring-0 focus:ring-offset-0">
<SelectValue placeholder="Pilih Status Kuliah" />
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

View File

@@ -0,0 +1,58 @@
'use client';
import { useState, useEffect } from 'react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
interface TahunAngkatan {
tahun_angkatan: number;
}
interface Props {
selectedYear: string;
onYearChange: (year: string) => void;
showAllOption?: boolean;
}
export default function FilterTahunAngkatan({ selectedYear, onYearChange, showAllOption = true }: Props) {
const [tahunAngkatan, setTahunAngkatan] = useState<TahunAngkatan[]>([]);
useEffect(() => {
const fetchTahunAngkatan = async () => {
try {
const response = await fetch('/api/mahasiswa/tahun-angkatan');
if (!response.ok) {
throw new Error('Failed to fetch tahun angkatan');
}
const data = await response.json();
setTahunAngkatan(data);
} catch (error) {
console.error('Error fetching tahun angkatan:', error);
}
};
fetchTahunAngkatan();
}, []);
return (
<div className="w-64">
<Select value={selectedYear} onValueChange={onYearChange}>
<SelectTrigger className="focus:outline-none focus:ring-0 focus:ring-offset-0">
<SelectValue placeholder="Pilih Tahun Angkatan" />
</SelectTrigger>
<SelectContent>
{showAllOption && (
<SelectItem value="all">Semua Angkatan</SelectItem>
)}
{tahunAngkatan.map((tahun) => (
<SelectItem
key={tahun.tahun_angkatan}
value={tahun.tahun_angkatan.toString()}
>
{tahun.tahun_angkatan}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

View File

@@ -0,0 +1,344 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface IPKBeasiswaData {
tahun_angkatan: number;
total_mahasiswa_beasiswa: number;
rata_rata_ipk: number;
}
interface Props {
selectedJenisBeasiswa: string;
}
export default function IPKBeasiswaChart({ selectedJenisBeasiswa }: Props) {
const { theme, systemTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<IPKBeasiswaData[]>([]);
const [series, setSeries] = useState<ApexAxisChartSeries>([{
name: 'Rata-rata IPK',
data: []
}]);
const [options, setOptions] = useState<ApexOptions>({
chart: {
type: 'line',
toolbar: {
show: true,
tools: {
download: true,
selection: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true
}
},
background: theme === 'dark' ? '#0F172B' : '#fff',
zoom: {
enabled: true,
type: 'x',
autoScaleYaxis: true
}
},
stroke: {
curve: 'smooth',
width: 3,
lineCap: 'round'
},
markers: {
size: 5,
strokeWidth: 2,
strokeColors: theme === 'dark' ? ['#fff'] : ['#3B82F6'],
colors: ['#3B82F6'],
hover: {
size: 7
}
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return val.toFixed(2);
},
style: {
fontSize: '14px',
fontWeight: 'bold'
},
background: {
enabled: false
},
offsetY: -10
},
xaxis: {
categories: [],
title: {
text: 'Tahun Angkatan',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
},
axisBorder: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
},
axisTicks: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
}
},
yaxis: {
title: {
text: 'Rata-rata IPK',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
},
formatter: function (val: number) {
return val.toFixed(2);
}
},
min: 0,
max: 4,
axisBorder: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
}
},
grid: {
borderColor: theme === 'dark' ? '#374151' : '#E5E7EB',
strokeDashArray: 4,
padding: {
top: 20,
right: 0,
bottom: 0,
left: 0
}
},
colors: ['#3B82F6'],
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val.toFixed(2);
}
},
marker: {
show: true
}
},
legend: {
show: true,
position: 'top',
horizontalAlign: 'right',
labels: {
colors: theme === 'dark' ? '#fff' : '#000'
}
}
});
// Update theme when it changes
useEffect(() => {
const currentTheme = theme === 'system' ? systemTheme : theme;
setOptions(prev => ({
...prev,
chart: {
...prev.chart,
background: currentTheme === 'dark' ? '#0F172B' : '#fff',
},
dataLabels: {
...prev.dataLabels,
style: {
...prev.dataLabels?.style,
colors: [currentTheme === 'dark' ? '#fff' : '#000']
},
background: {
...prev.dataLabels?.background,
foreColor: currentTheme === 'dark' ? '#fff' : '#000',
borderColor: currentTheme === 'dark' ? '#374151' : '#3B82F6'
}
},
xaxis: {
...prev.xaxis,
title: {
...prev.xaxis?.title,
style: {
...prev.xaxis?.title?.style,
color: currentTheme === 'dark' ? '#fff' : '#000'
}
},
labels: {
...prev.xaxis?.labels,
style: {
...prev.xaxis?.labels?.style,
colors: currentTheme === 'dark' ? '#fff' : '#000'
}
},
axisBorder: {
...prev.xaxis?.axisBorder,
color: currentTheme === 'dark' ? '#374151' : '#E5E7EB'
},
axisTicks: {
...prev.xaxis?.axisTicks,
color: currentTheme === 'dark' ? '#374151' : '#E5E7EB'
}
},
yaxis: {
...prev.yaxis,
title: {
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title : prev.yaxis?.title),
style: {
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title?.style : prev.yaxis?.title?.style),
color: currentTheme === 'dark' ? '#fff' : '#000'
}
},
labels: {
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels : prev.yaxis?.labels),
style: {
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels?.style : prev.yaxis?.labels?.style),
colors: currentTheme === 'dark' ? '#fff' : '#000'
}
}
},
tooltip: {
...prev.tooltip,
theme: currentTheme === 'dark' ? 'dark' : 'light'
}
}));
}, [theme, systemTheme]);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(`/api/mahasiswa/ipk-beasiswa?jenisBeasiswa=${selectedJenisBeasiswa}`);
if (!response.ok) {
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
}
const result = await response.json();
if (!Array.isArray(result)) {
throw new Error('Invalid data format received from server');
}
setData(result);
// Process data for chart
const tahunAngkatan = result.map(item => item.tahun_angkatan);
const rataRataIPK = result.map(item => item.rata_rata_ipk);
setSeries([{
name: 'Rata-rata IPK',
data: rataRataIPK
}]);
setOptions(prev => ({
...prev,
xaxis: {
...prev.xaxis,
categories: tahunAngkatan,
},
}));
} catch (err) {
console.error('Error in fetchData:', err);
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedJenisBeasiswa]);
if (!mounted) {
return null;
}
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500 dark:text-red-400">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Rata-rata IPK Mahasiswa Beasiswa {selectedJenisBeasiswa}
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px] sm:h-[350px] md:h-[400px] w-full max-w-5xl mx-auto">
<Chart
options={options}
series={series}
type="line"
height="100%"
width="90%"
/>
</div>
</CardContent>
</Card>
);
}

339
components/IPKChart.tsx Normal file
View File

@@ -0,0 +1,339 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface IPKData {
tahun_angkatan: number;
rata_rata_ipk: number;
}
export default function IPKChart() {
const { theme, systemTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<IPKData[]>([]);
const [series, setSeries] = useState<ApexAxisChartSeries>([{
name: 'Rata-rata IPK',
data: []
}]);
const [options, setOptions] = useState<ApexOptions>({
chart: {
type: 'line',
toolbar: {
show: true,
tools: {
download: true,
selection: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true
}
},
background: theme === 'dark' ? '#0F172B' : '#fff',
zoom: {
enabled: true,
type: 'x',
autoScaleYaxis: true
}
},
stroke: {
curve: 'smooth',
width: 3,
lineCap: 'round'
},
markers: {
size: 5,
strokeWidth: 2,
strokeColors: theme === 'dark' ? ['#fff'] : ['#3B82F6'],
colors: ['#3B82F6'],
hover: {
size: 7
}
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return val.toFixed(2);
},
style: {
fontSize: '14px',
fontWeight: 'bold'
},
background: {
enabled: false
},
offsetY: -10
},
xaxis: {
categories: [],
title: {
text: 'Tahun Angkatan',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
},
axisBorder: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
},
axisTicks: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
}
},
yaxis: {
title: {
text: 'Rata-rata IPK',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
},
formatter: function (val: number) {
return val.toFixed(2);
}
},
min: 0,
max: 4,
axisBorder: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
}
},
grid: {
borderColor: theme === 'dark' ? '#374151' : '#E5E7EB',
strokeDashArray: 4,
padding: {
top: 20,
right: 0,
bottom: 0,
left: 0
}
},
colors: ['#3B82F6'], // Blue color for line
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val.toFixed(2);
}
},
marker: {
show: true
}
},
legend: {
show: true,
position: 'top',
horizontalAlign: 'right',
labels: {
colors: theme === 'dark' ? '#fff' : '#000'
}
}
});
// Update theme when it changes
useEffect(() => {
const currentTheme = theme === 'system' ? systemTheme : theme;
setOptions(prev => ({
...prev,
chart: {
...prev.chart,
background: currentTheme === 'dark' ? '#0F172B' : '#fff',
},
dataLabels: {
...prev.dataLabels,
style: {
...prev.dataLabels?.style,
colors: [currentTheme === 'dark' ? '#fff' : '#000']
},
background: {
...prev.dataLabels?.background,
foreColor: currentTheme === 'dark' ? '#fff' : '#000',
borderColor: currentTheme === 'dark' ? '#374151' : '#3B82F6'
}
},
xaxis: {
...prev.xaxis,
title: {
...prev.xaxis?.title,
style: {
...prev.xaxis?.title?.style,
color: currentTheme === 'dark' ? '#fff' : '#000'
}
},
labels: {
...prev.xaxis?.labels,
style: {
...prev.xaxis?.labels?.style,
colors: currentTheme === 'dark' ? '#fff' : '#000'
}
},
axisBorder: {
...prev.xaxis?.axisBorder,
color: currentTheme === 'dark' ? '#374151' : '#E5E7EB'
},
axisTicks: {
...prev.xaxis?.axisTicks,
color: currentTheme === 'dark' ? '#374151' : '#E5E7EB'
}
},
yaxis: {
...prev.yaxis,
title: {
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title : prev.yaxis?.title),
style: {
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title?.style : prev.yaxis?.title?.style),
color: currentTheme === 'dark' ? '#fff' : '#000'
}
},
labels: {
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels : prev.yaxis?.labels),
style: {
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels?.style : prev.yaxis?.labels?.style),
colors: currentTheme === 'dark' ? '#fff' : '#000'
}
}
},
tooltip: {
...prev.tooltip,
theme: currentTheme === 'dark' ? 'dark' : 'light'
}
}));
}, [theme, systemTheme]);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch('/api/mahasiswa/ipk');
if (!response.ok) {
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
}
const result = await response.json();
if (!Array.isArray(result)) {
throw new Error('Invalid data format received from server');
}
setData(result);
// Process data for chart
const tahunAngkatan = result.map(item => item.tahun_angkatan);
const rataRataIPK = result.map(item => item.rata_rata_ipk);
setSeries([{
name: 'Rata-rata IPK',
data: rataRataIPK
}]);
setOptions(prev => ({
...prev,
xaxis: {
...prev.xaxis,
categories: tahunAngkatan,
},
}));
} catch (err) {
console.error('Error in fetchData:', err);
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
} finally {
setLoading(false);
}
};
fetchData();
}, []);
if (!mounted) {
return null;
}
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500 dark:text-red-400">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Rata-rata IPK Mahasiswa
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px] sm:h-[350px] md:h-[400px] w-full max-w-5xl mx-auto">
<Chart
options={options}
series={series}
type="line"
height="100%"
width="90%"
/>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,295 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface Props {
tahunAngkatan: string;
}
export default function IPKJenisKelaminChart({ tahunAngkatan }: Props) {
const { theme, systemTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const [series, setSeries] = useState<any[]>([]);
const [categories, setCategories] = useState<string[]>([]);
const [options, setOptions] = useState<ApexOptions>({
chart: {
type: 'bar',
toolbar: {
show: true,
tools: {
download: true,
selection: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true,
customIcons: []
},
export: {
csv: {
filename: `ipk-jenis-kelamin-angkatan`,
columnDelimiter: ',',
headerCategory: 'Jenis Kelamin',
headerValue: 'Rata-rata IPK'
}
},
},
background: theme === 'dark' ? '#0F172B' : '#fff',
},
colors: ['#3B82F6', '#EC4899'],
plotOptions: {
bar: {
horizontal: false,
columnWidth: '30%',
borderRadius: 2,
distributed: true
},
},
states: {
hover: {
filter: {
type: 'none'
}
},
active: {
filter: {
type: 'none'
}
}
},
fill: {
opacity: 1
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return val.toFixed(2);
},
offsetY: -20,
style: {
fontSize: '14px',
fontWeight: 'bold',
colors: [theme === 'dark' ? '#fff' : '#000']
}
},
xaxis: {
categories: [],
title: {
text: 'Jenis Kelamin',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
}
},
yaxis: {
min: 0,
max: 4.0,
tickAmount: 4,
title: {
text: 'Rata-rata IPK',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
}
},
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val.toFixed(2) + " IPK";
}
},
},
legend: {
show: false
}
});
// Update theme when it changes
useEffect(() => {
const currentTheme = theme === 'system' ? systemTheme : theme;
setOptions(prev => ({
...prev,
chart: {
...prev.chart,
background: currentTheme === 'dark' ? '#0F172B' : '#fff',
},
dataLabels: {
...prev.dataLabels,
style: {
...prev.dataLabels?.style,
colors: [currentTheme === 'dark' ? '#fff' : '#000']
}
},
xaxis: {
...prev.xaxis,
title: {
...prev.xaxis?.title,
style: {
...prev.xaxis?.title?.style,
color: currentTheme === 'dark' ? '#fff' : '#000'
}
},
labels: {
...prev.xaxis?.labels,
style: {
colors: currentTheme === 'dark' ? '#fff' : '#000'
}
}
},
yaxis: {
...prev.yaxis,
title: {
...(prev.yaxis as any)?.title,
style: {
...(prev.yaxis as any)?.title?.style,
color: currentTheme === 'dark' ? '#fff' : '#000'
}
},
labels: {
...(prev.yaxis as any)?.labels,
style: {
colors: currentTheme === 'dark' ? '#fff' : '#000'
}
}
},
tooltip: {
...prev.tooltip,
theme: currentTheme === 'dark' ? 'dark' : 'light'
}
}));
}, [theme, systemTheme]);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
const fetchData = async () => {
if (!tahunAngkatan || tahunAngkatan === 'all') {
console.log('Tahun angkatan tidak tersedia atau "all"');
return;
}
console.log('Fetching data for tahun angkatan:', tahunAngkatan);
const url = `/api/mahasiswa/ipk-jenis-kelamin?tahun_angkatan=${tahunAngkatan}`;
console.log('API URL:', url);
const response = await fetch(url);
if (!response.ok) {
console.error('Error response:', response.status, response.statusText);
return;
}
const data = await response.json();
console.log('Data received from API:', data);
if (!data || data.length === 0) {
console.log('No data received from API');
return;
}
// Process data for chart
const labels = data.map((item: any) => {
console.log('Processing item:', item);
return item.jk === 'Pria' ? 'Laki-laki' : 'Perempuan';
});
const values = data.map((item: any) => {
const ipk = parseFloat(item.rata_rata_ipk);
console.log(`IPK for ${item.jk}:`, ipk);
return ipk;
});
console.log('Processed labels:', labels);
console.log('Processed values:', values);
if (values.length === 0) {
console.log('No values to display');
return;
}
setCategories(labels);
// Untuk bar chart, kita memerlukan array dari objects
setSeries([{
name: 'Rata-rata IPK',
data: values
}]);
setOptions(prev => ({
...prev,
xaxis: {
...prev.xaxis,
categories: labels
},
chart: {
...prev.chart,
toolbar: {
...prev.chart?.toolbar,
export: {
...prev.chart?.toolbar?.export,
csv: {
...prev.chart?.toolbar?.export?.csv,
filename: `ipk-jenis-kelamin-angkatan-${tahunAngkatan}`
}
}
}
}
}));
};
if (mounted) {
fetchData();
}
}, [tahunAngkatan, mounted]);
if (!mounted) {
return null;
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Rata-rata IPK Berdasarkan Jenis Kelamin Angkatan {tahunAngkatan}
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[400px] w-full">
{series.length > 0 && series[0].data?.length > 0 ? (
<Chart
options={{
...options,
xaxis: {
...options.xaxis,
categories: categories
}
}}
series={series}
type="bar"
height="100%"
width="90%"
/>
) : (
<div className="flex items-center justify-center h-full">
<p className="text-gray-500 dark:text-gray-400">Tidak ada data</p>
</div>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,337 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface IPKLulusTepatData {
tahun_angkatan: number;
rata_rata_ipk: number;
}
interface IPKLulusTepatChartProps {
selectedYear: string;
}
export default function IPKLulusTepatChart({ selectedYear }: IPKLulusTepatChartProps) {
const { theme, systemTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<IPKLulusTepatData[]>([]);
const [series, setSeries] = useState<ApexAxisChartSeries>([{
name: 'Rata-rata IPK',
data: []
}]);
const [options, setOptions] = useState<ApexOptions>({
chart: {
type: 'line',
toolbar: {
show: true,
tools: {
download: true,
selection: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true
}
},
background: theme === 'dark' ? '#0F172B' : '#fff',
zoom: {
enabled: true,
type: 'x',
autoScaleYaxis: true
}
},
stroke: {
curve: 'smooth',
width: 3,
lineCap: 'round'
},
markers: {
size: 5,
strokeWidth: 2,
strokeColors: theme === 'dark' ? ['#fff'] : ['#3B82F6'],
colors: ['#3B82F6'],
hover: {
size: 7
}
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return val.toFixed(2);
},
style: {
fontSize: '14px',
fontWeight: 'bold'
},
background: {
enabled: false
},
offsetY: -10
},
xaxis: {
categories: [],
title: {
text: 'Tahun Angkatan',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
},
axisBorder: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
},
axisTicks: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
}
},
yaxis: {
title: {
text: 'Rata-rata IPK',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
},
formatter: function (val: number) {
return val.toFixed(2);
}
},
min: 0,
max: 4,
axisBorder: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
}
},
grid: {
borderColor: theme === 'dark' ? '#374151' : '#E5E7EB',
strokeDashArray: 4,
padding: {
top: 20,
right: 0,
bottom: 0,
left: 0
}
},
colors: ['#3B82F6'], // Blue color for line
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val.toFixed(2);
}
},
marker: {
show: true
}
},
legend: {
show: true,
position: 'top',
horizontalAlign: 'right',
labels: {
colors: theme === 'dark' ? '#fff' : '#000'
}
}
});
// Update theme when it changes
useEffect(() => {
const currentTheme = theme === 'system' ? systemTheme : theme;
setOptions(prev => ({
...prev,
chart: {
...prev.chart,
background: currentTheme === 'dark' ? '#0F172B' : '#fff',
},
dataLabels: {
...prev.dataLabels,
style: {
...prev.dataLabels?.style,
colors: [currentTheme === 'dark' ? '#fff' : '#000']
},
background: {
...prev.dataLabels?.background,
foreColor: currentTheme === 'dark' ? '#fff' : '#000',
borderColor: currentTheme === 'dark' ? '#374151' : '#3B82F6'
}
},
xaxis: {
...prev.xaxis,
title: {
...prev.xaxis?.title,
style: {
...prev.xaxis?.title?.style,
color: currentTheme === 'dark' ? '#fff' : '#000'
}
},
labels: {
...prev.xaxis?.labels,
style: {
...prev.xaxis?.labels?.style,
colors: currentTheme === 'dark' ? '#fff' : '#000'
}
},
axisBorder: {
...prev.xaxis?.axisBorder,
color: currentTheme === 'dark' ? '#374151' : '#E5E7EB'
},
axisTicks: {
...prev.xaxis?.axisTicks,
color: currentTheme === 'dark' ? '#374151' : '#E5E7EB'
}
},
yaxis: {
...prev.yaxis,
title: {
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title : prev.yaxis?.title),
style: {
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title?.style : prev.yaxis?.title?.style),
color: currentTheme === 'dark' ? '#fff' : '#000'
}
},
labels: {
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels : prev.yaxis?.labels),
style: {
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels?.style : prev.yaxis?.labels?.style),
colors: currentTheme === 'dark' ? '#fff' : '#000'
}
}
},
tooltip: {
...prev.tooltip,
theme: currentTheme === 'dark' ? 'dark' : 'light'
}
}));
}, [theme, systemTheme]);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(`/api/mahasiswa/ipk-lulus-tepat?tahunAngkatan=${selectedYear}`);
if (!response.ok) {
throw new Error('Failed to fetch data');
}
const fetchedData: IPKLulusTepatData[] = await response.json();
setData(fetchedData);
// Process data for chart
const tahunAngkatan = fetchedData.map(item => item.tahun_angkatan);
const rataRataIPK = fetchedData.map(item => item.rata_rata_ipk);
setSeries([{
name: 'Rata-rata IPK',
data: rataRataIPK
}]);
setOptions(prev => ({
...prev,
xaxis: {
...prev.xaxis,
categories: tahunAngkatan,
},
}));
} catch (error) {
setError(error instanceof Error ? error.message : 'An error occurred');
console.error('Error fetching data:', error);
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedYear]);
if (!mounted) {
return null;
}
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500 dark:text-red-400">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Rata-rata IPK Mahasiswa Lulus Tepat Waktu
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px] sm:h-[350px] md:h-[400px] w-full max-w-5xl mx-auto">
<Chart
options={options}
series={series}
type="line"
height="100%"
width="90%"
/>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,347 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface IPKPrestasiData {
tahun_angkatan: number;
total_mahasiswa_prestasi: number;
rata_rata_ipk: number;
}
interface Props {
selectedJenisPrestasi: string;
}
export default function IPKPrestasiChart({ selectedJenisPrestasi }: Props) {
const { theme, systemTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<IPKPrestasiData[]>([]);
const [series, setSeries] = useState<ApexAxisChartSeries>([{
name: 'Rata-rata IPK',
data: []
}]);
const [options, setOptions] = useState<ApexOptions>({
chart: {
type: 'line',
toolbar: {
show: true,
tools: {
download: true,
selection: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true
}
},
background: theme === 'dark' ? '#0F172B' : '#fff',
zoom: {
enabled: true,
type: 'x',
autoScaleYaxis: true
}
},
stroke: {
curve: 'smooth',
width: 3,
lineCap: 'round'
},
markers: {
size: 5,
strokeWidth: 2,
strokeColors: theme === 'dark' ? ['#fff'] : ['#3B82F6'],
colors: ['#3B82F6'],
hover: {
size: 7
}
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return val.toFixed(2);
},
style: {
fontSize: '14px',
fontWeight: 'bold'
},
background: {
enabled: false
},
offsetY: -10
},
xaxis: {
categories: [],
title: {
text: 'Tahun Angkatan',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
},
axisBorder: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
},
axisTicks: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
}
},
yaxis: {
title: {
text: 'Rata-rata IPK',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
},
formatter: function (val: number) {
return val.toFixed(2);
}
},
min: 0,
max: 4,
axisBorder: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
}
},
grid: {
borderColor: theme === 'dark' ? '#374151' : '#E5E7EB',
strokeDashArray: 4,
padding: {
top: 20,
right: 0,
bottom: 0,
left: 0
}
},
colors: ['#3B82F6'],
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val.toFixed(2);
}
},
marker: {
show: true
}
},
legend: {
show: true,
position: 'top',
horizontalAlign: 'right',
labels: {
colors: theme === 'dark' ? '#fff' : '#000'
}
}
});
// Update theme when it changes
useEffect(() => {
const currentTheme = theme === 'system' ? systemTheme : theme;
setOptions(prev => ({
...prev,
chart: {
...prev.chart,
background: currentTheme === 'dark' ? '#0F172B' : '#fff',
},
dataLabels: {
...prev.dataLabels,
style: {
...prev.dataLabels?.style,
colors: [currentTheme === 'dark' ? '#fff' : '#000']
},
background: {
...prev.dataLabels?.background,
foreColor: currentTheme === 'dark' ? '#fff' : '#000',
borderColor: currentTheme === 'dark' ? '#374151' : '#3B82F6'
}
},
xaxis: {
...prev.xaxis,
title: {
...prev.xaxis?.title,
style: {
...prev.xaxis?.title?.style,
color: currentTheme === 'dark' ? '#fff' : '#000'
}
},
labels: {
...prev.xaxis?.labels,
style: {
...prev.xaxis?.labels?.style,
colors: currentTheme === 'dark' ? '#fff' : '#000'
}
},
axisBorder: {
...prev.xaxis?.axisBorder,
color: currentTheme === 'dark' ? '#374151' : '#E5E7EB'
},
axisTicks: {
...prev.xaxis?.axisTicks,
color: currentTheme === 'dark' ? '#374151' : '#E5E7EB'
}
},
yaxis: {
...prev.yaxis,
title: {
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title : prev.yaxis?.title),
style: {
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title?.style : prev.yaxis?.title?.style),
color: currentTheme === 'dark' ? '#fff' : '#000'
}
},
labels: {
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels : prev.yaxis?.labels),
style: {
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels?.style : prev.yaxis?.labels?.style),
colors: currentTheme === 'dark' ? '#fff' : '#000'
}
}
},
tooltip: {
...prev.tooltip,
theme: currentTheme === 'dark' ? 'dark' : 'light'
}
}));
}, [theme, systemTheme]);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(`/api/mahasiswa/ipk-prestasi?jenisPrestasi=${selectedJenisPrestasi}`);
if (!response.ok) {
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
}
const result = await response.json();
if (!Array.isArray(result)) {
throw new Error('Invalid data format received from server');
}
setData(result);
// Sort data by tahun_angkatan in ascending order
const sortedData = [...result].sort((a, b) => a.tahun_angkatan - b.tahun_angkatan);
// Process data for chart
const tahunAngkatan = sortedData.map(item => item.tahun_angkatan);
const rataRataIPK = sortedData.map(item => item.rata_rata_ipk);
setSeries([{
name: 'Rata-rata IPK',
data: rataRataIPK
}]);
setOptions(prev => ({
...prev,
xaxis: {
...prev.xaxis,
categories: tahunAngkatan,
},
}));
} catch (err) {
console.error('Error in fetchData:', err);
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedJenisPrestasi]);
if (!mounted) {
return null;
}
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500 dark:text-red-400">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Rata-rata IPK Mahasiswa Prestasi {selectedJenisPrestasi}
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px] sm:h-[350px] md:h-[400px] w-full max-w-5xl mx-auto">
<Chart
options={options}
series={series}
type="line"
height="100%"
width="90%"
/>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,295 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface IpkStatusData {
tahun_angkatan: number;
status_kuliah: string;
total_mahasiswa: number;
rata_rata_ipk: number;
}
interface Props {
selectedYear: string;
selectedStatus: string;
}
export default function IpkStatusChart({ selectedYear, selectedStatus }: Props) {
const { theme, systemTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const [data, setData] = useState<IpkStatusData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const url = `/api/mahasiswa/ipk-status?tahun_angkatan=${selectedYear}&status_kuliah=${selectedStatus}`;
const response = await fetch(url);
if (!response.ok) {
const errorText = await response.text();
console.error('Error response:', errorText);
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
}
const result = await response.json();
console.log('Received data:', result);
if (!Array.isArray(result)) {
console.error('Invalid data format:', result);
throw new Error('Invalid data format received from server');
}
// Sort data by tahun_angkatan
const sortedData = result.sort((a: IpkStatusData, b: IpkStatusData) =>
a.tahun_angkatan - b.tahun_angkatan
);
console.log('Sorted data:', sortedData);
setData(sortedData);
} catch (err) {
console.error('Error in fetchData:', err);
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedYear, selectedStatus]);
// Log data changes
useEffect(() => {
console.log('Current data state:', data);
}, [data]);
const chartOptions: ApexOptions = {
chart: {
type: selectedYear === 'all' ? 'line' : 'bar',
toolbar: {
show: true,
tools: {
download: true,
selection: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true
}
},
background: theme === 'dark' ? '#0F172B' : '#fff',
zoom: {
enabled: true,
type: 'x',
autoScaleYaxis: true
}
},
plotOptions: {
bar: {
horizontal: false,
columnWidth: '55%',
borderRadius: 4,
},
},
stroke: {
curve: 'smooth',
width: 3,
lineCap: 'round'
},
markers: {
size: 5,
strokeWidth: 2,
strokeColors: theme === 'dark' ? ['#fff'] : ['#3B82F6'],
colors: ['#3B82F6'],
hover: {
size: 7
}
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return val.toFixed(2);
},
style: {
fontSize: '14px',
fontWeight: 'bold',
colors: [theme === 'dark' ? '#fff' : '#000']
},
background: {
enabled: false
},
offsetY: -10
},
xaxis: {
categories: data.map(item => item.tahun_angkatan.toString()),
title: {
text: 'Tahun Angkatan',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
},
axisBorder: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
},
axisTicks: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
}
},
yaxis: {
title: {
text: 'Rata-rata IPK',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
min: 0,
max: 4,
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
},
formatter: function (val: number) {
return val.toFixed(2);
}
},
axisBorder: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
}
},
grid: {
borderColor: theme === 'dark' ? '#374151' : '#E5E7EB',
strokeDashArray: 4,
padding: {
top: 20,
right: 0,
bottom: 0,
left: 0
}
},
colors: ['#3B82F6'],
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val.toFixed(2);
}
},
marker: {
show: true
}
},
legend: {
show: true,
position: 'top',
horizontalAlign: 'right',
labels: {
colors: theme === 'dark' ? '#fff' : '#000'
}
}
};
// Process data for series
const processSeriesData = () => {
return [{
name: 'Rata-rata IPK',
data: data.map(item => item.rata_rata_ipk)
}];
};
const series = processSeriesData();
if (!mounted) {
return null;
}
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Rata-rata IPK Mahasiswa {selectedStatus}
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px] sm:h-[350px] md:h-[400px] w-full max-w-5xl mx-auto">
{typeof window !== 'undefined' && (
<Chart
options={chartOptions}
series={series}
type={selectedYear === 'all' ? 'line' : 'bar'}
height="100%"
width="90%"
/>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,265 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface JenisPendaftaranBeasiswaData {
tahun_angkatan: number;
jenis_pendaftaran: string;
jumlah_mahasiswa_beasiswa: number;
}
interface Props {
selectedYear: string;
selectedJenisBeasiswa: string;
}
export default function JenisPendaftaranBeasiswaChart({ selectedYear, selectedJenisBeasiswa }: Props) {
const { theme } = useTheme();
const [data, setData] = useState<JenisPendaftaranBeasiswaData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const url = `/api/mahasiswa/jenis-pendaftaran-beasiswa?tahunAngkatan=${selectedYear}&jenisBeasiswa=${selectedJenisBeasiswa}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
}
const result = await response.json();
if (!Array.isArray(result)) {
throw new Error('Invalid data format received from server');
}
// Sort data by tahun_angkatan
const sortedData = result.sort((a: JenisPendaftaranBeasiswaData, b: JenisPendaftaranBeasiswaData) =>
a.tahun_angkatan - b.tahun_angkatan
);
setData(sortedData);
} catch (err) {
console.error('Error in fetchData:', err);
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedYear, selectedJenisBeasiswa]);
// Process data for series
const processSeriesData = () => {
if (!data.length) return [];
const years = [...new Set(data.map(item => item.tahun_angkatan))].sort();
const pendaftaranTypes = [...new Set(data.map(item => item.jenis_pendaftaran))].sort();
return pendaftaranTypes.map(pendaftaran => ({
name: pendaftaran,
data: years.map(year => {
const item = data.find(d => d.tahun_angkatan === year && d.jenis_pendaftaran === pendaftaran);
return item ? item.jumlah_mahasiswa_beasiswa : 0;
})
}));
};
const chartOptions: ApexOptions = {
chart: {
type: 'bar',
stacked: false,
toolbar: {
show: true,
tools: {
download: true,
selection: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true
}
},
background: theme === 'dark' ? '#0F172B' : '#fff',
},
plotOptions: {
bar: {
horizontal: false,
columnWidth: '55%',
borderRadius: 1,
},
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return val.toString();
},
style: {
fontSize: '12px',
colors: [theme === 'dark' ? '#fff' : '#000']
}
},
stroke: {
show: true,
width: 2,
colors: ['transparent']
},
xaxis: {
categories: [...new Set(data.map(item => item.tahun_angkatan))].sort(),
title: {
text: 'Tahun Angkatan',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
},
axisBorder: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
},
axisTicks: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
}
},
yaxis: {
title: {
text: 'Jumlah Mahasiswa',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
},
axisBorder: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
}
},
fill: {
opacity: 1
},
colors: ['#3B82F6', '#EC4899', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#06B6D4'],
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val + " mahasiswa";
}
}
},
legend: {
position: 'top',
fontSize: '14px',
markers: {
size: 12,
},
itemMargin: {
horizontal: 10,
},
labels: {
colors: theme === 'dark' ? '#fff' : '#000'
}
},
grid: {
borderColor: theme === 'dark' ? '#374151' : '#E5E7EB',
strokeDashArray: 4,
padding: {
top: 20,
right: 0,
bottom: 0,
left: 0
}
}
};
const series = processSeriesData();
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Jenis Pendaftaran Mahasiswa Beasiswa {selectedJenisBeasiswa}
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px] sm:h-[350px] md:h-[400px] w-full max-w-5xl mx-auto">
{typeof window !== 'undefined' && series.length > 0 && (
<Chart
options={chartOptions}
series={series}
type="bar"
height="100%"
width="90%"
/>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,192 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface JenisPendaftaranBeasiswaData {
tahun_angkatan: number;
jenis_pendaftaran: string;
jumlah_mahasiswa_beasiswa: number;
}
interface Props {
selectedYear: string;
selectedJenisBeasiswa: string;
}
export default function JenisPendaftaranBeasiswaPieChart({ selectedYear, selectedJenisBeasiswa }: Props) {
const { theme } = useTheme();
const [data, setData] = useState<JenisPendaftaranBeasiswaData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const url = `/api/mahasiswa/jenis-pendaftaran-beasiswa?tahunAngkatan=${selectedYear}&jenisBeasiswa=${selectedJenisBeasiswa}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
}
const result = await response.json();
setData(result);
} catch (err) {
console.error('Error in fetchData:', err);
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedYear, selectedJenisBeasiswa]);
const chartOptions: ApexOptions = {
chart: {
type: 'pie',
background: theme === 'dark' ? '#0F172B' : '#fff',
toolbar: {
show: true,
tools: {
download: true,
selection: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true
}
}
},
labels: [],
colors: ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4'],
legend: {
position: 'bottom',
fontSize: '14px',
markers: {
size: 12,
strokeWidth: 0
},
itemMargin: {
horizontal: 10
},
labels: {
colors: theme === 'dark' ? '#fff' : '#000'
}
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return `${val.toFixed(0)}%`;
},
style: {
fontSize: '14px',
fontFamily: 'Inter, sans-serif',
fontWeight: '500'
},
offsetY: 0,
dropShadow: {
enabled: false
}
},
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val + " mahasiswa"
}
}
}
};
// Process data for series
const processSeriesData = () => {
const jenisPendaftaran = [...new Set(data.map(item => item.jenis_pendaftaran))].sort();
const jumlahData = jenisPendaftaran.map(jenis => {
const item = data.find(d => d.jenis_pendaftaran === jenis);
return item ? item.jumlah_mahasiswa_beasiswa : 0;
});
return {
series: jumlahData,
labels: jenisPendaftaran
};
};
const { series, labels } = processSeriesData();
const totalMahasiswa = data.reduce((sum, item) => sum + item.jumlah_mahasiswa_beasiswa, 0);
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Jenis Pendaftaran Mahasiswa Beasiswa {selectedJenisBeasiswa}
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
</CardTitle>
<div className="text-lg font-semibold text-gray-600 dark:text-gray-300">
Total Mahasiswa: {totalMahasiswa}
</div>
</CardHeader>
<CardContent>
<div className="h-[350px] w-full max-w-5xl mx-auto">
{typeof window !== 'undefined' && (
<Chart
options={{...chartOptions, labels}}
series={series}
type="pie"
height="100%"
width="90%"
/>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,293 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface JenisPendaftaranData {
tahun_angkatan: number;
jenis_pendaftaran: string;
jumlah: number;
}
export default function JenisPendaftaranChart() {
const { theme, systemTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<JenisPendaftaranData[]>([]);
const [series, setSeries] = useState<ApexAxisChartSeries>([]);
const [options, setOptions] = useState<ApexOptions>({
chart: {
type: 'bar',
stacked: false,
toolbar: {
show: true,
},
background: theme === 'dark' ? '#0F172B' : '#fff',
},
plotOptions: {
bar: {
horizontal: true,
columnWidth: '55%',
},
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return val.toString();
},
style: {
fontSize: '12px',
colors: [theme === 'dark' ? '#fff' : '#000']
}
},
stroke: {
show: true,
width: 2,
colors: ['transparent'],
},
xaxis: {
categories: [],
title: {
text: 'Jumlah Mahasiswa',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
}
},
yaxis: {
title: {
text: 'Tahun Angkatan',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
}
},
fill: {
opacity: 1,
},
legend: {
position: 'top',
fontSize: '14px',
markers: {
size: 12,
},
itemMargin: {
horizontal: 10,
},
labels: {
colors: theme === 'dark' ? '#fff' : '#000'
}
},
colors: ['#3B82F6', '#10B981', '#F59E0B', '#EF4444'],
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val + " mahasiswa";
}
}
}
});
// Update theme when it changes
useEffect(() => {
const currentTheme = theme === 'system' ? systemTheme : theme;
setOptions(prev => ({
...prev,
chart: {
...prev.chart,
background: currentTheme === 'dark' ? '#0F172B' : '#fff',
},
dataLabels: {
...prev.dataLabels,
style: {
...prev.dataLabels?.style,
colors: [currentTheme === 'dark' ? '#fff' : '#000']
}
},
xaxis: {
...prev.xaxis,
title: {
...prev.xaxis?.title,
style: {
...prev.xaxis?.title?.style,
color: currentTheme === 'dark' ? '#fff' : '#000'
}
},
labels: {
...prev.xaxis?.labels,
style: {
...prev.xaxis?.labels?.style,
colors: currentTheme === 'dark' ? '#fff' : '#000'
}
}
},
yaxis: {
...prev.yaxis,
title: {
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title : prev.yaxis?.title),
style: {
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.title?.style : prev.yaxis?.title?.style),
color: currentTheme === 'dark' ? '#fff' : '#000'
}
},
labels: {
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels : prev.yaxis?.labels),
style: {
...(Array.isArray(prev.yaxis) ? prev.yaxis[0]?.labels?.style : prev.yaxis?.labels?.style),
colors: currentTheme === 'dark' ? '#fff' : '#000'
}
}
},
legend: {
...prev.legend,
labels: {
...prev.legend?.labels,
colors: currentTheme === 'dark' ? '#fff' : '#000'
}
},
tooltip: {
...prev.tooltip,
theme: currentTheme === 'dark' ? 'dark' : 'light'
}
}));
}, [theme, systemTheme]);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch('/api/mahasiswa/jenis-pendaftaran');
if (!response.ok) {
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
}
const result = await response.json();
if (!Array.isArray(result)) {
throw new Error('Invalid data format received from server');
}
setData(result);
const tahunAngkatan = [...new Set(result.map(item => item.tahun_angkatan))]
.sort((a, b) => b - a);
const jenisPendaftaran = [...new Set(result.map(item => item.jenis_pendaftaran))].sort();
const seriesData = jenisPendaftaran.map(jenis => ({
name: jenis,
data: tahunAngkatan.map(tahun => {
const item = result.find(d => d.tahun_angkatan === tahun && d.jenis_pendaftaran === jenis);
return item ? item.jumlah : 0;
}),
}));
setSeries(seriesData);
setOptions(prev => ({
...prev,
xaxis: {
...prev.xaxis,
categories: tahunAngkatan,
},
}));
} catch (err) {
console.error('Error in fetchData:', err);
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
} finally {
setLoading(false);
}
};
fetchData();
}, []);
if (!mounted) {
return null;
}
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500 dark:text-red-400">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Jenis Pendaftaran Mahasiswa
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[400px] w-full">
<Chart
options={options}
series={series}
type="bar"
height="100%"
width="90%"
/>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,262 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface JenisPendaftaranLulusData {
tahun_angkatan: number;
jenis_pendaftaran: string;
jumlah_lulus_tepat_waktu: number;
}
interface Props {
selectedYear: string;
}
export default function JenisPendaftaranLulusChart({ selectedYear }: Props) {
const { theme } = useTheme();
const [data, setData] = useState<JenisPendaftaranLulusData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const url = `/api/mahasiswa/jenis-pendaftaran-lulus?tahun_angkatan=${selectedYear}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
}
const result = await response.json();
if (!Array.isArray(result)) {
throw new Error('Invalid data format received from server');
}
// Sort data by tahun_angkatan
const sortedData = result.sort((a: JenisPendaftaranLulusData, b: JenisPendaftaranLulusData) =>
a.tahun_angkatan - b.tahun_angkatan
);
setData(sortedData);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedYear]);
// Process data for series
const processSeriesData = () => {
if (!data.length) return [];
const years = [...new Set(data.map(item => item.tahun_angkatan))].sort();
const pendaftaranTypes = [...new Set(data.map(item => item.jenis_pendaftaran))];
return pendaftaranTypes.map(type => ({
name: type,
data: years.map(year => {
const item = data.find(d => d.tahun_angkatan === year && d.jenis_pendaftaran === type);
return item ? item.jumlah_lulus_tepat_waktu : 0;
})
}));
};
const chartOptions: ApexOptions = {
chart: {
type: 'bar',
stacked: false,
toolbar: {
show: true,
tools: {
download: true,
selection: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true
}
},
background: theme === 'dark' ? '#0F172B' : '#fff',
},
plotOptions: {
bar: {
horizontal: false,
columnWidth: '55%',
borderRadius: 1,
},
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return val.toString();
},
style: {
fontSize: '12px',
colors: [theme === 'dark' ? '#fff' : '#000']
}
},
stroke: {
show: true,
width: 2,
colors: ['transparent']
},
xaxis: {
categories: [...new Set(data.map(item => item.tahun_angkatan))].sort(),
title: {
text: 'Tahun Angkatan',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
},
axisBorder: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
},
axisTicks: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
}
},
yaxis: {
title: {
text: 'Jumlah Mahasiswa',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
},
axisBorder: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
}
},
fill: {
opacity: 1
},
colors: ['#008FFB', '#00E396', '#FEB019', '#FF4560', '#775DD0'],
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val + " mahasiswa";
}
}
},
legend: {
position: 'top',
fontSize: '14px',
markers: {
size: 12,
},
itemMargin: {
horizontal: 10,
},
labels: {
colors: theme === 'dark' ? '#fff' : '#000'
}
},
grid: {
borderColor: theme === 'dark' ? '#374151' : '#E5E7EB',
strokeDashArray: 4,
padding: {
top: 20,
right: 0,
bottom: 0,
left: 0
}
}
};
const series = processSeriesData();
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Jenis Pendaftaran Mahasiswa Lulus Tepat Waktu
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px] sm:h-[350px] md:h-[400px] w-full max-w-5xl mx-auto">
{typeof window !== 'undefined' && series.length > 0 && (
<Chart
options={chartOptions}
series={series}
type="bar"
height="100%"
width="90%"
/>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,191 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface JenisPendaftaranLulusData {
tahun_angkatan: number;
jenis_pendaftaran: string;
jumlah_lulus_tepat_waktu: number;
}
interface Props {
selectedYear: string;
}
export default function JenisPendaftaranLulusPieChart({ selectedYear }: Props) {
const { theme } = useTheme();
const [data, setData] = useState<JenisPendaftaranLulusData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const url = `/api/mahasiswa/jenis-pendaftaran-lulus?tahun_angkatan=${selectedYear}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
}
const result = await response.json();
setData(result);
} catch (err) {
console.error('Error in fetchData:', err);
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedYear]);
const chartOptions: ApexOptions = {
chart: {
type: 'pie',
toolbar: {
show: true,
tools: {
download: true,
selection: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true
}
},
background: theme === 'dark' ? '#0F172B' : '#fff',
},
labels: [],
colors: ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'],
legend: {
position: 'bottom',
fontSize: '14px',
markers: {
size: 12,
strokeWidth: 0
},
itemMargin: {
horizontal: 10
},
labels: {
colors: theme === 'dark' ? '#fff' : '#000',
}
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return `${val.toFixed(0)}%`;
},
style: {
fontSize: '14px',
fontFamily: 'Inter, sans-serif',
fontWeight: '500'
},
offsetY: 0,
dropShadow: {
enabled: false
}
},
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val + " mahasiswa"
}
}
}
};
// Process data for series
const processSeriesData = () => {
const jenisPendaftaran = [...new Set(data.map(item => item.jenis_pendaftaran))].sort();
const jumlahData = jenisPendaftaran.map(jenis => {
const item = data.find(d => d.jenis_pendaftaran === jenis);
return item ? item.jumlah_lulus_tepat_waktu : 0;
});
return {
series: jumlahData,
labels: jenisPendaftaran
};
};
const { series, labels } = processSeriesData();
const totalMahasiswa = data.reduce((sum, item) => sum + item.jumlah_lulus_tepat_waktu, 0);
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Jenis Pendaftaran Mahasiswa Lulus Tepat Waktu
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
</CardTitle>
<div className="text-lg font-semibold text-gray-600 dark:text-gray-300">
Total Mahasiswa: {totalMahasiswa}
</div>
</CardHeader>
<CardContent>
<div className="h-[350px] w-full max-w-5xl mx-auto">
{typeof window !== 'undefined' && (
<Chart
options={{...chartOptions, labels}}
series={series}
type="pie"
height="100%"
width="90%"
/>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,250 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface JenisPendaftaranData {
tahun_angkatan: number;
jenis_pendaftaran: string;
jumlah: number;
}
interface Props {
tahunAngkatan: string;
}
export default function JenisPendaftaranPerAngkatanChart({ tahunAngkatan }: Props) {
const { theme, systemTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<JenisPendaftaranData[]>([]);
const [series, setSeries] = useState<number[]>([]);
const [options, setOptions] = useState<ApexOptions>({
chart: {
type: 'pie',
toolbar: {
show: true,
tools: {
download: true,
selection: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true
}
}
},
labels: [],
colors: ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'],
legend: {
position: 'bottom',
fontSize: '14px',
markers: {
size: 12,
strokeWidth: 0
},
itemMargin: {
horizontal: 10
},
labels: {
colors: theme === 'dark' ? '#fff' : '#000'
}
},
dataLabels: {
enabled: true,
formatter: function (val: number, opts: any) {
return `${val.toFixed(0)}%`;
},
style: {
fontSize: '14px',
fontFamily: 'Inter, sans-serif',
fontWeight: '500'
},
offsetY: 0,
dropShadow: {
enabled: false
}
},
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val + " mahasiswa"
}
}
}
});
// Update theme when it changes
useEffect(() => {
const currentTheme = theme === 'system' ? systemTheme : theme;
setOptions(prev => ({
...prev,
chart: {
...prev.chart,
background: currentTheme === 'dark' ? '#0F172B' : '#fff',
},
legend: {
...prev.legend,
labels: {
...prev.legend?.labels,
colors: currentTheme === 'dark' ? '#fff' : '#000'
}
},
tooltip: {
...prev.tooltip,
theme: currentTheme === 'dark' ? 'dark' : 'light'
}
}));
}, [theme, systemTheme]);
// Update dataLabels formatter when data changes
useEffect(() => {
if (data.length > 0) {
setOptions(prev => ({
...prev,
dataLabels: {
...prev.dataLabels,
formatter: function(val: number, opts: any) {
// Calculate the percentage based on the current data
const total = data.reduce((sum, item) => sum + item.jumlah, 0);
const seriesIndex = opts.seriesIndex;
const jenisPendaftaran = prev.labels?.[seriesIndex];
if (jenisPendaftaran) {
const item = data.find(d => d.jenis_pendaftaran === jenisPendaftaran);
const jumlah = item ? item.jumlah : 0;
const percentage = (jumlah / total) * 100;
return `${percentage.toFixed(0)}%`;
}
return `${val.toFixed(0)}%`;
}
}
}));
}
}, [data]);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(`/api/mahasiswa/jenis-pendaftaran?tahun_angkatan=${tahunAngkatan}`);
if (!response.ok) {
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
}
const result = await response.json();
if (!Array.isArray(result)) {
throw new Error('Invalid data format received from server');
}
// Process data for pie chart
const jenisPendaftaran = [...new Set(result.map(item => item.jenis_pendaftaran))].sort();
const jumlahData = jenisPendaftaran.map(jenis => {
const item = result.find(d => d.jenis_pendaftaran === jenis);
return item ? item.jumlah : 0;
});
setSeries(jumlahData);
setOptions(prev => ({
...prev,
labels: jenisPendaftaran,
}));
// Store processed data
setData(result);
} catch (err) {
console.error('Error in fetchData:', err);
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
} finally {
setLoading(false);
}
};
if (tahunAngkatan) {
fetchData();
}
}, [tahunAngkatan]);
if (!mounted) {
return null;
}
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500 dark:text-red-400">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
const totalMahasiswa = data.reduce((sum, item) => sum + item.jumlah, 0);
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Jenis Pendaftaran Angkatan {tahunAngkatan}
</CardTitle>
<div className="text-lg font-semibold text-gray-600 dark:text-gray-300">
Total Mahasiswa: {totalMahasiswa}
</div>
</CardHeader>
<CardContent>
<div className="h-[350px] w-full max-w-5xl mx-auto">
<Chart
options={options}
series={series}
type="pie"
height="100%"
width="90%"
/>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,263 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface JenisPendaftaranPrestasiData {
tahun_angkatan: number;
jenis_pendaftaran: string;
jenis_pendaftaran_mahasiswa_prestasi: number;
}
interface Props {
selectedJenisPrestasi: string;
}
export default function JenisPendaftaranPrestasiChart({ selectedJenisPrestasi }: Props) {
const { theme } = useTheme();
const [data, setData] = useState<JenisPendaftaranPrestasiData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const url = `/api/mahasiswa/jenis-pendaftaran-prestasi?jenisPrestasi=${selectedJenisPrestasi}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
}
const result = await response.json();
if (!Array.isArray(result)) {
throw new Error('Invalid data format received from server');
}
// Sort data by tahun_angkatan
const sortedData = result.sort((a: JenisPendaftaranPrestasiData, b: JenisPendaftaranPrestasiData) =>
a.tahun_angkatan - b.tahun_angkatan
);
setData(sortedData);
} catch (err) {
console.error('Error in fetchData:', err);
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedJenisPrestasi]);
// Process data for series
const processSeriesData = () => {
if (!data.length) return [];
const years = [...new Set(data.map(item => item.tahun_angkatan))].sort();
const pendaftaranTypes = [...new Set(data.map(item => item.jenis_pendaftaran))].sort();
return pendaftaranTypes.map(pendaftaran => ({
name: pendaftaran,
data: years.map(year => {
const item = data.find(d => d.tahun_angkatan === year && d.jenis_pendaftaran === pendaftaran);
return item?.jenis_pendaftaran_mahasiswa_prestasi || 0;
})
}));
};
const chartOptions: ApexOptions = {
chart: {
type: 'bar',
stacked: false,
toolbar: {
show: true,
tools: {
download: true,
selection: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true
}
},
background: theme === 'dark' ? '#0F172B' : '#fff',
},
plotOptions: {
bar: {
horizontal: false,
columnWidth: '55%',
borderRadius: 1,
},
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return val?.toString() || '0';
},
style: {
fontSize: '12px',
colors: [theme === 'dark' ? '#fff' : '#000']
}
},
stroke: {
show: true,
width: 2,
colors: ['transparent']
},
xaxis: {
categories: [...new Set(data.map(item => item.tahun_angkatan))].sort(),
title: {
text: 'Tahun Angkatan',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
},
axisBorder: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
},
axisTicks: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
}
},
yaxis: {
title: {
text: 'Jumlah Mahasiswa',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
},
axisBorder: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
}
},
fill: {
opacity: 1
},
colors: ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4'],
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val + " mahasiswa";
}
}
},
legend: {
position: 'top',
fontSize: '14px',
markers: {
size: 12,
},
itemMargin: {
horizontal: 10,
},
labels: {
colors: theme === 'dark' ? '#fff' : '#000'
}
},
grid: {
borderColor: theme === 'dark' ? '#374151' : '#E5E7EB',
strokeDashArray: 4,
padding: {
top: 20,
right: 0,
bottom: 0,
left: 0
}
}
};
const series = processSeriesData();
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Jenis Pendaftaran Mahasiswa Berprestasi {selectedJenisPrestasi}
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px] sm:h-[350px] md:h-[400px] w-full max-w-5xl mx-auto">
{typeof window !== 'undefined' && series.length > 0 && (
<Chart
options={chartOptions}
series={series}
type="bar"
height="100%"
width="90%"
/>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,188 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface JenisPendaftaranPrestasiData {
tahun_angkatan: number;
jenis_pendaftaran: string;
jenis_pendaftaran_mahasiswa_prestasi: number;
}
interface Props {
selectedYear: string;
selectedJenisPrestasi: string;
}
export default function JenisPendaftaranPrestasiPieChart({ selectedYear, selectedJenisPrestasi }: Props) {
const { theme } = useTheme();
const [data, setData] = useState<JenisPendaftaranPrestasiData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const url = `/api/mahasiswa/jenis-pendaftaran-prestasi?tahunAngkatan=${selectedYear}&jenisPrestasi=${selectedJenisPrestasi}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
}
const result = await response.json();
setData(result);
} catch (err) {
console.error('Error in fetchData:', err);
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedYear, selectedJenisPrestasi]);
const chartOptions: ApexOptions = {
chart: {
type: 'pie',
background: theme === 'dark' ? '#0F172B' : '#fff',
toolbar: {
show: true,
tools: {
download: true,
selection: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true
}
}
},
labels: [],
colors: ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4'],
legend: {
position: 'bottom',
fontSize: '14px',
markers: {
size: 12,
strokeWidth: 0
},
itemMargin: {
horizontal: 10
},
labels: {
colors: theme === 'dark' ? '#fff' : '#000'
}
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return `${val.toFixed(0)}%`;
},
style: {
fontSize: '14px',
fontFamily: 'Inter, sans-serif',
fontWeight: '500'
},
offsetY: 0,
dropShadow: {
enabled: false
}
},
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val + " mahasiswa"
}
}
}
};
// Process data for series
const processSeriesData = () => {
const filtered = data.filter(item => String(item.tahun_angkatan) === String(selectedYear));
const jenisPendaftaran = [...new Set(filtered.map(item => item.jenis_pendaftaran))].sort();
const jumlahData = jenisPendaftaran.map(jenis => {
const item = filtered.find(d => d.jenis_pendaftaran === jenis);
return item ? item.jenis_pendaftaran_mahasiswa_prestasi : 0;
});
return {
series: jumlahData,
labels: jenisPendaftaran
};
};
const { series, labels } = processSeriesData();
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Jenis Pendaftaran Mahasiswa Berprestasi {selectedJenisPrestasi} Angkatan {selectedYear}
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[350px] w-full max-w-5xl mx-auto">
{typeof window !== 'undefined' && (
<Chart
options={{...chartOptions, labels}}
series={series}
type="pie"
height="100%"
width="90%"
/>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,250 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface JenisPendaftaranStatusData {
jenis_pendaftaran: string;
tahun_angkatan: number;
status_kuliah: string;
total_mahasiswa: number;
}
interface Props {
selectedYear: string;
selectedStatus: string;
}
export default function JenisPendaftaranStatusChart({ selectedYear, selectedStatus }: Props) {
const { theme } = useTheme();
const [data, setData] = useState<JenisPendaftaranStatusData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
console.log('Fetching data with params:', { selectedYear, selectedStatus });
const response = await fetch(
`/api/mahasiswa/jenis-pendaftaran-status?tahun_angkatan=${selectedYear}&status_kuliah=${selectedStatus}`
);
if (!response.ok) {
throw new Error('Failed to fetch data');
}
const result = await response.json();
console.log('Received data:', result);
// Sort data by tahun_angkatan
const sortedData = result.sort((a: JenisPendaftaranStatusData, b: JenisPendaftaranStatusData) =>
Number(a.tahun_angkatan) - Number(b.tahun_angkatan)
);
setData(sortedData);
} catch (err) {
console.error('Error in fetchData:', err);
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedYear, selectedStatus]);
// Log data changes
useEffect(() => {
console.log('Current data state:', data);
}, [data]);
// Get unique years and sort them
const years = [...new Set(data.map(item => item.tahun_angkatan))].sort((a, b) => Number(a) - Number(b));
console.log('Sorted years:', years);
// Get unique jenis pendaftaran
const jenisPendaftaran = [...new Set(data.map(item => item.jenis_pendaftaran))];
console.log('Jenis pendaftaran:', jenisPendaftaran);
const chartOptions: ApexOptions = {
chart: {
type: 'bar',
stacked: false,
toolbar: {
show: true,
},
background: theme === 'dark' ? '#0F172B' : '#fff',
},
plotOptions: {
bar: {
horizontal: false,
columnWidth: '55%',
},
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return val.toString();
},
style: {
fontSize: '12px',
colors: [theme === 'dark' ? '#fff' : '#000']
}
},
stroke: {
show: true,
width: 2,
colors: ['transparent'],
},
xaxis: {
categories: years,
title: {
text: 'Tahun Angkatan',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
}
},
yaxis: {
title: {
text: 'Jumlah Mahasiswa',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
}
},
fill: {
opacity: 1,
},
legend: {
position: 'top',
fontSize: '14px',
markers: {
size: 12,
},
itemMargin: {
horizontal: 10,
},
labels: {
colors: theme === 'dark' ? '#fff' : '#000'
}
},
colors: ['#008FFB', '#00E396', '#FEB019', '#FF4560', '#775DD0'],
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val + " mahasiswa";
}
}
}
};
// Process data for series
const processSeriesData = () => {
return jenisPendaftaran.map(jenis => {
const seriesData = new Array(years.length).fill(0);
data.forEach(item => {
if (item.jenis_pendaftaran === jenis) {
const yearIndex = years.indexOf(item.tahun_angkatan);
if (yearIndex !== -1) {
seriesData[yearIndex] = item.total_mahasiswa;
}
}
});
return {
name: jenis,
data: seriesData
};
});
};
const series = processSeriesData();
console.log('Processed series data:', series);
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Jenis Pendaftaran Mahasiswa {selectedStatus}
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[350px] w-full max-w-5xl mx-auto">
{typeof window !== 'undefined' && (
<Chart
options={chartOptions}
series={series}
type="bar"
height="100%"
width="90%"
/>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,193 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface JenisPendaftaranStatusData {
jenis_pendaftaran: string;
tahun_angkatan: number;
status_kuliah: string;
total_mahasiswa: number;
}
interface Props {
selectedYear: string;
selectedStatus: string;
}
export default function JenisPendaftaranStatusPieChart({ selectedYear, selectedStatus }: Props) {
const { theme } = useTheme();
const [data, setData] = useState<JenisPendaftaranStatusData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const response = await fetch(
`/api/mahasiswa/jenis-pendaftaran-status?tahun_angkatan=${selectedYear}&status_kuliah=${selectedStatus}`
);
if (!response.ok) {
throw new Error('Failed to fetch data');
}
const result = await response.json();
setData(result);
} catch (err) {
console.error('Error in fetchData:', err);
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedYear, selectedStatus]);
const chartOptions: ApexOptions = {
chart: {
type: 'pie',
toolbar: {
show: true,
tools: {
download: true,
selection: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true
}
},
background: theme === 'dark' ? '#0F172B' : '#fff',
},
labels: [],
colors: ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'],
legend: {
position: 'bottom',
fontSize: '14px',
markers: {
size: 12,
strokeWidth: 0
},
itemMargin: {
horizontal: 10
},
labels: {
colors: theme === 'dark' ? '#fff' : '#000',
}
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return `${val.toFixed(0)}%`;
},
style: {
fontSize: '14px',
fontFamily: 'Inter, sans-serif',
fontWeight: '500'
},
offsetY: 0,
dropShadow: {
enabled: false
}
},
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val + " mahasiswa"
}
}
}
};
// Process data for series
const processSeriesData = () => {
const jenisPendaftaran = [...new Set(data.map(item => item.jenis_pendaftaran))].sort();
const jumlahData = jenisPendaftaran.map(jenis => {
const item = data.find(d => d.jenis_pendaftaran === jenis);
return item ? item.total_mahasiswa : 0;
});
return {
series: jumlahData,
labels: jenisPendaftaran
};
};
const { series, labels } = processSeriesData();
const totalMahasiswa = data.reduce((sum, item) => sum + item.total_mahasiswa, 0);
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Jenis Pendaftaran Mahasiswa {selectedStatus}
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
</CardTitle>
<div className="text-lg font-semibold text-gray-600 dark:text-gray-300">
Total Mahasiswa: {totalMahasiswa}
</div>
</CardHeader>
<CardContent>
<div className="h-[350px] w-full max-w-5xl mx-auto">
{typeof window !== 'undefined' && (
<Chart
options={{...chartOptions, labels}}
series={series}
type="pie"
height="100%"
width="90%"
/>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,280 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface LulusTepatWaktuData {
tahun_angkatan: number;
jk: string;
jumlah_lulus_tepat_waktu: number;
}
interface Props {
selectedYear: string;
}
export default function LulusTepatWaktuChart({ selectedYear }: Props) {
const { theme } = useTheme();
const [data, setData] = useState<LulusTepatWaktuData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const url = `/api/mahasiswa/lulus-tepat-waktu?tahun_angkatan=${selectedYear}`;
const response = await fetch(url);
if (!response.ok) {
const errorText = await response.text();
console.error('Error response:', errorText);
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
}
const result = await response.json();
if (!Array.isArray(result)) {
console.error('Invalid data format:', result);
throw new Error('Invalid data format received from server');
}
// Sort data by tahun_angkatan
const sortedData = result.sort((a: LulusTepatWaktuData, b: LulusTepatWaktuData) =>
a.tahun_angkatan - b.tahun_angkatan
);
console.log('Sorted data:', sortedData);
setData(sortedData);
} catch (err) {
console.error('Error in fetchData:', err);
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedYear]);
// Process data for series
const processSeriesData = () => {
if (!data.length) return [];
const years = [...new Set(data.map(item => item.tahun_angkatan))].sort();
const pria = years.map(year => {
const item = data.find(d => d.tahun_angkatan === year && d.jk === 'Pria');
return item ? item.jumlah_lulus_tepat_waktu : 0;
});
const wanita = years.map(year => {
const item = data.find(d => d.tahun_angkatan === year && d.jk === 'Wanita');
return item ? item.jumlah_lulus_tepat_waktu : 0;
});
return [
{
name: 'Laki-laki',
data: pria
},
{
name: 'Perempuan',
data: wanita
}
];
};
const chartOptions: ApexOptions = {
chart: {
type: 'bar',
stacked: false,
toolbar: {
show: true,
tools: {
download: true,
selection: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true
}
},
background: theme === 'dark' ? '#0F172B' : '#fff',
},
plotOptions: {
bar: {
horizontal: false,
columnWidth: '55%',
borderRadius: 1,
},
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return val.toString();
},
style: {
fontSize: '12px',
colors: [theme === 'dark' ? '#fff' : '#000']
}
},
stroke: {
show: true,
width: 2,
colors: ['transparent']
},
xaxis: {
categories: [...new Set(data.map(item => item.tahun_angkatan))].sort(),
title: {
text: 'Tahun Angkatan',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
},
axisBorder: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
},
axisTicks: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
}
},
yaxis: {
title: {
text: 'Jumlah Mahasiswa',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
},
axisBorder: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
}
},
fill: {
opacity: 1
},
colors: ['#3B82F6', '#EC4899'],
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val + " mahasiswa";
}
}
},
legend: {
position: 'top',
fontSize: '14px',
markers: {
size: 12,
},
itemMargin: {
horizontal: 10,
},
labels: {
colors: theme === 'dark' ? '#fff' : '#000'
}
},
grid: {
borderColor: theme === 'dark' ? '#374151' : '#E5E7EB',
strokeDashArray: 4,
padding: {
top: 20,
right: 0,
bottom: 0,
left: 0
}
}
};
const series = processSeriesData();
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Mahasiswa Lulus Tepat Waktu
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px] sm:h-[350px] md:h-[400px] w-full max-w-5xl mx-auto">
{typeof window !== 'undefined' && series.length > 0 && (
<Chart
options={chartOptions}
series={series}
type="bar"
height="100%"
width="90%"
/>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,184 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface LulusTepatWaktuData {
tahun_angkatan: number;
jk: string;
jumlah_lulus_tepat_waktu: number;
}
interface Props {
selectedYear: string;
}
export default function LulusTepatWaktuPieChart({ selectedYear }: Props) {
const { theme } = useTheme();
const [data, setData] = useState<LulusTepatWaktuData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const url = `/api/mahasiswa/lulus-tepat-waktu?tahun_angkatan=${selectedYear}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
}
const result = await response.json();
setData(result);
} catch (err) {
console.error('Error in fetchData:', err);
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedYear]);
const chartOptions: ApexOptions = {
chart: {
type: 'pie',
toolbar: {
show: true,
tools: {
download: true,
selection: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true
}
},
background: theme === 'dark' ? '#0F172B' : '#fff',
},
labels: ['Laki-laki', 'Perempuan'],
colors: ['#3B82F6', '#EC4899'],
legend: {
position: 'bottom',
fontSize: '14px',
markers: {
size: 12,
strokeWidth: 0
},
itemMargin: {
horizontal: 10
},
labels: {
colors: theme === 'dark' ? '#fff' : '#000',
}
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return `${val.toFixed(0)}%`;
},
style: {
fontSize: '14px',
fontFamily: 'Inter, sans-serif',
fontWeight: '500'
},
offsetY: 0,
dropShadow: {
enabled: false
}
},
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val + " mahasiswa"
}
}
}
};
// Process data for series
const processSeriesData = () => {
const maleData = data.find(item => item.jk === 'Pria')?.jumlah_lulus_tepat_waktu || 0;
const femaleData = data.find(item => item.jk === 'Wanita')?.jumlah_lulus_tepat_waktu || 0;
return [maleData, femaleData];
};
const series = processSeriesData();
const totalMahasiswa = data.reduce((sum, item) => sum + item.jumlah_lulus_tepat_waktu, 0);
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Mahasiswa Lulus Tepat Waktu
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
</CardTitle>
<div className="text-lg font-semibold text-gray-600 dark:text-gray-300">
Total Mahasiswa: {totalMahasiswa}
</div>
</CardHeader>
<CardContent>
<div className="h-[350px] w-full max-w-5xl mx-auto">
{typeof window !== 'undefined' && (
<Chart
options={chartOptions}
series={series}
type="pie"
height="100%"
width="90%"
/>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,265 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface NamaBeasiswaData {
tahun_angkatan: number;
nama_beasiswa: string;
jumlah_nama_beasiswa: number;
}
interface Props {
selectedYear: string;
selectedJenisBeasiswa: string;
}
export default function NamaBeasiswaChart({ selectedYear, selectedJenisBeasiswa }: Props) {
const { theme } = useTheme();
const [data, setData] = useState<NamaBeasiswaData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const url = `/api/mahasiswa/nama-beasiswa?tahunAngkatan=${selectedYear}&jenisBeasiswa=${selectedJenisBeasiswa}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
}
const result = await response.json();
if (!Array.isArray(result)) {
throw new Error('Invalid data format received from server');
}
// Sort data by tahun_angkatan
const sortedData = result.sort((a: NamaBeasiswaData, b: NamaBeasiswaData) =>
a.tahun_angkatan - b.tahun_angkatan
);
setData(sortedData);
} catch (err) {
console.error('Error in fetchData:', err);
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedYear, selectedJenisBeasiswa]);
// Process data for series
const processSeriesData = () => {
if (!data.length) return [];
const years = [...new Set(data.map(item => item.tahun_angkatan))].sort();
const beasiswaTypes = [...new Set(data.map(item => item.nama_beasiswa))].sort();
return beasiswaTypes.map(beasiswa => ({
name: beasiswa,
data: years.map(year => {
const item = data.find(d => d.tahun_angkatan === year && d.nama_beasiswa === beasiswa);
return item ? item.jumlah_nama_beasiswa : 0;
})
}));
};
const chartOptions: ApexOptions = {
chart: {
type: 'bar',
stacked: false,
toolbar: {
show: true,
tools: {
download: true,
selection: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true
}
},
background: theme === 'dark' ? '#0F172B' : '#fff',
},
plotOptions: {
bar: {
horizontal: false,
columnWidth: '55%',
borderRadius: 1,
},
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return val.toString();
},
style: {
fontSize: '12px',
colors: [theme === 'dark' ? '#fff' : '#000']
}
},
stroke: {
show: true,
width: 2,
colors: ['transparent']
},
xaxis: {
categories: [...new Set(data.map(item => item.tahun_angkatan))].sort(),
title: {
text: 'Tahun Angkatan',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
},
axisBorder: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
},
axisTicks: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
}
},
yaxis: {
title: {
text: 'Jumlah Mahasiswa',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
},
axisBorder: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
}
},
fill: {
opacity: 1
},
colors: ['#3B82F6', '#EC4899', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#06B6D4'],
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val + " mahasiswa";
}
}
},
legend: {
position: 'top',
fontSize: '14px',
markers: {
size: 12,
},
itemMargin: {
horizontal: 10,
},
labels: {
colors: theme === 'dark' ? '#fff' : '#000'
}
},
grid: {
borderColor: theme === 'dark' ? '#374151' : '#E5E7EB',
strokeDashArray: 4,
padding: {
top: 20,
right: 0,
bottom: 0,
left: 0
}
}
};
const series = processSeriesData();
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Nama Beasiswa {selectedJenisBeasiswa}
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[400px] sm:h-[350px] md:h-[400px] w-full max-w-5xl mx-auto">
{typeof window !== 'undefined' && series.length > 0 && (
<Chart
options={chartOptions}
series={series}
type="bar"
height="100%"
width="90%"
/>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,192 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface NamaBeasiswaData {
tahun_angkatan: number;
nama_beasiswa: string;
jumlah_nama_beasiswa: number;
}
interface Props {
selectedYear: string;
selectedJenisBeasiswa: string;
}
export default function NamaBeasiswaPieChart({ selectedYear, selectedJenisBeasiswa }: Props) {
const { theme } = useTheme();
const [data, setData] = useState<NamaBeasiswaData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const url = `/api/mahasiswa/nama-beasiswa?tahunAngkatan=${selectedYear}&jenisBeasiswa=${selectedJenisBeasiswa}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
}
const result = await response.json();
setData(result);
} catch (err) {
console.error('Error in fetchData:', err);
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedYear, selectedJenisBeasiswa]);
const chartOptions: ApexOptions = {
chart: {
type: 'pie',
background: theme === 'dark' ? '#0F172B' : '#fff',
toolbar: {
show: true,
tools: {
download: true,
selection: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true
}
}
},
labels: [],
colors: ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4'],
legend: {
position: 'bottom',
fontSize: '14px',
markers: {
size: 12,
strokeWidth: 0
},
itemMargin: {
horizontal: 10
},
labels: {
colors: theme === 'dark' ? '#fff' : '#000',
}
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return `${val.toFixed(0)}%`;
},
style: {
fontSize: '14px',
fontFamily: 'Inter, sans-serif',
fontWeight: '500'
},
offsetY: 0,
dropShadow: {
enabled: false
}
},
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val + " mahasiswa"
}
}
}
};
// Process data for series
const processSeriesData = () => {
const namaBeasiswa = [...new Set(data.map(item => item.nama_beasiswa))].sort();
const jumlahData = namaBeasiswa.map(nama => {
const item = data.find(d => d.nama_beasiswa === nama);
return item ? item.jumlah_nama_beasiswa : 0;
});
return {
series: jumlahData,
labels: namaBeasiswa
};
};
const { series, labels } = processSeriesData();
const totalMahasiswa = data.reduce((sum, item) => sum + item.jumlah_nama_beasiswa, 0);
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Nama Beasiswa {selectedJenisBeasiswa}
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
</CardTitle>
<div className="text-lg font-semibold text-gray-600 dark:text-gray-300">
Total Mahasiswa: {totalMahasiswa}
</div>
</CardHeader>
<CardContent>
<div className="h-[350px] w-full max-w-5xl mx-auto">
{typeof window !== 'undefined' && (
<Chart
options={{...chartOptions, labels}}
series={series}
type="pie"
height="100%"
width="90%"
/>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,318 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useTheme } from "next-themes";
// Import ApexCharts secara dinamis untuk menghindari error SSR
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface MahasiswaStatistik {
tahun_angkatan: number;
total_mahasiswa: number;
pria: number;
wanita: number;
}
export default function StatistikMahasiswaChart() {
const { theme, systemTheme } = useTheme();
const [statistikData, setStatistikData] = useState<MahasiswaStatistik[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [chartOptions, setChartOptions] = useState({
chart: {
type: 'bar' as const,
stacked: false,
toolbar: {
show: true,
tools: {
download: true,
selection: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true
},
},
zoom: {
enabled: true,
type: 'x' as const,
autoScaleYaxis: true
},
},
markers: {
size: 10,
shape: 'circle' as const,
strokeWidth: 0,
hover: {
size: 10
}
},
plotOptions: {
bar: {
horizontal: false,
columnWidth: '55%',
},
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return val.toString()
},
position: 'top',
style: {
fontSize: '12px',
colors: ['#000']
},
},
stroke: {
show: true,
width: [0, 0, 2],
colors: ['transparent', 'transparent', '#10B981'],
curve: 'straight' as const
},
xaxis: {
categories: [] as number[],
title: {
text: 'Tahun Angkatan',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: '#000'
}
}
},
yaxis: [
{
title: {
text: 'Jumlah Mahasiswa',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: '#000'
}
}
}
],
fill: {
opacity: 1
},
legend: {
position: 'top' as const,
fontSize: '14px',
markers: {
size: 12,
},
itemMargin: {
horizontal: 10,
},
labels: {
colors: '#000'
}
},
colors: ['#3B82F6', '#EC4899', '#10B981'],
tooltip: {
theme: 'light',
y: [
{
formatter: function (val: number) {
return val + " mahasiswa"
}
},
{
formatter: function (val: number) {
return val + " mahasiswa"
}
},
{
formatter: function (val: number) {
return val + " mahasiswa"
}
}
]
}
});
useEffect(() => {
const fetchData = async () => {
try {
const statistikResponse = await fetch('/api/mahasiswa/statistik', {
cache: 'no-store',
});
if (!statistikResponse.ok) {
throw new Error('Failed to fetch statistik data');
}
const statistikData = await statistikResponse.json();
setStatistikData(statistikData);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
console.error('Error fetching data:', err);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
// Update theme when it changes
useEffect(() => {
const currentTheme = theme === 'system' ? systemTheme : theme;
const textColor = currentTheme === 'dark' ? '#fff' : '#000';
const tooltipTheme = currentTheme === 'dark' ? 'dark' : 'light';
setChartOptions(prev => ({
...prev,
chart: {
...prev.chart,
background: currentTheme === 'dark' ? '#0F172B' : '#fff',
},
dataLabels: {
...prev.dataLabels,
style: {
...prev.dataLabels.style,
colors: [textColor]
},
},
xaxis: {
...prev.xaxis,
title: {
...prev.xaxis.title,
style: {
...prev.xaxis.title.style,
color: textColor
}
},
labels: {
...prev.xaxis.labels,
style: {
...prev.xaxis.labels.style,
colors: textColor
}
}
},
yaxis: [
{
...prev.yaxis[0],
title: {
...prev.yaxis[0].title,
style: {
...prev.yaxis[0].title.style,
color: textColor
}
},
labels: {
...prev.yaxis[0].labels,
style: {
...prev.yaxis[0].labels.style,
colors: textColor
}
}
}
],
legend: {
...prev.legend,
labels: {
...prev.legend.labels,
colors: textColor
}
},
tooltip: {
...prev.tooltip,
theme: tooltipTheme
}
}));
}, [theme, systemTheme]);
// Update categories when data changes
useEffect(() => {
if (statistikData.length > 0) {
setChartOptions(prev => ({
...prev,
xaxis: {
...prev.xaxis,
categories: statistikData.map(item => item.tahun_angkatan)
}
}));
}
}, [statistikData]);
const chartSeries = [
{
name: 'Laki-laki',
type: 'bar' as const,
data: statistikData.map(item => item.pria)
},
{
name: 'Perempuan',
type: 'bar' as const,
data: statistikData.map(item => item.wanita)
},
{
name: 'Total',
type: 'line' as const,
data: statistikData.map(item => item.total_mahasiswa)
}
];
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500 dark:text-red-400">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Total Mahasiswa
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px] sm:h-[350px] md:h-[400px] w-full max-w-5xl mx-auto">
<Chart
options={chartOptions}
series={chartSeries}
type="bar"
height="100%"
width="90%"
/>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,238 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useTheme } from "next-themes";
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface GenderStatistik {
tahun_angkatan: number;
jk: string;
jumlah: number;
}
interface Props {
tahunAngkatan: string;
}
export default function StatistikPerAngkatanChart({ tahunAngkatan }: Props) {
const { theme, systemTheme } = useTheme();
const [statistikData, setStatistikData] = useState<GenderStatistik[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(`/api/mahasiswa/gender-per-angkatan?tahun=${tahunAngkatan}`);
if (!response.ok) {
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
}
const result = await response.json();
if (!Array.isArray(result)) {
throw new Error('Invalid data format received from server');
}
setStatistikData(result);
} catch (err) {
console.error('Error in fetchData:', err);
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
} finally {
setLoading(false);
}
};
if (tahunAngkatan !== "all") {
fetchData();
}
}, [tahunAngkatan]);
const [chartOptions, setChartOptions] = useState({
chart: {
type: 'pie' as const,
toolbar: {
show: true,
tools: {
download: true,
selection: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true
}
},
background: 'transparent',
},
labels: ['Laki-laki', 'Perempuan'],
colors: ['#3B82F6', '#EC4899'],
legend: {
position: 'bottom' as const,
fontSize: '14px',
markers: {
size: 12,
strokeWidth: 0
},
itemMargin: {
horizontal: 10
},
labels: {
colors: '#000',
style: {
fontSize: '14px',
fontFamily: 'Inter, sans-serif',
fontWeight: '500'
}
}
},
dataLabels: {
enabled: true,
formatter: function (val: number, opts: any) {
return val.toString();
},
style: {
fontSize: '14px',
fontFamily: 'Inter, sans-serif',
fontWeight: '500'
},
offsetY: 0,
dropShadow: {
enabled: false
},
position: 'top'
},
tooltip: {
theme: 'light',
y: {
formatter: function (val: number) {
return val + " mahasiswa"
}
}
}
});
// Update theme when it changes
useEffect(() => {
const currentTheme = theme === 'system' ? systemTheme : theme;
const textColor = currentTheme === 'dark' ? '#fff' : '#000';
const tooltipTheme = currentTheme === 'dark' ? 'dark' : 'light';
setChartOptions(prev => ({
...prev,
chart: {
...prev.chart,
background: currentTheme === 'dark' ? '#0F172B' : '#fff',
},
legend: {
...prev.legend,
labels: {
...prev.legend.labels,
colors: textColor,
}
},
dataLabels: {
...prev.dataLabels,
style: {
...prev.dataLabels.style,
color: textColor
}
},
tooltip: {
...prev.tooltip,
theme: tooltipTheme
}
}));
}, [theme, systemTheme]);
// Update dataLabels formatter when data changes
useEffect(() => {
if (statistikData.length > 0) {
setChartOptions(prev => ({
...prev,
dataLabels: {
...prev.dataLabels,
formatter: function (val: number, opts: any) {
const total = statistikData.reduce((sum, item) => sum + item.jumlah, 0);
const percentage = ((statistikData[opts.seriesIndex]?.jumlah / total) * 100) || 0;
return `${percentage.toFixed(0)}%`;
}
}
}));
}
}, [statistikData]);
const chartSeries = statistikData.map(item => item.jumlah);
const totalMahasiswa = statistikData.reduce((sum, item) => sum + item.jumlah, 0);
if (tahunAngkatan === "all") {
return null;
}
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500 dark:text-red-400">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (statistikData.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Total Mahasiswa Angkatan {tahunAngkatan}
</CardTitle>
<div className="text-lg font-semibold text-gray-600 dark:text-gray-300">
Total Mahasiswa: {totalMahasiswa}
</div>
</CardHeader>
<CardContent>
<div className="h-[350px] w-full max-w-5xl mx-auto">
<Chart
options={chartOptions}
series={chartSeries}
type="pie"
height="100%"
width="90%"
/>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,289 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface StatusData {
tahun_angkatan: string;
status_kuliah: string;
jumlah: number;
}
export default function StatusMahasiswaChart() {
const { theme, systemTheme } = useTheme();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<StatusData[]>([]);
const [series, setSeries] = useState<ApexAxisChartSeries>([]);
const [options, setOptions] = useState<ApexOptions>({
chart: {
type: 'bar',
stacked: false,
toolbar: {
show: true,
},
},
plotOptions: {
bar: {
horizontal: false,
columnWidth: '55%',
},
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return val.toString();
},
style: {
fontSize: '12px',
colors: ['#000']
}
},
stroke: {
show: true,
width: 2,
colors: ['transparent'],
},
xaxis: {
categories: [],
title: {
text: 'Tahun Angkatan',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: '#000'
}
}
},
yaxis: {
title: {
text: 'Jumlah Mahasiswa',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: '#000'
}
}
},
fill: {
opacity: 1,
},
legend: {
position: 'top',
fontSize: '14px',
markers: {
size: 12,
},
itemMargin: {
horizontal: 10,
},
labels: {
colors: '#000'
}
},
colors: ['#008FFB', '#00E396', '#FEB019', '#EF4444'],
tooltip: {
theme: 'light',
y: {
formatter: function (val: number) {
return val + " mahasiswa";
}
}
}
});
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch('/api/mahasiswa/status');
if (!response.ok) {
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
}
const result = await response.json();
if (!Array.isArray(result)) {
throw new Error('Invalid data format received from server');
}
setData(result);
// Process data to create series
const tahunAngkatan = [...new Set(result.map(item => item.tahun_angkatan))].sort();
const statuses = ['Aktif', 'Lulus', 'Cuti', 'DO'];
const seriesData = statuses.map(status => ({
name: status,
data: tahunAngkatan.map(tahun => {
const item = result.find(d => d.tahun_angkatan === tahun && d.status_kuliah === status);
return item ? item.jumlah : 0;
}),
}));
setSeries(seriesData);
setOptions(prev => ({
...prev,
xaxis: {
...prev.xaxis,
categories: tahunAngkatan,
},
}));
} catch (err) {
console.error('Error in fetchData:', err);
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
} finally {
setLoading(false);
}
};
fetchData();
}, []);
// Update theme when it changes
useEffect(() => {
const currentTheme = theme === 'system' ? systemTheme : theme;
const textColor = currentTheme === 'dark' ? '#fff' : '#000';
const tooltipTheme = currentTheme === 'dark' ? 'dark' : 'light';
setOptions(prev => ({
...prev,
chart: {
...prev.chart,
background: currentTheme === 'dark' ? '#0F172B' : '#fff',
},
dataLabels: {
...prev.dataLabels,
style: {
...prev.dataLabels?.style,
colors: [textColor]
}
},
xaxis: {
...prev.xaxis,
title: {
...prev.xaxis?.title,
style: {
...prev.xaxis?.title?.style,
color: textColor
}
},
labels: {
...prev.xaxis?.labels,
style: {
...prev.xaxis?.labels?.style,
colors: textColor
}
}
},
yaxis: {
...(prev.yaxis as ApexYAxis),
title: {
...(prev.yaxis as ApexYAxis)?.title,
style: {
...(prev.yaxis as ApexYAxis)?.title?.style,
color: textColor
}
},
labels: {
...(prev.yaxis as ApexYAxis)?.labels,
style: {
...(prev.yaxis as ApexYAxis)?.labels?.style,
colors: textColor
}
}
},
legend: {
...prev.legend,
labels: {
...prev.legend?.labels,
colors: textColor
}
},
tooltip: {
...prev.tooltip,
theme: tooltipTheme
}
}));
}, [theme, systemTheme]);
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500 dark:text-red-400">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Status Mahasiswa
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[350px] w-full max-w-5xl mx-auto">
{typeof window !== 'undefined' && (
<Chart
options={options}
series={series}
type="bar"
height="100%"
width="90%"
/>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,252 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface StatusMahasiswaData {
tahun_angkatan: number;
jk: string;
total_mahasiswa: number;
}
interface Props {
selectedYear: string;
selectedStatus: string;
}
export default function StatusMahasiswaFilterChart({ selectedYear, selectedStatus }: Props) {
const { theme } = useTheme();
const [data, setData] = useState<StatusMahasiswaData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
console.log('Fetching data with params:', { selectedYear, selectedStatus });
const response = await fetch(
`/api/mahasiswa/status-mahasiswa?tahun_angkatan=${selectedYear}&status_kuliah=${selectedStatus}`
);
if (!response.ok) {
throw new Error('Failed to fetch data');
}
const result = await response.json();
console.log('Received data:', result);
// Sort data by tahun_angkatan
const sortedData = result.sort((a: StatusMahasiswaData, b: StatusMahasiswaData) =>
Number(a.tahun_angkatan) - Number(b.tahun_angkatan)
);
setData(sortedData);
} catch (err) {
console.error('Error in fetchData:', err);
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedYear, selectedStatus]);
// Log data changes
useEffect(() => {
console.log('Current data state:', data);
}, [data]);
// Get unique years and sort them
const years = [...new Set(data.map(item => item.tahun_angkatan))].sort((a, b) => Number(a) - Number(b));
console.log('Sorted years:', years);
const chartOptions: ApexOptions = {
chart: {
type: 'bar',
stacked: false,
toolbar: {
show: true,
},
background: theme === 'dark' ? '#0F172B' : '#fff',
},
plotOptions: {
bar: {
horizontal: false,
columnWidth: '55%',
},
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return val.toString();
},
style: {
fontSize: '12px',
colors: [theme === 'dark' ? '#fff' : '#000']
}
},
stroke: {
show: true,
width: 2,
colors: ['transparent'],
},
xaxis: {
categories: years,
title: {
text: 'Tahun Angkatan',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
}
},
yaxis: {
title: {
text: 'Jumlah Mahasiswa',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
}
},
fill: {
opacity: 1,
},
legend: {
position: 'top',
fontSize: '14px',
markers: {
size: 12,
},
itemMargin: {
horizontal: 10,
},
labels: {
colors: theme === 'dark' ? '#fff' : '#000'
}
},
colors: ['#3B82F6', '#EC4899'],
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val + " mahasiswa";
}
}
}
};
// Process data for series
const processSeriesData = () => {
const maleData = new Array(years.length).fill(0);
const femaleData = new Array(years.length).fill(0);
data.forEach(item => {
const yearIndex = years.indexOf(item.tahun_angkatan);
if (yearIndex !== -1) {
if (item.jk === 'L') {
maleData[yearIndex] = item.total_mahasiswa;
} else if (item.jk === 'P') {
femaleData[yearIndex] = item.total_mahasiswa;
}
}
});
return [
{
name: 'Laki-laki',
data: maleData
},
{
name: 'Perempuan',
data: femaleData
}
];
};
const series = processSeriesData();
console.log('Processed series data:', series);
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Status Mahasiswa {selectedStatus}
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[350px] w-full max-w-5xl mx-auto">
{typeof window !== 'undefined' && (
<Chart
options={chartOptions}
series={series}
type="bar"
height="100%"
width="90%"
/>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,185 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface StatusMahasiswaData {
tahun_angkatan: number;
jk: string;
total_mahasiswa: number;
}
interface Props {
selectedYear: string;
selectedStatus: string;
}
export default function StatusMahasiswaFilterPieChart({ selectedYear, selectedStatus }: Props) {
const { theme } = useTheme();
const [data, setData] = useState<StatusMahasiswaData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const response = await fetch(
`/api/mahasiswa/status-mahasiswa?tahun_angkatan=${selectedYear}&status_kuliah=${selectedStatus}`
);
if (!response.ok) {
throw new Error('Failed to fetch data');
}
const result = await response.json();
setData(result);
} catch (err) {
console.error('Error in fetchData:', err);
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedYear, selectedStatus]);
const chartOptions: ApexOptions = {
chart: {
type: 'pie',
toolbar: {
show: true,
tools: {
download: true,
selection: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true
}
},
background: theme === 'dark' ? '#0F172B' : '#fff',
},
labels: ['Laki-laki', 'Perempuan'],
colors: ['#3B82F6', '#EC4899'],
legend: {
position: 'bottom',
fontSize: '14px',
markers: {
size: 12,
strokeWidth: 0
},
itemMargin: {
horizontal: 10
},
labels: {
colors: theme === 'dark' ? '#fff' : '#000',
}
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return `${val.toFixed(0)}%`;
},
style: {
fontSize: '14px',
fontFamily: 'Inter, sans-serif',
fontWeight: '500'
},
offsetY: 0,
dropShadow: {
enabled: false
}
},
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val + " mahasiswa"
}
}
}
};
// Process data for series
const processSeriesData = () => {
const maleData = data.find(item => item.jk === 'L')?.total_mahasiswa || 0;
const femaleData = data.find(item => item.jk === 'P')?.total_mahasiswa || 0;
return [maleData, femaleData];
};
const series = processSeriesData();
const totalMahasiswa = data.reduce((sum, item) => sum + item.total_mahasiswa, 0);
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Status Mahasiswa {selectedStatus}
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
</CardTitle>
<div className="text-lg font-semibold text-gray-600 dark:text-gray-300">
Total Mahasiswa: {totalMahasiswa}
</div>
</CardHeader>
<CardContent>
<div className="h-[350px] w-full max-w-5xl mx-auto">
{typeof window !== 'undefined' && (
<Chart
options={chartOptions}
series={series}
type="pie"
height="100%"
width="90%"
/>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,263 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface TingkatPrestasiData {
tahun_angkatan: number;
tingkat: string;
tingkat_mahasiswa_prestasi: number;
}
interface Props {
selectedJenisPrestasi: string;
}
export default function TingkatPrestasiChart({ selectedJenisPrestasi }: Props) {
const { theme } = useTheme();
const [data, setData] = useState<TingkatPrestasiData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const url = `/api/mahasiswa/tingkat-prestasi?jenisPrestasi=${selectedJenisPrestasi}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
}
const result = await response.json();
if (!Array.isArray(result)) {
throw new Error('Invalid data format received from server');
}
// Sort data by tahun_angkatan
const sortedData = result.sort((a: TingkatPrestasiData, b: TingkatPrestasiData) =>
a.tahun_angkatan - b.tahun_angkatan
);
setData(sortedData);
} catch (err) {
console.error('Error in fetchData:', err);
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedJenisPrestasi]);
// Process data for series
const processSeriesData = () => {
if (!data.length) return [];
const years = [...new Set(data.map(item => item.tahun_angkatan))].sort();
const tingkatTypes = [...new Set(data.map(item => item.tingkat))].sort();
return tingkatTypes.map(tingkat => ({
name: tingkat,
data: years.map(year => {
const item = data.find(d => d.tahun_angkatan === year && d.tingkat === tingkat);
return item?.tingkat_mahasiswa_prestasi || 0;
})
}));
};
const chartOptions: ApexOptions = {
chart: {
type: 'bar',
stacked: false,
toolbar: {
show: true,
tools: {
download: true,
selection: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true
}
},
background: theme === 'dark' ? '#0F172B' : '#fff',
},
plotOptions: {
bar: {
horizontal: false,
columnWidth: '55%',
borderRadius: 1,
},
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return val?.toString() || '0';
},
style: {
fontSize: '12px',
colors: [theme === 'dark' ? '#fff' : '#000']
}
},
stroke: {
show: true,
width: 2,
colors: ['transparent']
},
xaxis: {
categories: [...new Set(data.map(item => item.tahun_angkatan))].sort(),
title: {
text: 'Tahun Angkatan',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
},
axisBorder: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
},
axisTicks: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
}
},
yaxis: {
title: {
text: 'Jumlah Mahasiswa',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
},
axisBorder: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
}
},
fill: {
opacity: 1
},
colors: ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4'],
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val + " mahasiswa";
}
}
},
legend: {
position: 'top',
fontSize: '14px',
markers: {
size: 12,
},
itemMargin: {
horizontal: 10,
},
labels: {
colors: theme === 'dark' ? '#fff' : '#000'
}
},
grid: {
borderColor: theme === 'dark' ? '#374151' : '#E5E7EB',
strokeDashArray: 4,
padding: {
top: 20,
right: 0,
bottom: 0,
left: 0
}
}
};
const series = processSeriesData();
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tingkat Prestasi {selectedJenisPrestasi}
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[400px] sm:h-[350px] md:h-[400px] w-full max-w-5xl mx-auto">
{typeof window !== 'undefined' && series.length > 0 && (
<Chart
options={chartOptions}
series={series}
type="bar"
height="100%"
width="90%"
/>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,188 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface TingkatPrestasiData {
tahun_angkatan: number;
tingkat: string;
tingkat_mahasiswa_prestasi: number;
}
interface Props {
selectedYear: string;
selectedJenisPrestasi: string;
}
export default function TingkatPrestasiPieChart({ selectedYear, selectedJenisPrestasi }: Props) {
const { theme } = useTheme();
const [data, setData] = useState<TingkatPrestasiData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const url = `/api/mahasiswa/tingkat-prestasi?tahunAngkatan=${selectedYear}&jenisPrestasi=${selectedJenisPrestasi}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
}
const result = await response.json();
setData(result);
} catch (err) {
console.error('Error in fetchData:', err);
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedYear, selectedJenisPrestasi]);
const chartOptions: ApexOptions = {
chart: {
type: 'pie',
background: theme === 'dark' ? '#0F172B' : '#fff',
toolbar: {
show: true,
tools: {
download: true,
selection: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true
}
}
},
labels: [],
colors: ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4'],
legend: {
position: 'bottom',
fontSize: '14px',
markers: {
size: 12,
strokeWidth: 0
},
itemMargin: {
horizontal: 10
},
labels: {
colors: theme === 'dark' ? '#fff' : '#000'
}
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return `${val.toFixed(0)}%`;
},
style: {
fontSize: '14px',
fontFamily: 'Inter, sans-serif',
fontWeight: '500'
},
offsetY: 0,
dropShadow: {
enabled: false
}
},
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val + " mahasiswa"
}
}
}
};
// Process data for series
const processSeriesData = () => {
// Filter data sesuai tahun angkatan
const filtered = data.filter(item => String(item.tahun_angkatan) === String(selectedYear));
const tingkat = [...new Set(filtered.map(item => item.tingkat))].sort();
const jumlahData = tingkat.map(t => {
const item = filtered.find(d => d.tingkat === t);
return item ? item.tingkat_mahasiswa_prestasi : 0;
});
return {
series: jumlahData,
labels: tingkat
};
};
const { series, labels } = processSeriesData();
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tingkat Prestasi {selectedJenisPrestasi} Angkatan {selectedYear}
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[350px] w-full max-w-5xl mx-auto">
{typeof window !== 'undefined' && (
<Chart
options={{...chartOptions, labels}}
series={series}
type="pie"
height="100%"
width="90%"
/>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,277 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface TotalBeasiswaData {
tahun_angkatan: number;
jk: string;
jumlah_mahasiswa_beasiswa: number;
}
interface Props {
selectedYear: string;
selectedJenisBeasiswa: string;
}
export default function TotalBeasiswaChart({ selectedYear, selectedJenisBeasiswa }: Props) {
const { theme } = useTheme();
const [data, setData] = useState<TotalBeasiswaData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const url = `/api/mahasiswa/total-beasiswa?tahunAngkatan=${selectedYear}&jenisBeasiswa=${selectedJenisBeasiswa}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
}
const result = await response.json();
if (!Array.isArray(result)) {
throw new Error('Invalid data format received from server');
}
// Sort data by tahun_angkatan
const sortedData = result.sort((a: TotalBeasiswaData, b: TotalBeasiswaData) =>
a.tahun_angkatan - b.tahun_angkatan
);
setData(sortedData);
} catch (err) {
console.error('Error in fetchData:', err);
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedYear, selectedJenisBeasiswa]);
// Process data for series
const processSeriesData = () => {
if (!data.length) return [];
const years = [...new Set(data.map(item => item.tahun_angkatan))].sort();
const pria = years.map(year => {
const item = data.find(d => d.tahun_angkatan === year && d.jk === 'Pria');
return item ? item.jumlah_mahasiswa_beasiswa : 0;
});
const wanita = years.map(year => {
const item = data.find(d => d.tahun_angkatan === year && d.jk === 'Wanita');
return item ? item.jumlah_mahasiswa_beasiswa : 0;
});
return [
{
name: 'Laki-laki',
data: pria
},
{
name: 'Perempuan',
data: wanita
}
];
};
const chartOptions: ApexOptions = {
chart: {
type: 'bar',
stacked: false,
toolbar: {
show: true,
tools: {
download: true,
selection: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true
}
},
background: theme === 'dark' ? '#0F172B' : '#fff',
},
plotOptions: {
bar: {
horizontal: false,
columnWidth: '55%',
borderRadius: 1,
},
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return val.toString();
},
style: {
fontSize: '12px',
colors: [theme === 'dark' ? '#fff' : '#000']
}
},
stroke: {
show: true,
width: 2,
colors: ['transparent']
},
xaxis: {
categories: [...new Set(data.map(item => item.tahun_angkatan))].sort(),
title: {
text: 'Tahun Angkatan',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
},
axisBorder: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
},
axisTicks: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
}
},
yaxis: {
title: {
text: 'Jumlah Mahasiswa',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
},
axisBorder: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
}
},
fill: {
opacity: 1
},
colors: ['#3B82F6', '#EC4899'],
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val + " mahasiswa";
}
}
},
legend: {
position: 'top',
fontSize: '14px',
markers: {
size: 12,
},
itemMargin: {
horizontal: 10,
},
labels: {
colors: theme === 'dark' ? '#fff' : '#000'
}
},
grid: {
borderColor: theme === 'dark' ? '#374151' : '#E5E7EB',
strokeDashArray: 4,
padding: {
top: 20,
right: 0,
bottom: 0,
left: 0
}
}
};
const series = processSeriesData();
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Total Mahasiswa Beasiswa {selectedJenisBeasiswa}
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px] sm:h-[350px] md:h-[400px] w-full max-w-5xl mx-auto">
{typeof window !== 'undefined' && series.length > 0 && (
<Chart
options={chartOptions}
series={series}
type="bar"
height="100%"
width="90%"
/>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,188 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface TotalBeasiswaData {
tahun_angkatan: number;
jk: string;
jumlah_mahasiswa_beasiswa: number;
}
interface Props {
selectedYear: string;
selectedJenisBeasiswa: string;
}
export default function TotalBeasiswaPieChart({ selectedYear, selectedJenisBeasiswa }: Props) {
const { theme } = useTheme();
const [data, setData] = useState<TotalBeasiswaData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const url = `/api/mahasiswa/total-beasiswa?tahunAngkatan=${selectedYear}&jenisBeasiswa=${selectedJenisBeasiswa}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
}
const result = await response.json();
setData(result);
} catch (err) {
console.error('Error in fetchData:', err);
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedYear, selectedJenisBeasiswa]);
const chartOptions: ApexOptions = {
chart: {
type: 'pie',
background: theme === 'dark' ? '#0F172B' : '#fff',
toolbar: {
show: true,
tools: {
download: true,
selection: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true
}
}
},
labels: ['Laki-laki', 'Perempuan'],
colors: ['#3B82F6', '#EC4899'],
legend: {
position: 'bottom',
fontSize: '14px',
markers: {
size: 12,
strokeWidth: 0
},
itemMargin: {
horizontal: 10
},
formatter: function(legendName: string, opts: any) {
return legendName;
},
labels: {
colors: theme === 'dark' ? '#fff' : '#000'
}
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return `${val.toFixed(0)}%`;
},
style: {
fontSize: '14px',
fontFamily: 'Inter, sans-serif',
fontWeight: '500'
},
offsetY: 0,
dropShadow: {
enabled: false
}
},
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val + " mahasiswa"
}
}
}
};
// Process data for series
const processSeriesData = () => {
const maleData = data.find(item => item.jk === 'Pria')?.jumlah_mahasiswa_beasiswa || 0;
const femaleData = data.find(item => item.jk === 'Wanita')?.jumlah_mahasiswa_beasiswa || 0;
return [maleData, femaleData];
};
const series = processSeriesData();
const totalMahasiswa = data.reduce((sum, item) => sum + item.jumlah_mahasiswa_beasiswa, 0);
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Total Mahasiswa Beasiswa {selectedJenisBeasiswa}
{selectedYear !== 'all' ? ` Angkatan ${selectedYear}` : ''}
</CardTitle>
<div className="text-lg font-semibold text-gray-600 dark:text-gray-300">
Total Mahasiswa: {totalMahasiswa}
</div>
</CardHeader>
<CardContent>
<div className="h-[350px] w-full max-w-5xl mx-auto">
{typeof window !== 'undefined' && (
<Chart
options={chartOptions}
series={series}
type="pie"
height="100%"
width="90%"
/>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,275 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface TotalPrestasiData {
tahun_angkatan: number;
jk: string;
jumlah_mahasiswa_prestasi: number;
}
interface Props {
selectedJenisPrestasi: string;
}
export default function TotalPrestasiChart({ selectedJenisPrestasi }: Props) {
const { theme } = useTheme();
const [data, setData] = useState<TotalPrestasiData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const url = `/api/mahasiswa/total-prestasi?jenisPrestasi=${selectedJenisPrestasi}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
}
const result = await response.json();
if (!Array.isArray(result)) {
throw new Error('Invalid data format received from server');
}
// Sort data by tahun_angkatan
const sortedData = result.sort((a: TotalPrestasiData, b: TotalPrestasiData) =>
a.tahun_angkatan - b.tahun_angkatan
);
setData(sortedData);
} catch (err) {
console.error('Error in fetchData:', err);
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedJenisPrestasi]);
// Process data for series
const processSeriesData = () => {
if (!data.length) return [];
const years = [...new Set(data.map(item => item.tahun_angkatan))].sort();
const pria = years.map(year => {
const item = data.find(d => d.tahun_angkatan === year && d.jk === 'Pria');
return item ? item.jumlah_mahasiswa_prestasi : 0;
});
const wanita = years.map(year => {
const item = data.find(d => d.tahun_angkatan === year && d.jk === 'Wanita');
return item ? item.jumlah_mahasiswa_prestasi : 0;
});
return [
{
name: 'Laki-laki',
data: pria
},
{
name: 'Perempuan',
data: wanita
}
];
};
const chartOptions: ApexOptions = {
chart: {
type: 'bar',
stacked: false,
toolbar: {
show: true,
tools: {
download: true,
selection: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true
}
},
background: theme === 'dark' ? '#0F172B' : '#fff',
},
plotOptions: {
bar: {
horizontal: false,
columnWidth: '55%',
borderRadius: 1,
},
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return val.toString();
},
style: {
fontSize: '12px',
colors: [theme === 'dark' ? '#fff' : '#000']
}
},
stroke: {
show: true,
width: 2,
colors: ['transparent']
},
xaxis: {
categories: [...new Set(data.map(item => item.tahun_angkatan))].sort(),
title: {
text: 'Tahun Angkatan',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
},
axisBorder: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
},
axisTicks: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
}
},
yaxis: {
title: {
text: 'Jumlah Mahasiswa',
style: {
fontSize: '14px',
fontWeight: 'bold',
color: theme === 'dark' ? '#fff' : '#000'
}
},
labels: {
style: {
fontSize: '12px',
colors: theme === 'dark' ? '#fff' : '#000'
}
},
axisBorder: {
show: true,
color: theme === 'dark' ? '#374151' : '#E5E7EB'
}
},
fill: {
opacity: 1
},
colors: ['#3B82F6', '#EC4899'],
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val + " mahasiswa";
}
}
},
legend: {
position: 'top',
fontSize: '14px',
markers: {
size: 12,
},
itemMargin: {
horizontal: 10,
},
labels: {
colors: theme === 'dark' ? '#fff' : '#000'
}
},
grid: {
borderColor: theme === 'dark' ? '#374151' : '#E5E7EB',
strokeDashArray: 4,
padding: {
top: 20,
right: 0,
bottom: 0,
left: 0
}
}
};
const series = processSeriesData();
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Total Mahasiswa Berprestasi {selectedJenisPrestasi}
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px] sm:h-[350px] md:h-[400px] w-full max-w-5xl mx-auto">
{typeof window !== 'undefined' && series.length > 0 && (
<Chart
options={chartOptions}
series={series}
type="bar"
height="100%"
width="90%"
/>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,188 @@
'use client';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ApexOptions } from 'apexcharts';
import { useTheme } from 'next-themes';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Dynamically import ApexCharts to avoid SSR issues
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
interface TotalPrestasiData {
tahun_angkatan: number;
jk: string;
jumlah_mahasiswa_prestasi: number;
}
interface Props {
selectedYear: string;
selectedJenisPrestasi: string;
}
export default function TotalPrestasiPieChart({ selectedYear, selectedJenisPrestasi }: Props) {
const { theme } = useTheme();
const [data, setData] = useState<TotalPrestasiData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const url = `/api/mahasiswa/total-prestasi?tahunAngkatan=${selectedYear}&jenisPrestasi=${selectedJenisPrestasi}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch data: ${response.status} ${response.statusText}`);
}
const result = await response.json();
setData(result);
} catch (err) {
console.error('Error in fetchData:', err);
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedYear, selectedJenisPrestasi]);
const chartOptions: ApexOptions = {
chart: {
type: 'pie',
background: theme === 'dark' ? '#0F172B' : '#fff',
toolbar: {
show: true,
tools: {
download: true,
selection: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true
}
}
},
labels: ['Laki-laki', 'Perempuan'],
colors: ['#3B82F6', '#EC4899'],
legend: {
position: 'bottom',
fontSize: '14px',
markers: {
size: 12,
strokeWidth: 0
},
itemMargin: {
horizontal: 10
},
labels: {
colors: theme === 'dark' ? '#fff' : '#000'
}
},
dataLabels: {
enabled: true,
formatter: function (val: number) {
return `${val.toFixed(0)}%`;
},
style: {
fontSize: '14px',
fontFamily: 'Inter, sans-serif',
fontWeight: '500'
},
offsetY: 0,
dropShadow: {
enabled: false
}
},
tooltip: {
theme: theme === 'dark' ? 'dark' : 'light',
y: {
formatter: function (val: number) {
return val + " mahasiswa"
}
}
}
};
// Process data for series
const processSeriesData = () => {
// Filter data sesuai tahun angkatan
const filtered = data.filter(item => String(item.tahun_angkatan) === String(selectedYear));
const pria = filtered.find(item => item.jk === 'Pria')?.jumlah_mahasiswa_prestasi || 0;
const wanita = filtered.find(item => item.jk === 'Wanita')?.jumlah_mahasiswa_prestasi || 0;
return [pria, wanita];
};
const series = processSeriesData();
const totalMahasiswa = data
.filter(item => String(item.tahun_angkatan) === String(selectedYear))
.reduce((sum, item) => sum + item.jumlah_mahasiswa_prestasi, 0);
if (loading) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Loading...
</CardTitle>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold text-red-500">
Error: {error}
</CardTitle>
</CardHeader>
</Card>
);
}
if (data.length === 0) {
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Tidak ada data yang tersedia
</CardTitle>
</CardHeader>
</Card>
);
}
return (
<Card className="bg-white dark:bg-slate-900 shadow-lg">
<CardHeader>
<CardTitle className="text-xl font-bold dark:text-white">
Total Mahasiswa Berprestasi {selectedJenisPrestasi} Angkatan {selectedYear}
</CardTitle>
<div className="text-lg font-semibold text-gray-600 dark:text-gray-300">
Total Mahasiswa: {totalMahasiswa}
</div>
</CardHeader>
<CardContent>
<div className="h-[350px] w-full max-w-5xl mx-auto">
{typeof window !== 'undefined' && (
<Chart
options={chartOptions}
series={series}
type="pie"
height="100%"
width="90%"
/>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,18 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({ children, ...props }: React.ComponentProps<typeof NextThemesProvider>) {
const [mounted, setMounted] = React.useState(false)
React.useEffect(() => {
setMounted(true)
}, [])
if (!mounted) {
return <>{children}</>
}
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@@ -0,0 +1,37 @@
"use client"
import * as React from "react"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export function ThemeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

374
components/ui/Navbar.tsx Normal file
View File

@@ -0,0 +1,374 @@
'use client';
import { useState, useEffect } from 'react';
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { ThemeToggle } from '@/components/theme-toggle';
import { Menu, User } from 'lucide-react';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import SidebarContent from '@/components/ui/SidebarContent';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useToast } from '@/components/ui/use-toast';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
const Navbar = () => {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [loginData, setLoginData] = useState({ nim: '', password: '' });
const [registerData, setRegisterData] = useState({ username: '', nim: '', password: '', confirmPassword: '' });
const [userData, setUserData] = useState<any>(null);
const { toast } = useToast();
const router = useRouter();
// Check login status on component mount and when route changes
useEffect(() => {
const checkAuth = async () => {
try {
const response = await fetch('/api/auth/check');
const data = await response.json();
if (response.ok && data.user) {
setUserData(data.user);
setIsLoggedIn(true);
} else {
setUserData(null);
setIsLoggedIn(false);
}
} catch (error) {
console.error('Auth check failed:', error);
setUserData(null);
setIsLoggedIn(false);
}
};
checkAuth();
}, [router]);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
// Validate input
if (!loginData.nim || !loginData.password) {
toast({
variant: "destructive",
title: "Login gagal",
description: "NIM dan password harus diisi",
});
return;
}
console.log('Login attempt with data:', {
nim: loginData.nim,
password: '***'
});
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
nim: loginData.nim.trim(),
password: loginData.password
}),
});
console.log('Login response status:', response.status);
const data = await response.json();
console.log('Login response data:', data);
if (!response.ok) {
throw new Error(data.error || 'Login gagal');
}
toast({
title: "Login berhasil",
description: "Selamat datang kembali!",
});
setUserData(data.user);
setIsLoggedIn(true);
setDialogOpen(false);
router.refresh();
} catch (error) {
console.error('Login error:', error);
toast({
variant: "destructive",
title: "Login gagal",
description: error instanceof Error ? error.message : 'Terjadi kesalahan saat login',
});
}
};
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
// Validate passwords match
if (registerData.password !== registerData.confirmPassword) {
toast({
variant: "destructive",
title: "Registrasi gagal",
description: "Password dan konfirmasi password tidak cocok",
});
return;
}
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: registerData.username,
nim: registerData.nim,
password: registerData.password,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Registrasi gagal');
}
toast({
title: "Registrasi berhasil",
description: "Silakan login dengan akun Anda",
});
// Reset form and switch to login tab
setRegisterData({ username: '', nim: '', password: '', confirmPassword: '' });
const tabsList = document.querySelector('[role="tablist"]');
if (tabsList) {
const loginTab = tabsList.querySelector('[value="login"]');
if (loginTab) {
(loginTab as HTMLElement).click();
}
}
} catch (error) {
toast({
variant: "destructive",
title: "Registrasi gagal",
description: error instanceof Error ? error.message : 'Terjadi kesalahan saat registrasi',
});
}
};
const handleLogout = async () => {
try {
const response = await fetch('/api/auth/logout', {
method: 'POST',
});
if (!response.ok) {
throw new Error('Logout gagal');
}
toast({
title: "Logout berhasil",
description: "Sampai jumpa lagi!",
});
setUserData(null);
setIsLoggedIn(false);
router.push('/');
router.refresh();
} catch (error) {
toast({
variant: "destructive",
title: "Logout gagal",
description: error instanceof Error ? error.message : 'Terjadi kesalahan saat logout',
});
}
};
const handleProfileClick = async () => {
try {
const response = await fetch('/api/auth/check');
const data = await response.json();
if (response.ok && data.user) {
router.push('/mahasiswa/profile');
} else {
toast({
variant: "destructive",
title: "Akses Ditolak",
description: "Silakan login terlebih dahulu untuk mengakses profil",
});
setDialogOpen(true);
router.push('/'); // Redirect to home if not logged in
}
} catch (error) {
console.error('Error checking auth status:', error);
toast({
variant: "destructive",
title: "Error",
description: "Terjadi kesalahan saat memeriksa status login",
});
router.push('/');
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
if (name.startsWith('login')) {
const loginField = name.replace('login', '').toLowerCase();
setLoginData(prev => ({
...prev,
[loginField]: value
}));
} else {
setRegisterData(prev => ({
...prev,
[name]: value
}));
}
};
return (
<div className="bg-background border-b sticky top-0 z-50 py-2 px-5 flex justify-between items-center">
<div className="flex items-center gap-2">
{/* Mobile Menu Button */}
<div className="md:hidden">
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" size="icon">
<Menu className="h-5 w-5" />
<span className="sr-only">Toggle menu</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="p-0 w-[250px] overflow-y-auto">
<DialogTitle className="sr-only">Menu Navigasi</DialogTitle>
<SidebarContent />
</SheetContent>
</Sheet>
</div>
<Link href="/" className="flex items-center text-lg font-semibold hover:text-primary transition-colors">
<img src="/podif-icon.png" alt="PODIF Logo" className="h-6 w-auto mr-2" />
PODIF
</Link>
</div>
<div className="flex items-center gap-4">
{isLoggedIn ? (
<DropdownMenu>
<DropdownMenuTrigger className="focus:outline-none">
<Avatar>
<AvatarImage src="" alt={userData?.username || 'User'} />
<AvatarFallback className="bg-primary/10">
<User className="h-5 w-5" />
</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom" sideOffset={9} alignOffset={0}>
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleProfileClick}>
Profile
</DropdownMenuItem>
<DropdownMenuItem onClick={handleLogout}>Logout</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button variant="secondary">
Login
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Portal Data Informatika</DialogTitle>
<DialogDescription>Masuk atau daftar untuk mengakses portal</DialogDescription>
</DialogHeader>
<Tabs defaultValue="login" className="w-full mt-4">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="login">Login</TabsTrigger>
<TabsTrigger value="register">Register</TabsTrigger>
</TabsList>
<TabsContent value="login">
<form onSubmit={handleLogin} className="space-y-4">
<Input
type="text"
name="loginNim"
placeholder="NIM"
value={loginData.nim}
onChange={handleInputChange}
required
/>
<Input
type="password"
name="loginPassword"
placeholder="Password"
value={loginData.password}
onChange={handleInputChange}
required
/>
<Button className="w-full" type="submit">
Login
</Button>
</form>
</TabsContent>
<TabsContent value="register">
<form onSubmit={handleRegister} className="space-y-4">
<Input
type="text"
name="username"
placeholder="Nama Lengkap"
value={registerData.username}
onChange={handleInputChange}
required
/>
<Input
type="text"
name="nim"
placeholder="NIM"
value={registerData.nim}
onChange={handleInputChange}
required
/>
<Input
type="password"
name="password"
placeholder="Password"
value={registerData.password}
onChange={handleInputChange}
required
/>
<Input
type="password"
name="confirmPassword"
placeholder="Konfirmasi Password"
value={registerData.confirmPassword}
onChange={handleInputChange}
required
/>
<Button className="w-full" type="submit">
Register
</Button>
</form>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
)}
<ThemeToggle />
</div>
</div>
);
};
export default Navbar;

View File

@@ -0,0 +1,32 @@
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { useToast } from '@/components/ui/use-toast';
import { useRouter } from 'next/navigation';
interface ProfileMenuItemProps {
isLoggedIn: boolean;
setDialogOpen: (open: boolean) => void;
}
export const ProfileMenuItem = ({ isLoggedIn, setDialogOpen }: ProfileMenuItemProps) => {
const { toast } = useToast();
const router = useRouter();
const handleClick = () => {
if (!isLoggedIn) {
toast({
variant: "destructive",
title: "Akses Ditolak",
description: "Silakan login terlebih dahulu untuk mengakses profil",
});
setDialogOpen(true);
} else {
router.push('/mahasiswa/profile');
}
};
return (
<DropdownMenuItem onClick={handleClick}>
Profile
</DropdownMenuItem>
);
};

13
components/ui/Sidebar.tsx Normal file
View File

@@ -0,0 +1,13 @@
"use client";
import SidebarContent from './SidebarContent';
const Sidebar = () => {
return (
<div className="hidden md:block h-[calc(100vh-4rem)] w-[250px] fixed">
<SidebarContent />
</div>
);
};
export default Sidebar;

View File

@@ -0,0 +1,89 @@
"use client";
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import Link from "next/link";
import { School, Settings, User, GraduationCap, Award, Users, Clock, BookOpen, Home } from "lucide-react";
const SidebarContent = () => {
return (
<Command className="bg-background border-r h-full">
<CommandList className="overflow-visible">
<CommandGroup heading="Dashboard PODIF" className="mt-2">
<Link href="/" className="w-full no-underline cursor-pointer">
<CommandItem className="py-2 px-3 hover:bg-accent hover:text-accent-foreground rounded-md flex items-center transition-colors cursor-pointer">
<Home className="h-4 w-4" />
<span>Dashboard</span>
</CommandItem>
</Link>
</CommandGroup>
<CommandGroup heading="Menu Utama">
<CommandItem className="p-0">
<Accordion type="single" collapsible defaultValue="data-mahasiswa" className="w-full">
<AccordionItem value="data-mahasiswa" className="border-none">
<AccordionTrigger className="py-2 px-3 hover:bg-accent hover:text-accent-foreground rounded-md transition-colors">
<div className="flex items-center">
<School className="mr-2 h-4 w-4" />
<span>Data Mahasiswa</span>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="pl-6 flex flex-col space-y-1">
<Link href="/mahasiswa/total" className="py-2 px-3 hover:bg-accent hover:text-accent-foreground rounded-md flex items-center transition-colors">
<Users className="mr-2 h-4 w-4" />
<span>Mahasiswa Total</span>
</Link>
<Link href="/mahasiswa/status" className="py-2 px-3 hover:bg-accent hover:text-accent-foreground rounded-md flex items-center transition-colors">
<GraduationCap className="mr-2 h-4 w-4" />
<span>Mahasiswa Status</span>
</Link>
<Link href="/mahasiswa/lulustepatwaktu" className="py-2 px-3 hover:bg-accent hover:text-accent-foreground rounded-md flex items-center transition-colors">
<Clock className="mr-2 h-4 w-4" />
<span>Mahasiswa Lulus Tepat Waktu</span>
</Link>
<Link href="/mahasiswa/beasiswa" className="py-2 px-3 hover:bg-accent hover:text-accent-foreground rounded-md flex items-center transition-colors">
<BookOpen className="mr-2 h-4 w-4" />
<span>Mahasiswa Beasiswa</span>
</Link>
<Link href="/mahasiswa/berprestasi" className="py-2 px-3 hover:bg-accent hover:text-accent-foreground rounded-md flex items-center transition-colors">
<Award className="mr-2 h-4 w-4" />
<span>Mahasiswa Berprestasi</span>
</Link>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Data Diri">
<Link href="/mahasiswa/profile" className="w-full no-underline cursor-pointer" style={{ cursor: 'pointer' }}>
<CommandItem className="py-2 px-3 hover:bg-accent hover:text-accent-foreground rounded-md flex items-center transition-colors cursor-pointer">
<User className="h-4 w-4" />
<span>Profile</span>
</CommandItem>
</Link>
</CommandGroup>
</CommandList>
</Command>
);
};
export default SidebarContent;

View File

@@ -0,0 +1,72 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"flex flex-1 items-center justify-between gap-2 rounded-md py-2 text-left transition-all outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className={cn(
"overflow-hidden max-h-0 transition-[max-height] duration-300 ease-in-out data-[state=open]:max-h-[500px]",
className
)}
{...props}
>
<div className="pt-1 pb-2">
{children}
</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

41
components/ui/avatar.tsx Normal file
View File

@@ -0,0 +1,41 @@
'use client';
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/lib/utils';
function Avatar({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', className)}
{...props}
/>
);
}
function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn('aspect-square size-full', className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn('bg-muted flex size-full items-center justify-center rounded-full', className)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback };

56
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,56 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-card shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

92
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

177
components/ui/command.tsx Normal file
View File

@@ -0,0 +1,177 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-3 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

135
components/ui/dialog.tsx Normal file
View File

@@ -0,0 +1,135 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

132
components/ui/drawer.tsx Normal file
View File

@@ -0,0 +1,132 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@@ -0,0 +1,228 @@
'use client';
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...props}
/>
);
}
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};

21
components/ui/input.tsx Normal file
View File

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

24
components/ui/label.tsx Normal file
View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

121
components/ui/select.tsx Normal file
View File

@@ -0,0 +1,121 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
}

140
components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,140 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open:animate-in:data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open:animate-in:data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-300",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

66
components/ui/tabs.tsx Normal file
View File

@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

127
components/ui/toast.tsx Normal file
View File

@@ -0,0 +1,127 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[swipe=end]:slide-out-to-right-full sm:data-[state=open]:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

35
components/ui/toaster.tsx Normal file
View File

@@ -0,0 +1,35 @@
"use client"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
import { useToast } from "@/components/ui/use-toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

192
components/ui/use-toast.ts Normal file
View File

@@ -0,0 +1,192 @@
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_VALUE
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }