added new universitas chart, more chart colors variety, added new warning for login field
This commit is contained in:
+195
-149
@@ -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,35 +418,67 @@ 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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs" style={{ color: card.iconColor }}>
|
|
||||||
{card.subtitle}
|
|
||||||
</p>
|
|
||||||
</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">
|
||||||
|
<h2 className="mb-6 text-lg font-bold text-gray-900">
|
||||||
|
Position
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="mb-6 flex justify-center">
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={positionData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={0}
|
||||||
|
outerRadius={85}
|
||||||
|
paddingAngle={2}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{positionData.map((_, index) => (
|
||||||
|
<Cell
|
||||||
|
key={`position-cell-${index}`}
|
||||||
|
fill={
|
||||||
|
CATEGORY_COLORS[
|
||||||
|
index % CATEGORY_COLORS.length
|
||||||
|
]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PieLegend data={positionData} colors={CATEGORY_COLORS} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2) Universitas */}
|
||||||
<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
|
Universitas
|
||||||
</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={universitasData}
|
||||||
cx="50%"
|
cx="50%"
|
||||||
cy="50%"
|
cy="50%"
|
||||||
innerRadius={0}
|
innerRadius={0}
|
||||||
outerRadius={100}
|
outerRadius={85}
|
||||||
paddingAngle={2}
|
paddingAngle={2}
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
>
|
>
|
||||||
@@ -378,8 +486,8 @@ const Dashboard: React.FC = () => {
|
|||||||
<Cell
|
<Cell
|
||||||
key={`universitas-cell-${index}`}
|
key={`universitas-cell-${index}`}
|
||||||
fill={
|
fill={
|
||||||
UNIVERSITAS_COLORS[
|
CATEGORY_COLORS[
|
||||||
index % UNIVERSITAS_COLORS.length
|
index % CATEGORY_COLORS.length
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -389,50 +497,59 @@ const Dashboard: React.FC = () => {
|
|||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legend */}
|
<PieLegend
|
||||||
<div className="space-y-3 text-sm">
|
data={universitasData}
|
||||||
{universitasData.map((item, index) => (
|
colors={CATEGORY_COLORS}
|
||||||
<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 */}
|
{/* 3) Negara */}
|
||||||
<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">
|
||||||
|
Negara
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="mb-6 flex justify-center">
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={negaraData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={0}
|
||||||
|
outerRadius={85}
|
||||||
|
paddingAngle={2}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{negaraData.map((_, index) => (
|
||||||
|
<Cell
|
||||||
|
key={`negara-cell-${index}`}
|
||||||
|
fill={CATEGORY_COLORS[index % CATEGORY_COLORS.length]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PieLegend data={negaraData} colors={CATEGORY_COLORS} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 4) Penerimaan */}
|
||||||
<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">
|
||||||
Penerimaan
|
Penerimaan
|
||||||
</h2>
|
</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={penerimaanData}
|
||||||
cx="50%"
|
cx="50%"
|
||||||
cy="50%"
|
cy="50%"
|
||||||
innerRadius={0}
|
innerRadius={0}
|
||||||
outerRadius={70}
|
outerRadius={85}
|
||||||
paddingAngle={2}
|
paddingAngle={2}
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
>
|
>
|
||||||
@@ -440,8 +557,8 @@ const Dashboard: React.FC = () => {
|
|||||||
<Cell
|
<Cell
|
||||||
key={`penerimaan-cell-${index}`}
|
key={`penerimaan-cell-${index}`}
|
||||||
fill={
|
fill={
|
||||||
PENERIMAAN_COLORS[
|
CATEGORY_COLORS[
|
||||||
index % PENERIMAAN_COLORS.length
|
index % CATEGORY_COLORS.length
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -451,78 +568,7 @@ const Dashboard: React.FC = () => {
|
|||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legend */}
|
<PieLegend data={penerimaanData} colors={CATEGORY_COLORS} />
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
{penerimaanData.map((item, index) => (
|
|
||||||
<div
|
|
||||||
key={`penerimaan-legend-${index}`}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="h-3 w-3 rounded"
|
|
||||||
style={{
|
|
||||||
backgroundColor:
|
|
||||||
PENERIMAAN_COLORS[
|
|
||||||
index % PENERIMAAN_COLORS.length
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="text-gray-700">{item.name}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Negara Chart */}
|
|
||||||
<div className="rounded-lg border border-gray-300 bg-white p-6">
|
|
||||||
<h2 className="mb-6 text-lg font-bold text-gray-900">
|
|
||||||
Negara
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="mb-6 flex justify-center">
|
|
||||||
<ResponsiveContainer width="100%" height={180}>
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={negaraData}
|
|
||||||
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="space-y-2 text-sm">
|
|
||||||
{negaraData.map((item, index) => (
|
|
||||||
<div
|
|
||||||
key={`negara-legend-${index}`}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="h-3 w-3 rounded"
|
|
||||||
style={{
|
|
||||||
backgroundColor:
|
|
||||||
NEGARA_COLORS[index % NEGARA_COLORS.length],
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="text-gray-700">{item.name}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+65
-15
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user