added new universitas chart, more chart colors variety, added new warning for login field

This commit is contained in:
Billy Marcello
2026-01-18 14:52:38 +07:00
parent 694a05cb39
commit 87d6461a24
2 changed files with 287 additions and 191 deletions
+222 -176
View File
@@ -20,7 +20,6 @@ type ChartDataItem = {
type StatCard = { type StatCard = {
title: string; title: string;
value: number; value: number;
subtitle: string;
icon: React.ReactNode; icon: React.ReactNode;
iconColor: string; iconColor: string;
}; };
@@ -78,19 +77,79 @@ type BatchResponse = {
}>; }>;
}; };
const UNIVERSITAS_COLORS = ["#001a66", "#334d99", "#6699cc", "#99b3ff"]; type ApplicationsListResponse = {
const PENERIMAAN_COLORS = ["#0088ff", "#00cc00", "#ff3333"]; status: boolean;
const NEGARA_COLORS = ["#cc3333", "#dd6666", "#ee9999", "#f0b3b3", "#f5cccc"]; data: Array<{
universitas?: string | null;
}>;
message?: string;
code?: number;
};
const CATEGORY_COLORS = [
"#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd",
"#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf",
"#393b79", "#637939", "#8c6d31", "#843c39", "#7b4173",
"#3182bd", "#e6550d", "#31a354", "#756bb1", "#636363",
"#6baed6", "#fd8d3c", "#74c476", "#9e9ac8", "#969696",
"#9ecae1", "#fdae6b", "#a1d99b", "#bcbddc", "#bdbdbd",
];
const API_BASE_URL = const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL?.trim() || import.meta.env.VITE_API_BASE_URL?.trim() ||
"https://internify-ruddy.vercel.app"; "https://internify-ruddy.vercel.app";
// helper: group by key -> count
function groupCount(list: Array<string>) {
const map = new Map<string, number>();
for (const raw of list) {
const key = (raw || "").trim();
if (!key) continue;
map.set(key, (map.get(key) || 0) + 1);
}
return Array.from(map.entries())
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value);
}
function PieLegend({
data,
colors,
}: {
data: ChartDataItem[];
colors: string[];
}) {
return (
<div className="space-y-3 text-sm">
{data.map((item, index) => (
<div
key={`${item.name}-${index}`}
className="flex items-center justify-between"
>
<div className="flex items-center gap-2">
<div
className="h-3 w-3 rounded"
style={{ backgroundColor: colors[index % colors.length] }}
/>
<span className="text-gray-700">{item.name}</span>
</div>
<span className="text-sm font-semibold text-gray-900">
{item.value}
</span>
</div>
))}
</div>
);
}
const Dashboard: React.FC = () => { const Dashboard: React.FC = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const [statCards, setStatCards] = useState<StatCard[]>([]); const [statCards, setStatCards] = useState<StatCard[]>([]);
const [positionData, setPositionData] = useState<ChartDataItem[]>([]);
const [universitasData, setUniversitasData] = useState<ChartDataItem[]>([]); const [universitasData, setUniversitasData] = useState<ChartDataItem[]>([]);
const [penerimaanData, setPenerimaanData] = useState<ChartDataItem[]>([]); const [penerimaanData, setPenerimaanData] = useState<ChartDataItem[]>([]);
const [negaraData, setNegaraData] = useState<ChartDataItem[]>([]); const [negaraData, setNegaraData] = useState<ChartDataItem[]>([]);
@@ -107,9 +166,7 @@ const Dashboard: React.FC = () => {
const api = useMemo(() => { const api = useMemo(() => {
const instance = axios.create({ const instance = axios.create({
baseURL: API_BASE_URL, baseURL: API_BASE_URL,
headers: { headers: { Accept: "application/json" },
Accept: "application/json",
},
timeout: 15000, timeout: 15000,
}); });
@@ -153,15 +210,13 @@ const Dashboard: React.FC = () => {
const token = getTokenFromCookie(); const token = getTokenFromCookie();
if (!token) { if (!token) {
setErrorMessage( setErrorMessage("Token tidak ditemukan di cookie. Silakan login ulang.");
"Token tidak ditemukan di cookie. Silakan login ulang."
);
setLoading(false); setLoading(false);
return; return;
} }
const [statsRes, positionRes, countryRes, batchRes] = await Promise.all( const [statsRes, positionRes, countryRes, batchRes, appsRes] =
[ await Promise.all([
api.get<DashboardStatsResponse>( api.get<DashboardStatsResponse>(
"/lamaran-magang-api/statistics/dashboard" "/lamaran-magang-api/statistics/dashboard"
), ),
@@ -172,13 +227,14 @@ const Dashboard: React.FC = () => {
"/lamaran-magang-api/statistics/country" "/lamaran-magang-api/statistics/country"
), ),
api.get<BatchResponse>("/batch-api"), api.get<BatchResponse>("/batch-api"),
] api.get<ApplicationsListResponse>("/lamaran-magang-api/statistics/university"),
); ]);
const statsJson = statsRes.data; const statsJson = statsRes.data;
const positionJson = positionRes.data; const positionJson = positionRes.data;
const countryJson = countryRes.data; const countryJson = countryRes.data;
const batchJson = batchRes.data; const batchJson = batchRes.data;
const appsJson = appsRes.data;
if (!statsJson?.status) { if (!statsJson?.status) {
setErrorMessage( setErrorMessage(
@@ -213,61 +269,83 @@ const Dashboard: React.FC = () => {
console.warn("Fetch batch gagal:", batchJson?.message); console.warn("Fetch batch gagal:", batchJson?.message);
} }
if (!appsJson?.status) {
console.warn(
"Fetch applicants list gagal (untuk universitas chart):",
appsJson?.message
);
}
const stats = statsJson.data; const stats = statsJson.data;
const positions = positionJson.data || []; const positions = positionJson.data || [];
const countries = countryJson.data || []; const countries = countryJson.data || [];
// === Stat Cards ===
const statCardsFromApi: StatCard[] = [ const statCardsFromApi: StatCard[] = [
{ {
title: "Total Pendaftar", title: "Total Pendaftar",
value: stats.total_pendaftar, value: stats.total_pendaftar,
subtitle: "+200 dari batch sebelumnya",
icon: <Users size={24} />, icon: <Users size={24} />,
iconColor: "#0088ff", iconColor: "#0088ff",
}, },
{ {
title: "Total Diterima", title: "Total Diterima",
value: stats.total_diterima, value: stats.total_diterima,
subtitle: `${stats.acceptance_rate} acceptance rate`,
icon: <UserCheck size={24} />, icon: <UserCheck size={24} />,
iconColor: "#00cc00", iconColor: "#00cc00",
}, },
{ {
title: "Total Ditolak", title: "Total Ditolak",
value: stats.total_ditolak, value: stats.total_ditolak,
subtitle: `${stats.rejection_rate} rejection rate`,
icon: <UserX size={24} />, icon: <UserX size={24} />,
iconColor: "#ff3333", iconColor: "#ff3333",
}, },
{ {
title: "Sedang Diproses", title: "Sedang Diproses",
value: stats.sedang_diproses, value: stats.sedang_diproses,
subtitle: `${stats.pending_rate} pending review`,
icon: <Clock size={24} />, icon: <Clock size={24} />,
iconColor: "#ffaa00", iconColor: "#ffaa00",
}, },
]; ];
const penerimaanFromApi: ChartDataItem[] = [ // === Position ===
const positionCounts: ChartDataItem[] = positions.map((p) => ({
name: p.posisi,
value: p.total,
}));
// === Universitas ===
const applicants = appsJson?.status ? appsJson.data || [] : [];
const uniCountsRaw = groupCount(
applicants.map((a) => (a.universitas ?? "").toString())
);
// Top 5 + Lainnya
const topN = 5;
const top = uniCountsRaw.slice(0, topN);
const rest = uniCountsRaw.slice(topN);
const othersValue = rest.reduce((acc, x) => acc + x.value, 0);
const uniCounts: ChartDataItem[] =
othersValue > 0 ? [...top, { name: "Lainnya", value: othersValue }] : top;
// === Negara ===
const negaraCounts: ChartDataItem[] = countries.map((c) => ({
name: c.negara,
value: c.total,
}));
// === Penerimaan ===
const penerimaanCounts: ChartDataItem[] = [
{ name: "Total Pendaftar", value: stats.total_pendaftar }, { name: "Total Pendaftar", value: stats.total_pendaftar },
{ name: "Total Diterima", value: stats.total_diterima }, { name: "Total Diterima", value: stats.total_diterima },
{ name: "Total Ditolak", value: stats.total_ditolak }, { name: "Total Ditolak", value: stats.total_ditolak },
]; ];
const universitasFromApi: ChartDataItem[] = positions.map((p) => ({
name: p.posisi,
value: p.total,
}));
const negaraFromApi: ChartDataItem[] = countries.map((c) => ({
name: c.negara,
value: c.total,
}));
setStatCards(statCardsFromApi); setStatCards(statCardsFromApi);
setPenerimaanData(penerimaanFromApi); setPositionData(positionCounts);
setUniversitasData(universitasFromApi); setUniversitasData(uniCounts);
setNegaraData(negaraFromApi); setNegaraData(negaraCounts);
setPenerimaanData(penerimaanCounts);
setLoading(false); setLoading(false);
} catch (err) { } catch (err) {
@@ -313,9 +391,7 @@ const Dashboard: React.FC = () => {
{/* Loading / Error State */} {/* Loading / Error State */}
{loading && ( {loading && (
<p className="text-center text-gray-600"> <p className="text-center text-gray-600">Memuat data dashboard...</p>
Memuat data dashboard...
</p>
)} )}
{!loading && errorMessage && ( {!loading && errorMessage && (
@@ -342,44 +418,40 @@ const Dashboard: React.FC = () => {
{card.title} {card.title}
</h3> </h3>
</div> </div>
<div className="mb-2">
<p className="text-2xl font-bold text-gray-900"> <p className="text-2xl font-bold text-gray-900">
{card.value} {card.value}
</p>
</div>
<p className="text-xs" style={{ color: card.iconColor }}>
{card.subtitle}
</p> </p>
</div> </div>
))} ))}
</div> </div>
{/* Charts Section */} {/* ====== Charts ====== */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Universitas Chart */} {/* 1) Position */}
<div className="rounded-lg border border-gray-300 bg-white p-6"> <div className="rounded-lg border border-gray-300 bg-white p-6">
<h2 className="mb-6 text-lg font-bold text-gray-900"> <h2 className="mb-6 text-lg font-bold text-gray-900">
Universitas Position
</h2> </h2>
<div className="mb-6 flex justify-center"> <div className="mb-6 flex justify-center">
<ResponsiveContainer width="100%" height={250}> <ResponsiveContainer width="100%" height={220}>
<PieChart> <PieChart>
<Pie <Pie
data={universitasData} data={positionData}
cx="50%" cx="50%"
cy="50%" cy="50%"
innerRadius={0} innerRadius={0}
outerRadius={100} outerRadius={85}
paddingAngle={2} paddingAngle={2}
dataKey="value" dataKey="value"
> >
{universitasData.map((_, index) => ( {positionData.map((_, index) => (
<Cell <Cell
key={`universitas-cell-${index}`} key={`position-cell-${index}`}
fill={ fill={
UNIVERSITAS_COLORS[ CATEGORY_COLORS[
index % UNIVERSITAS_COLORS.length index % CATEGORY_COLORS.length
] ]
} }
/> />
@@ -389,140 +461,114 @@ const Dashboard: React.FC = () => {
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
{/* Legend */} <PieLegend data={positionData} colors={CATEGORY_COLORS} />
<div className="space-y-3 text-sm">
{universitasData.map((item, index) => (
<div
key={`universitas-legend-${index}`}
className="flex items-center justify-between"
>
<div className="flex items-center gap-2">
<div
className="h-3 w-3 rounded"
style={{
backgroundColor:
UNIVERSITAS_COLORS[
index % UNIVERSITAS_COLORS.length
],
}}
/>
<span className="text-gray-700">{item.name}</span>
</div>
<span className="text-sm font-semibold text-gray-900">
{item.value}%
</span>
</div>
))}
</div>
</div> </div>
{/* Right Column - Penerimaan & Negara */} {/* 2) Universitas */}
<div className="space-y-6"> <div className="rounded-lg border border-gray-300 bg-white p-6">
{/* Penerimaan Chart */} <h2 className="mb-6 text-lg font-bold text-gray-900">
<div className="rounded-lg border border-gray-300 bg-white p-6"> Universitas
<h2 className="mb-6 text-lg font-bold text-gray-900"> </h2>
Penerimaan
</h2>
<div className="mb-6 flex justify-center"> <div className="mb-6 flex justify-center">
<ResponsiveContainer width="100%" height={180}> <ResponsiveContainer width="100%" height={220}>
<PieChart> <PieChart>
<Pie <Pie
data={penerimaanData} data={universitasData}
cx="50%" cx="50%"
cy="50%" cy="50%"
innerRadius={0} innerRadius={0}
outerRadius={70} outerRadius={85}
paddingAngle={2} paddingAngle={2}
dataKey="value" dataKey="value"
>
{penerimaanData.map((_, index) => (
<Cell
key={`penerimaan-cell-${index}`}
fill={
PENERIMAAN_COLORS[
index % PENERIMAAN_COLORS.length
]
}
/>
))}
</Pie>
</PieChart>
</ResponsiveContainer>
</div>
{/* Legend */}
<div className="space-y-2 text-sm">
{penerimaanData.map((item, index) => (
<div
key={`penerimaan-legend-${index}`}
className="flex items-center gap-2"
> >
<div {universitasData.map((_, index) => (
className="h-3 w-3 rounded" <Cell
style={{ key={`universitas-cell-${index}`}
backgroundColor: fill={
PENERIMAAN_COLORS[ CATEGORY_COLORS[
index % PENERIMAAN_COLORS.length index % CATEGORY_COLORS.length
], ]
}} }
/> />
<span className="text-gray-700">{item.name}</span> ))}
</div> </Pie>
))} </PieChart>
</div> </ResponsiveContainer>
</div> </div>
{/* Negara Chart */} <PieLegend
<div className="rounded-lg border border-gray-300 bg-white p-6"> data={universitasData}
<h2 className="mb-6 text-lg font-bold text-gray-900"> colors={CATEGORY_COLORS}
Negara />
</h2> </div>
<div className="mb-6 flex justify-center"> {/* 3) Negara */}
<ResponsiveContainer width="100%" height={180}> <div className="rounded-lg border border-gray-300 bg-white p-6">
<PieChart> <h2 className="mb-6 text-lg font-bold text-gray-900">
<Pie Negara
data={negaraData} </h2>
cx="50%"
cy="50%"
innerRadius={0}
outerRadius={70}
paddingAngle={2}
dataKey="value"
>
{negaraData.map((_, index) => (
<Cell
key={`negara-cell-${index}`}
fill={
NEGARA_COLORS[index % NEGARA_COLORS.length]
}
/>
))}
</Pie>
</PieChart>
</ResponsiveContainer>
</div>
{/* Legend */} <div className="mb-6 flex justify-center">
<div className="space-y-2 text-sm"> <ResponsiveContainer width="100%" height={220}>
{negaraData.map((item, index) => ( <PieChart>
<div <Pie
key={`negara-legend-${index}`} data={negaraData}
className="flex items-center gap-2" cx="50%"
cy="50%"
innerRadius={0}
outerRadius={85}
paddingAngle={2}
dataKey="value"
> >
<div {negaraData.map((_, index) => (
className="h-3 w-3 rounded" <Cell
style={{ key={`negara-cell-${index}`}
backgroundColor: fill={CATEGORY_COLORS[index % CATEGORY_COLORS.length]}
NEGARA_COLORS[index % NEGARA_COLORS.length], />
}} ))}
/> </Pie>
<span className="text-gray-700">{item.name}</span> </PieChart>
</div> </ResponsiveContainer>
))}
</div>
</div> </div>
<PieLegend data={negaraData} colors={CATEGORY_COLORS} />
</div>
{/* 4) Penerimaan */}
<div className="rounded-lg border border-gray-300 bg-white p-6">
<h2 className="mb-6 text-lg font-bold text-gray-900">
Penerimaan
</h2>
<div className="mb-6 flex justify-center">
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie
data={penerimaanData}
cx="50%"
cy="50%"
innerRadius={0}
outerRadius={85}
paddingAngle={2}
dataKey="value"
>
{penerimaanData.map((_, index) => (
<Cell
key={`penerimaan-cell-${index}`}
fill={
CATEGORY_COLORS[
index % CATEGORY_COLORS.length
]
}
/>
))}
</Pie>
</PieChart>
</ResponsiveContainer>
</div>
<PieLegend data={penerimaanData} colors={CATEGORY_COLORS} />
</div> </div>
</div> </div>
</> </>
+65 -15
View File
@@ -10,16 +10,50 @@ const LoginAdmin = () => {
const [errorMsg, setErrorMsg] = useState(""); const [errorMsg, setErrorMsg] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
// helper validasi sederhana
const validate = () => {
const trimmedEmail = email.trim();
const trimmedPassword = password.trim();
if (!trimmedEmail && !trimmedPassword) {
setErrorMsg("Field Email dan Password kosong, mohon diisi terlebih dahulu");
return false;
}
if (!trimmedEmail) {
setErrorMsg("Field Email kosong, mohon diisi terlebih dahulu");
return false;
}
if (!trimmedPassword) {
setErrorMsg("Field Password kosong, mohon diisi terlebih dahulu");
return false;
}
// opsional: validasi format email sederhana
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(trimmedEmail)) {
setErrorMsg("Format email tidak valid");
return false;
}
return true;
};
const handleLogin = async () => { const handleLogin = async () => {
// reset error dulu
setErrorMsg("");
// validasi sebelum request
if (!validate()) return;
setIsLoading(true); setIsLoading(true);
try { try {
const response = await axios.post( const response = await axios.post(
`${import.meta.env.VITE_API_BASE_URL}/auth-api/login`, `${import.meta.env.VITE_API_BASE_URL}/auth-api/login`,
{ email, password }, { email: email.trim(), password: password.trim() },
{ {
headers: { headers: { "Content-Type": "application/json" },
"Content-Type": "application/json",
},
} }
); );
@@ -30,14 +64,19 @@ const LoginAdmin = () => {
setErrorMsg(response.data.message || "Login failed"); setErrorMsg(response.data.message || "Login failed");
} }
} catch (error: any) { } catch (error: any) {
setErrorMsg( setErrorMsg(error?.response?.data?.message || "Something went wrong");
error?.response?.data?.message || "Something went wrong"
);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
// Enter submit: cukup pakai form onSubmit
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (isLoading) return;
handleLogin();
};
return ( return (
<div className="body-container min-h-screen bg-[#F7FAFC]"> <div className="body-container min-h-screen bg-[#F7FAFC]">
<div className="container-of-card flex flex-col min-h-screen gap-9 justify-center items-center"> <div className="container-of-card flex flex-col min-h-screen gap-9 justify-center items-center">
@@ -45,7 +84,11 @@ const LoginAdmin = () => {
<img src={logoblack} className="w-[250px] mb-4" alt="Logo" /> <img src={logoblack} className="w-[250px] mb-4" alt="Logo" />
</div> </div>
<div className="form-input bg-white rounded-3xl flex flex-col items-center gap-8 p-[30px] w-[30rem] shadow-2xl"> {/* Pakai form agar Enter bisa submit */}
<form
onSubmit={handleSubmit}
className="form-input bg-white rounded-3xl flex flex-col items-center gap-8 p-[30px] w-[30rem] shadow-2xl"
>
{/* Email */} {/* Email */}
<div className="username-section flex flex-col w-full gap-2"> <div className="username-section flex flex-col w-full gap-2">
<div className="title-of-username flex flex-row items-center gap-3"> <div className="title-of-username flex flex-row items-center gap-3">
@@ -70,7 +113,11 @@ const LoginAdmin = () => {
placeholder="Enter your email" placeholder="Enter your email"
className="w-full p-4 bg-[#EDF2F7] rounded-xl" className="w-full p-4 bg-[#EDF2F7] rounded-xl"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => {
setEmail(e.target.value);
if (errorMsg) setErrorMsg(""); // opsional: hapus warning saat user mulai mengetik
}}
autoComplete="email"
/> />
</div> </div>
@@ -99,7 +146,11 @@ const LoginAdmin = () => {
placeholder="Enter your password" placeholder="Enter your password"
className="w-full p-4 bg-[#EDF2F7] rounded-xl" className="w-full p-4 bg-[#EDF2F7] rounded-xl"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => {
setPassword(e.target.value);
if (errorMsg) setErrorMsg("");
}}
autoComplete="current-password"
/> />
</div> </div>
@@ -108,10 +159,10 @@ const LoginAdmin = () => {
<p className="text-red-600 text-sm font-medium">{errorMsg}</p> <p className="text-red-600 text-sm font-medium">{errorMsg}</p>
)} )}
{/* Sign In Button */} {/* Sign In Button: type="submit" biar form bisa Enter */}
<button <button
className="bg-[#2A4365] hover:bg-[#7f9bc3] cursor-pointer text-[20px] text-white w-full p-3 rounded-xl font-medium flex justify-center items-center gap-2" type="submit"
onClick={handleLogin} className="bg-[#2A4365] hover:bg-[#7f9bc3] cursor-pointer text-[20px] text-white w-full p-3 rounded-xl font-medium flex justify-center items-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed"
disabled={isLoading} disabled={isLoading}
> >
{isLoading ? ( {isLoading ? (
@@ -144,7 +195,6 @@ const LoginAdmin = () => {
</button> </button>
<h2 className="font-semibold flex flex-row items-center gap-2 text-red-700"> <h2 className="font-semibold flex flex-row items-center gap-2 text-red-700">
{" "}
<span> <span>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -163,7 +213,7 @@ const LoginAdmin = () => {
</span> </span>
Halaman Khusus Login Petugas HUMIC Halaman Khusus Login Petugas HUMIC
</h2> </h2>
</div> </form>
</div> </div>
</div> </div>
); );