Yvonne Priscilla commited on
Commit ·
66ed0c8
1
Parent(s): e394370
up chart
Browse files- src/app/api/cv-profile/charts/major/route.ts +36 -0
- src/app/api/cv-profile/charts/university/route.ts +36 -0
- src/app/recruitment/page.tsx +3 -1
- src/components/dashboard/charts-section.tsx +5 -5
- src/components/dashboard/charts/major-chart.tsx +30 -0
- src/components/dashboard/charts/university-bar.tsx +29 -38
- src/components/ui/ChartBar.tsx +79 -0
- src/components/ui/ChartPie.tsx +63 -0
- src/types/chart.ts +5 -0
src/app/api/cv-profile/charts/major/route.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { prisma } from "@/lib/prisma";
|
| 2 |
+
import { NextResponse } from "next/server";
|
| 3 |
+
|
| 4 |
+
function randomColor() {
|
| 5 |
+
return `#${Math.floor(Math.random() * 16777215).toString(16)}`;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export async function GET() {
|
| 9 |
+
const profiles = await prisma.cv_profile.findMany({
|
| 10 |
+
select: {
|
| 11 |
+
major_edu_1: true,
|
| 12 |
+
major_edu_2: true,
|
| 13 |
+
major_edu_3: true,
|
| 14 |
+
},
|
| 15 |
+
});
|
| 16 |
+
|
| 17 |
+
const majorCount: Record<string, number> = {};
|
| 18 |
+
|
| 19 |
+
profiles.forEach((profile) => {
|
| 20 |
+
[profile.major_edu_1, profile.major_edu_2, profile.major_edu_3]
|
| 21 |
+
.filter(Boolean)
|
| 22 |
+
.forEach((univ) => {
|
| 23 |
+
if (!univ) return;
|
| 24 |
+
|
| 25 |
+
majorCount[univ] = (majorCount[univ] || 0) + 1;
|
| 26 |
+
});
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
return NextResponse.json(Object.entries(majorCount).map(([name, value]) => ({
|
| 32 |
+
name,
|
| 33 |
+
value,
|
| 34 |
+
color: randomColor(),
|
| 35 |
+
})));
|
| 36 |
+
}
|
src/app/api/cv-profile/charts/university/route.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { prisma } from "@/lib/prisma";
|
| 2 |
+
import { NextResponse } from "next/server";
|
| 3 |
+
|
| 4 |
+
function randomColor() {
|
| 5 |
+
return `#${Math.floor(Math.random() * 16777215).toString(16)}`;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export async function GET() {
|
| 9 |
+
const profiles = await prisma.cv_profile.findMany({
|
| 10 |
+
select: {
|
| 11 |
+
univ_edu_1: true,
|
| 12 |
+
univ_edu_2: true,
|
| 13 |
+
univ_edu_3: true,
|
| 14 |
+
},
|
| 15 |
+
});
|
| 16 |
+
|
| 17 |
+
const universityCount: Record<string, number> = {};
|
| 18 |
+
|
| 19 |
+
profiles.forEach((profile) => {
|
| 20 |
+
[profile.univ_edu_1, profile.univ_edu_2, profile.univ_edu_3]
|
| 21 |
+
.filter(Boolean)
|
| 22 |
+
.forEach((univ) => {
|
| 23 |
+
if (!univ) return;
|
| 24 |
+
|
| 25 |
+
universityCount[univ] = (universityCount[univ] || 0) + 1;
|
| 26 |
+
});
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
return NextResponse.json(Object.entries(universityCount).map(([name, value]) => ({
|
| 32 |
+
name,
|
| 33 |
+
value,
|
| 34 |
+
color: randomColor(),
|
| 35 |
+
})));
|
| 36 |
+
}
|
src/app/recruitment/page.tsx
CHANGED
|
@@ -1,12 +1,14 @@
|
|
| 1 |
'use client';
|
| 2 |
|
| 3 |
-
import { MetricsRow } from '@/components/dashboard/metrics-row';
|
| 4 |
import CandidateTable from '@/components/dashboard/candidates-table';
|
|
|
|
|
|
|
| 5 |
|
| 6 |
export default function DashboardPage() {
|
| 7 |
return (
|
| 8 |
<div>
|
| 9 |
<MetricsRow />
|
|
|
|
| 10 |
<CandidateTable />
|
| 11 |
</div>
|
| 12 |
);
|
|
|
|
| 1 |
'use client';
|
| 2 |
|
|
|
|
| 3 |
import CandidateTable from '@/components/dashboard/candidates-table';
|
| 4 |
+
import { ChartsSection } from '@/components/dashboard/charts-section';
|
| 5 |
+
import { MetricsRow } from '@/components/dashboard/metrics-row';
|
| 6 |
|
| 7 |
export default function DashboardPage() {
|
| 8 |
return (
|
| 9 |
<div>
|
| 10 |
<MetricsRow />
|
| 11 |
+
<ChartsSection />
|
| 12 |
<CandidateTable />
|
| 13 |
</div>
|
| 14 |
);
|
src/components/dashboard/charts-section.tsx
CHANGED
|
@@ -1,21 +1,21 @@
|
|
| 1 |
'use client';
|
| 2 |
|
| 3 |
import { Card } from '@/components/ui/card';
|
| 4 |
-
import {
|
| 5 |
import { UniversityBar } from './charts/university-bar';
|
| 6 |
|
| 7 |
export function ChartsSection() {
|
| 8 |
return (
|
| 9 |
-
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 10 |
<Card className="p-6 border-2">
|
| 11 |
<h3 className="text-base font-semibold text-gray-900 text-center text-green-500">
|
| 12 |
-
|
| 13 |
</h3>
|
| 14 |
-
<
|
| 15 |
</Card>
|
| 16 |
<Card className="p-6 border-2">
|
| 17 |
<h3 className="text-base font-semibold text-gray-900 text-center text-green-500">
|
| 18 |
-
|
| 19 |
</h3>
|
| 20 |
<UniversityBar />
|
| 21 |
</Card>
|
|
|
|
| 1 |
'use client';
|
| 2 |
|
| 3 |
import { Card } from '@/components/ui/card';
|
| 4 |
+
import { MajorChart } from './charts/major-chart';
|
| 5 |
import { UniversityBar } from './charts/university-bar';
|
| 6 |
|
| 7 |
export function ChartsSection() {
|
| 8 |
return (
|
| 9 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 my-2 mt-4">
|
| 10 |
<Card className="p-6 border-2">
|
| 11 |
<h3 className="text-base font-semibold text-gray-900 text-center text-green-500">
|
| 12 |
+
All Major
|
| 13 |
</h3>
|
| 14 |
+
<MajorChart />
|
| 15 |
</Card>
|
| 16 |
<Card className="p-6 border-2">
|
| 17 |
<h3 className="text-base font-semibold text-gray-900 text-center text-green-500">
|
| 18 |
+
All University
|
| 19 |
</h3>
|
| 20 |
<UniversityBar />
|
| 21 |
</Card>
|
src/components/dashboard/charts/major-chart.tsx
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { authFetch } from '@/lib/api';
|
| 4 |
+
import { useQuery } from '@tanstack/react-query';
|
| 5 |
+
import { ChartPie } from '../../ui/ChartPie';
|
| 6 |
+
|
| 7 |
+
export function MajorChart() {
|
| 8 |
+
const fetchMajor = async (): Promise<{
|
| 9 |
+
name: string
|
| 10 |
+
value: number
|
| 11 |
+
color: string
|
| 12 |
+
}[]> => {
|
| 13 |
+
const res = await authFetch(`/api/cv-profile/charts/major`)
|
| 14 |
+
if (!res.ok) throw new Error("Failed to fetch major")
|
| 15 |
+
return res.json()
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
const { data = [], isLoading } = useQuery({
|
| 20 |
+
queryKey: ["charts-major"],
|
| 21 |
+
queryFn: () => fetchMajor(),
|
| 22 |
+
staleTime: 0, // always fresh
|
| 23 |
+
placeholderData: (prev) => prev, // keep previous data while loading (no flicker)
|
| 24 |
+
refetchOnWindowFocus: false,
|
| 25 |
+
refetchOnMount: false,
|
| 26 |
+
})
|
| 27 |
+
return (
|
| 28 |
+
<ChartPie loading={isLoading} data={data} />
|
| 29 |
+
);
|
| 30 |
+
}
|
src/components/dashboard/charts/university-bar.tsx
CHANGED
|
@@ -1,42 +1,33 @@
|
|
| 1 |
-
'use client'
|
| 2 |
|
| 3 |
-
import {
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
XAxis,
|
| 7 |
-
YAxis,
|
| 8 |
-
CartesianGrid,
|
| 9 |
-
Tooltip,
|
| 10 |
-
ResponsiveContainer,
|
| 11 |
-
Cell,
|
| 12 |
-
} from 'recharts';
|
| 13 |
-
|
| 14 |
-
const data = [
|
| 15 |
-
{ name: 'Universitas Indonesia', value: 1200 },
|
| 16 |
-
{ name: 'Institut Teknologi Bandung', value: 1100 },
|
| 17 |
-
{ name: 'Binus University', value: 950 },
|
| 18 |
-
{ name: 'Universitas Diponegoro', value: 850 },
|
| 19 |
-
{ name: 'Universitas Gadah Mada', value: 800 },
|
| 20 |
-
];
|
| 21 |
|
| 22 |
export function UniversityBar() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
return (
|
| 24 |
-
<
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
layout="vertical"
|
| 28 |
-
margin={{ top: 5, right: 30, bottom: 5 }}
|
| 29 |
-
>
|
| 30 |
-
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
| 31 |
-
<XAxis type="number" />
|
| 32 |
-
<YAxis dataKey="name" type="category" width={195} tick={{ fontSize: 12 }} />
|
| 33 |
-
<Tooltip />
|
| 34 |
-
<Bar dataKey="value" fill="#22c55e" radius={[0, 8, 8, 0]}>
|
| 35 |
-
{data.map((_, index) => (
|
| 36 |
-
<Cell key={`cell-${index}`} fill="#22c55e" />
|
| 37 |
-
))}
|
| 38 |
-
</Bar>
|
| 39 |
-
</BarChart>
|
| 40 |
-
</ResponsiveContainer>
|
| 41 |
-
);
|
| 42 |
-
}
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
|
| 3 |
+
import { authFetch } from '@/lib/api'
|
| 4 |
+
import { useQuery } from '@tanstack/react-query'
|
| 5 |
+
import { ChartBar } from '../../ui/ChartBar'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
export function UniversityBar() {
|
| 8 |
+
const fetchScore = async (): Promise<
|
| 9 |
+
{
|
| 10 |
+
name: string
|
| 11 |
+
value: number
|
| 12 |
+
color: string
|
| 13 |
+
}[]
|
| 14 |
+
> => {
|
| 15 |
+
const res = await authFetch(`/api/cv-profile/charts/university`)
|
| 16 |
+
if (!res.ok) throw new Error('Failed to fetch score')
|
| 17 |
+
return res.json()
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
const { data = [], isLoading } = useQuery({
|
| 21 |
+
queryKey: ['charts-university'],
|
| 22 |
+
queryFn: fetchScore,
|
| 23 |
+
staleTime: 0,
|
| 24 |
+
placeholderData: (prev) => prev,
|
| 25 |
+
refetchOnWindowFocus: false,
|
| 26 |
+
refetchOnMount: false,
|
| 27 |
+
})
|
| 28 |
+
|
| 29 |
+
|
| 30 |
return (
|
| 31 |
+
<ChartBar loading={isLoading} data={data} />
|
| 32 |
+
)
|
| 33 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/components/ui/ChartBar.tsx
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { ChartData } from '@/types/chart'
|
| 4 |
+
import {
|
| 5 |
+
Bar,
|
| 6 |
+
BarChart,
|
| 7 |
+
CartesianGrid,
|
| 8 |
+
Cell,
|
| 9 |
+
ResponsiveContainer,
|
| 10 |
+
Tooltip,
|
| 11 |
+
XAxis,
|
| 12 |
+
YAxis,
|
| 13 |
+
} from 'recharts'
|
| 14 |
+
|
| 15 |
+
type ChartBarProps = {
|
| 16 |
+
loading: boolean
|
| 17 |
+
data: ChartData[]
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export function ChartBar({
|
| 21 |
+
loading,
|
| 22 |
+
data
|
| 23 |
+
}: ChartBarProps) {
|
| 24 |
+
const rows = 8
|
| 25 |
+
if (loading) return (
|
| 26 |
+
<div
|
| 27 |
+
className="w-full"
|
| 28 |
+
style={{ height: rows * 40 }}
|
| 29 |
+
>
|
| 30 |
+
<div className="flex flex-col gap-4 animate-pulse">
|
| 31 |
+
{Array.from({ length: rows }).map((_, index) => (
|
| 32 |
+
<div key={index} className="flex items-center gap-4">
|
| 33 |
+
{/* Fake Y label */}
|
| 34 |
+
<div className="h-4 bg-gray-200 rounded w-48" />
|
| 35 |
+
|
| 36 |
+
{/* Fake Bar */}
|
| 37 |
+
<div
|
| 38 |
+
className="h-5 bg-gray-200 rounded"
|
| 39 |
+
style={{
|
| 40 |
+
width: `${40 + Math.random() * 50}%`,
|
| 41 |
+
}}
|
| 42 |
+
/>
|
| 43 |
+
</div>
|
| 44 |
+
))}
|
| 45 |
+
</div>
|
| 46 |
+
</div>
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
return (
|
| 50 |
+
<ResponsiveContainer
|
| 51 |
+
width="100%"
|
| 52 |
+
height={data.length * 40} // 40px per row
|
| 53 |
+
>
|
| 54 |
+
<BarChart
|
| 55 |
+
data={data}
|
| 56 |
+
layout="vertical"
|
| 57 |
+
margin={{ top: 5, right: 30, bottom: 5 }}
|
| 58 |
+
>
|
| 59 |
+
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
| 60 |
+
<XAxis type="number" />
|
| 61 |
+
<YAxis
|
| 62 |
+
dataKey="name"
|
| 63 |
+
type="category"
|
| 64 |
+
width={220}
|
| 65 |
+
tickFormatter={(value: string) =>
|
| 66 |
+
value.length > 25 ? value.slice(0, 25) + '...' : value
|
| 67 |
+
}
|
| 68 |
+
/>
|
| 69 |
+
<Tooltip />
|
| 70 |
+
|
| 71 |
+
<Bar dataKey="value" radius={[0, 8, 8, 0]}>
|
| 72 |
+
{data.map((entry, index) => (
|
| 73 |
+
<Cell key={`cell-${index}`} fill={entry.color} />
|
| 74 |
+
))}
|
| 75 |
+
</Bar>
|
| 76 |
+
</BarChart>
|
| 77 |
+
</ResponsiveContainer>
|
| 78 |
+
)
|
| 79 |
+
}
|
src/components/ui/ChartPie.tsx
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { ChartData } from '@/types/chart';
|
| 4 |
+
import {
|
| 5 |
+
Cell,
|
| 6 |
+
Legend,
|
| 7 |
+
Pie,
|
| 8 |
+
PieChart,
|
| 9 |
+
ResponsiveContainer,
|
| 10 |
+
Tooltip,
|
| 11 |
+
} from 'recharts';
|
| 12 |
+
|
| 13 |
+
type ChartPieProps = {
|
| 14 |
+
loading: boolean
|
| 15 |
+
data: ChartData[]
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export function ChartPie({
|
| 19 |
+
loading,
|
| 20 |
+
data
|
| 21 |
+
}: ChartPieProps) {
|
| 22 |
+
const items = 5
|
| 23 |
+
if (loading) return (
|
| 24 |
+
<div className="w-full flex items-center justify-center gap-10 animate-pulse">
|
| 25 |
+
|
| 26 |
+
{/* Fake Pie Circle */}
|
| 27 |
+
<div className="relative">
|
| 28 |
+
<div className="w-48 h-48 bg-gray-200 rounded-full" />
|
| 29 |
+
<div className="absolute inset-6 bg-white rounded-full" />
|
| 30 |
+
</div>
|
| 31 |
+
|
| 32 |
+
{/* Fake Legend */}
|
| 33 |
+
<div className="flex flex-col gap-4">
|
| 34 |
+
{Array.from({ length: items }).map((_, index) => (
|
| 35 |
+
<div key={index} className="flex items-center gap-3">
|
| 36 |
+
<div className="w-4 h-4 bg-gray-200 rounded-sm" />
|
| 37 |
+
<div className="h-4 bg-gray-200 rounded w-32" />
|
| 38 |
+
</div>
|
| 39 |
+
))}
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
</div>
|
| 43 |
+
)
|
| 44 |
+
return (
|
| 45 |
+
<ResponsiveContainer
|
| 46 |
+
width="100%"
|
| 47 |
+
height={data.length * 40} // 40px per row
|
| 48 |
+
>
|
| 49 |
+
<PieChart>
|
| 50 |
+
<Pie
|
| 51 |
+
data={data}
|
| 52 |
+
dataKey="value"
|
| 53 |
+
>
|
| 54 |
+
{data.map((entry, index) => (
|
| 55 |
+
<Cell key={`cell-${index}`} fill={entry.color} />
|
| 56 |
+
))}
|
| 57 |
+
</Pie>
|
| 58 |
+
<Tooltip formatter={(value) => `${value}%`} />
|
| 59 |
+
<Legend />
|
| 60 |
+
</PieChart>
|
| 61 |
+
</ResponsiveContainer>
|
| 62 |
+
);
|
| 63 |
+
}
|
src/types/chart.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type ChartData = {
|
| 2 |
+
name: string;
|
| 3 |
+
value: number;
|
| 4 |
+
color: string;
|
| 5 |
+
};
|