fixed FAQ view button in ListFAQ, ongoing: Dashboard routing to BE
This commit is contained in:
+128
-31
@@ -1,53 +1,150 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import axios from "axios";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
const faqData = [
|
||||
{
|
||||
question: "Bagaimana cara magang disini?",
|
||||
answer: "Pilih Internship, Kemudian Lakukan Proses Administrasi",
|
||||
},
|
||||
{
|
||||
question: "Apakah saya bisa melamar lebih dari satu lowongan?",
|
||||
answer: "Tidak Bisa, Hanya Bisa Melamar 1 Lowongan",
|
||||
},
|
||||
{
|
||||
question: "Bagaimana saya tahu jika lamaran saya diterima atau ditolak?",
|
||||
answer: "Sistem Akan Mengirimkan Pesan Diterima/Ditolak Ke Email Anda",
|
||||
},
|
||||
{
|
||||
question: "Berapa lama durasi magang di Humic?",
|
||||
answer: "Cek Instagram Kita Yah, Disana Tertera Durasi Magangnya",
|
||||
},
|
||||
{
|
||||
question: "Apakah data pribadi saya aman?",
|
||||
answer: "Data Pribadi Dipastikan Aman, Sistem Ini Memiliki Admin Dashboard",
|
||||
},
|
||||
];
|
||||
type FaqItem = {
|
||||
id: number;
|
||||
pertanyaan: string;
|
||||
jawaban: string;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
const Faq = () => {
|
||||
const location = useLocation();
|
||||
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||
const [faqData, setFaqData] = useState<FaqItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const faqIdFromUrl = useMemo(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const raw = params.get("faq");
|
||||
const n = raw ? Number(raw) : NaN;
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}, [location.search]);
|
||||
|
||||
const toggleItem = (index: number) => {
|
||||
setOpenIndex(openIndex === index ? null : index);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFaq = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = document.cookie
|
||||
.split("; ")
|
||||
.find((row) => row.startsWith("token="))
|
||||
?.split("=")[1];
|
||||
|
||||
const response = await axios.get(
|
||||
`${import.meta.env.VITE_API_BASE_URL}/faq-api/get`,
|
||||
{
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data?.status) {
|
||||
const items: FaqItem[] = Array.isArray(response.data.data)
|
||||
? response.data.data
|
||||
: [];
|
||||
setFaqData(items);
|
||||
} else {
|
||||
setFaqData([]);
|
||||
setError(response.data?.message || "Gagal mengambil data FAQ.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Gagal mengambil data FAQ:", err);
|
||||
setFaqData([]);
|
||||
setError(
|
||||
err?.response?.data?.message ||
|
||||
"Terjadi kesalahan saat mengambil data FAQ."
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchFaq();
|
||||
}, []);
|
||||
|
||||
// Auto-scroll ke section FAQ kalau ada #faq
|
||||
useEffect(() => {
|
||||
if (location.hash === "#faq") {
|
||||
// wrapper ada di Landing: <div id="faq"><Faq/></div>
|
||||
// ini memastikan scroll halus
|
||||
const el = document.getElementById("faq");
|
||||
el?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}, [location.hash]);
|
||||
|
||||
// Auto-open FAQ tertentu kalau ?faq=ID ada dan data sudah ke-load
|
||||
useEffect(() => {
|
||||
if (!faqIdFromUrl) return;
|
||||
if (faqData.length === 0) return;
|
||||
|
||||
const idx = faqData.findIndex((f) => f.id === faqIdFromUrl);
|
||||
if (idx !== -1) {
|
||||
setOpenIndex(idx);
|
||||
|
||||
// Optional: setelah open, pastikan tetap terlihat
|
||||
setTimeout(() => {
|
||||
const el = document.getElementById("faq");
|
||||
el?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}, 0);
|
||||
}
|
||||
}, [faqIdFromUrl, faqData]);
|
||||
|
||||
return (
|
||||
<div className="hero-5-section flex flex-col lg:flex-row items-start lg:items-center justify-between mt-25 px-6 md:px-16 lg:px-[150px] pb-[60px] gap-10">
|
||||
{/* UI tetap sama persis */}
|
||||
<div className="content-of-5 w-full lg:w-[610px]">
|
||||
<h2 className="font-bold text-2xl md:text-3xl">Frequently Asked Questions</h2>
|
||||
<h2 className="font-bold text-2xl md:text-3xl">
|
||||
Frequently Asked Questions
|
||||
</h2>
|
||||
<p className="font-medium text-base mt-4">
|
||||
Berikut adalah informasi mengenai pertanyaan umum seputar program magang di Humic, termasuk tata cara pendaftaran dan persyaratan yang perlu dipenuhi. Masih ada yang perlu ditanyakan? Silahkan{" "}
|
||||
Berikut adalah informasi mengenai pertanyaan umum seputar program magang
|
||||
di Humic, termasuk tata cara pendaftaran dan persyaratan yang perlu
|
||||
dipenuhi. Masih ada yang perlu ditanyakan? Silahkan{" "}
|
||||
<span className="text-[#C3423F] font-semibold">DM</span> kami
|
||||
</p>
|
||||
|
||||
{loading && <p className="mt-4 text-sm text-gray-600">Memuat FAQ...</p>}
|
||||
{error && <p className="mt-4 text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="faq-content-list space-y-6 w-full lg:w-[700px]">
|
||||
{!loading && !error && faqData.length === 0 && (
|
||||
<div className="bg-[#FAF0EF] shadow-md rounded-sm p-4">
|
||||
<p className="text-sm text-gray-700">Belum ada FAQ.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{faqData.map((faq, index) => (
|
||||
<div key={index} className="bg-[#FAF0EF] shadow-md rounded-sm">
|
||||
<button onClick={() => toggleItem(index)} className="w-full flex items-center cursor-pointer justify-between p-4 focus:outline-none">
|
||||
<span className="text-[16px] font-semibold text-left text-[#C3423F]">{faq.question}</span>
|
||||
<span className="text-[20px] font-bold text-[#C3423F]">{openIndex === index ? "-" : "+"}</span>
|
||||
<div
|
||||
key={faq.id ?? index}
|
||||
className="bg-[#FAF0EF] shadow-md rounded-sm"
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleItem(index)}
|
||||
className="w-full flex items-center cursor-pointer justify-between p-4 focus:outline-none"
|
||||
>
|
||||
<span className="text-[16px] font-semibold text-left text-[#C3423F]">
|
||||
{faq.pertanyaan}
|
||||
</span>
|
||||
<span className="text-[20px] font-bold text-[#C3423F]">
|
||||
{openIndex === index ? "-" : "+"}
|
||||
</span>
|
||||
</button>
|
||||
<div className={`px-4 pb-4 text-black transition-all duration-300 ${openIndex === index ? "block" : "hidden"}`}>
|
||||
<p>{faq.answer}</p>
|
||||
|
||||
<div
|
||||
className={`px-4 pb-4 text-black transition-all duration-300 ${
|
||||
openIndex === index ? "block" : "hidden"
|
||||
}`}
|
||||
>
|
||||
<div dangerouslySetInnerHTML={{ __html: faq.jawaban }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -26,8 +26,6 @@ const NavbarAdmin = () => {
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
console.log("Response status:", response.status);
|
||||
console.log("Response body:", result);
|
||||
|
||||
if (response.ok && result.data?.nama_depan) {
|
||||
setNamaDepan(result.data.nama_depan);
|
||||
|
||||
@@ -11,7 +11,7 @@ const SidebarAdmin = () => {
|
||||
<div className="list-of-sidebar flex flex-col p-7 gap-4">
|
||||
<div
|
||||
className={`text-container flex flex-row gap-3 p-3 items-center cursor-pointer rounded-2xl ${
|
||||
location.pathname === "/product-list"
|
||||
location.pathname === "/dashboard"
|
||||
? "bg-[#D3DFEA] font-bold"
|
||||
: "hover:bg-[#D3DFEA] "
|
||||
}`}
|
||||
|
||||
+213
-47
@@ -1,6 +1,14 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer } from "recharts";
|
||||
import { Search, ChevronDown, Users, UserCheck, UserX, Clock } from "lucide-react";
|
||||
import {
|
||||
Search,
|
||||
ChevronDown,
|
||||
Users,
|
||||
UserCheck,
|
||||
UserX,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import axios, { AxiosError } from "axios";
|
||||
import NavbarAdmin from "../Layout/NavbarAdmin";
|
||||
import SidebarAdmin from "../Layout/SidebarAdmin";
|
||||
|
||||
@@ -17,104 +25,262 @@ type StatCard = {
|
||||
iconColor: string;
|
||||
};
|
||||
|
||||
type DashboardApiResponse = {
|
||||
totalPendaftar: number;
|
||||
totalDiterima: number;
|
||||
totalDitolak: number;
|
||||
totalDiproses: number;
|
||||
universitas: ChartDataItem[];
|
||||
negara: ChartDataItem[];
|
||||
// ====== BE RESPONSE TYPES ======
|
||||
type DashboardStatsResponse = {
|
||||
status: boolean;
|
||||
data: {
|
||||
total_pendaftar: number;
|
||||
total_diterima: number;
|
||||
total_ditolak: number;
|
||||
sedang_diproses: number;
|
||||
acceptance_rate: string;
|
||||
rejection_rate: string;
|
||||
pending_rate: string;
|
||||
};
|
||||
message: string;
|
||||
code?: number;
|
||||
};
|
||||
|
||||
type PositionStatsResponse = {
|
||||
status: boolean;
|
||||
data: Array<{
|
||||
posisi: string;
|
||||
total: number;
|
||||
diterima: number;
|
||||
ditolak: number;
|
||||
}>;
|
||||
message?: string;
|
||||
code?: number;
|
||||
};
|
||||
|
||||
type CountryStatsResponse = {
|
||||
status: boolean;
|
||||
data: Array<{
|
||||
negara: string;
|
||||
total: number;
|
||||
diterima: number;
|
||||
ditolak: number;
|
||||
diproses: number;
|
||||
}>;
|
||||
message?: string;
|
||||
code?: number;
|
||||
};
|
||||
|
||||
type BatchResponse = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: Array<{
|
||||
id: number;
|
||||
batch_number: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const UNIVERSITAS_COLORS = ["#001a66", "#334d99", "#6699cc", "#99b3ff"];
|
||||
const PENERIMAAN_COLORS = ["#0088ff", "#00cc00", "#ff3333"];
|
||||
const NEGARA_COLORS = ["#cc3333", "#dd6666", "#ee9999", "#f0b3b3", "#f5cccc"];
|
||||
|
||||
const API_BASE_URL =
|
||||
import.meta.env.VITE_API_BASE_URL?.trim() ||
|
||||
"https://internify-ruddy.vercel.app";
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const [statCards, setStatCards] = useState<StatCard[]>([]);
|
||||
const [universitasData, setUniversitasData] = useState<ChartDataItem[]>([]);
|
||||
const [penerimaanData, setPenerimaanData] = useState<ChartDataItem[]>([]);
|
||||
const [negaraData, setNegaraData] = useState<ChartDataItem[]>([]);
|
||||
|
||||
const [, setBatches] = useState<BatchResponse["data"]>([]);
|
||||
|
||||
const getTokenFromCookie = () => {
|
||||
return document.cookie
|
||||
.split("; ")
|
||||
.find((row) => row.startsWith("token="))
|
||||
?.split("=")[1];
|
||||
};
|
||||
|
||||
const api = useMemo(() => {
|
||||
const instance = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
instance.interceptors.request.use((config) => {
|
||||
const token = getTokenFromCookie();
|
||||
if (token) {
|
||||
config.headers = config.headers ?? {};
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
return instance;
|
||||
}, []);
|
||||
|
||||
const getAxiosErrorMessage = (err: unknown) => {
|
||||
if (!axios.isAxiosError(err)) {
|
||||
return "Terjadi kesalahan saat mengambil data dashboard.";
|
||||
}
|
||||
|
||||
const axErr = err as AxiosError<any>;
|
||||
const status = axErr.response?.status;
|
||||
|
||||
const msgFromBe =
|
||||
axErr.response?.data?.message ||
|
||||
axErr.response?.data?.error ||
|
||||
axErr.message;
|
||||
|
||||
if (status === 401) {
|
||||
return msgFromBe || "Unauthorized (401). Silakan login ulang.";
|
||||
}
|
||||
|
||||
return msgFromBe || `Request gagal (status ${status ?? "unknown"}).`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${import.meta.env.VITE_API_BASE_URL}/lowongan-magang-api/get`
|
||||
);
|
||||
const result = await response.json();
|
||||
setLoading(true);
|
||||
setErrorMessage("");
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("Gagal mengambil data dashboard:", result.message);
|
||||
setErrorMessage(result.message || "Gagal mengambil data dashboard.");
|
||||
const token = getTokenFromCookie();
|
||||
if (!token) {
|
||||
setErrorMessage(
|
||||
"Token tidak ditemukan di cookie. Silakan login ulang."
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const data: DashboardApiResponse = result.data;
|
||||
const [statsRes, positionRes, countryRes, batchRes] = await Promise.all(
|
||||
[
|
||||
api.get<DashboardStatsResponse>(
|
||||
"/lamaran-magang-api/statistics/dashboard"
|
||||
),
|
||||
api.get<PositionStatsResponse>(
|
||||
"/lamaran-magang-api/statistics/position"
|
||||
),
|
||||
api.get<CountryStatsResponse>(
|
||||
"/lamaran-magang-api/statistics/country"
|
||||
),
|
||||
api.get<BatchResponse>("/api/batch"),
|
||||
]
|
||||
);
|
||||
|
||||
const statsJson = statsRes.data;
|
||||
const positionJson = positionRes.data;
|
||||
const countryJson = countryRes.data;
|
||||
const batchJson = batchRes.data;
|
||||
|
||||
if (!statsJson?.status) {
|
||||
setErrorMessage(
|
||||
statsJson?.message ||
|
||||
"Gagal mengambil data dashboard (statistics/dashboard)."
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!positionJson?.status) {
|
||||
setErrorMessage(
|
||||
positionJson?.message ||
|
||||
"Gagal mengambil data dashboard (statistics/position)."
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!countryJson?.status) {
|
||||
setErrorMessage(
|
||||
countryJson?.message ||
|
||||
"Gagal mengambil data dashboard (statistics/country)."
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (batchJson?.success) {
|
||||
setBatches(batchJson.data || []);
|
||||
} else {
|
||||
console.warn("Fetch batch gagal:", batchJson?.message);
|
||||
}
|
||||
|
||||
const stats = statsJson.data;
|
||||
const positions = positionJson.data || [];
|
||||
const countries = countryJson.data || [];
|
||||
|
||||
const statCardsFromApi: StatCard[] = [
|
||||
{
|
||||
title: "Total Pendaftar",
|
||||
value: data.totalPendaftar,
|
||||
subtitle: "+200 dari batch sebelumnya", // sesuaikan bila BE sudah support
|
||||
value: stats.total_pendaftar,
|
||||
subtitle: "+200 dari batch sebelumnya",
|
||||
icon: <Users size={24} />,
|
||||
iconColor: "#0088ff",
|
||||
},
|
||||
{
|
||||
title: "Total Diterima",
|
||||
value: data.totalDiterima,
|
||||
subtitle: `${(
|
||||
(data.totalDiterima / (data.totalPendaftar || 1)) *
|
||||
100
|
||||
).toFixed(1)}% acceptance rate`,
|
||||
value: stats.total_diterima,
|
||||
subtitle: `${stats.acceptance_rate} acceptance rate`,
|
||||
icon: <UserCheck size={24} />,
|
||||
iconColor: "#00cc00",
|
||||
},
|
||||
{
|
||||
title: "Total Ditolak",
|
||||
value: data.totalDitolak,
|
||||
subtitle: `${(
|
||||
(data.totalDitolak / (data.totalPendaftar || 1)) *
|
||||
100
|
||||
).toFixed(1)}% rejection rate`,
|
||||
value: stats.total_ditolak,
|
||||
subtitle: `${stats.rejection_rate} rejection rate`,
|
||||
icon: <UserX size={24} />,
|
||||
iconColor: "#ff3333",
|
||||
},
|
||||
{
|
||||
title: "Sedang Diproses",
|
||||
value: data.totalDiproses,
|
||||
subtitle: `${(
|
||||
(data.totalDiproses / (data.totalPendaftar || 1)) *
|
||||
100
|
||||
).toFixed(1)}% pending review`,
|
||||
value: stats.sedang_diproses,
|
||||
subtitle: `${stats.pending_rate} pending review`,
|
||||
icon: <Clock size={24} />,
|
||||
iconColor: "#ffaa00",
|
||||
},
|
||||
];
|
||||
|
||||
const penerimaanFromApi: ChartDataItem[] = [
|
||||
{ name: "Total Pendaftar", value: data.totalPendaftar },
|
||||
{ name: "Total Diterima", value: data.totalDiterima },
|
||||
{ name: "Total Ditolak", value: data.totalDitolak },
|
||||
{ name: "Total Pendaftar", value: stats.total_pendaftar },
|
||||
{ name: "Total Diterima", value: stats.total_diterima },
|
||||
{ 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);
|
||||
setUniversitasData(data.universitas || []);
|
||||
setPenerimaanData(penerimaanFromApi);
|
||||
setNegaraData(data.negara || []);
|
||||
setUniversitasData(universitasFromApi);
|
||||
setNegaraData(negaraFromApi);
|
||||
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error("Terjadi kesalahan saat mengambil data dashboard:", error);
|
||||
setErrorMessage("Terjadi kesalahan saat mengambil data dashboard.");
|
||||
} catch (err) {
|
||||
console.error("Terjadi kesalahan saat mengambil data dashboard:", err);
|
||||
setErrorMessage(getAxiosErrorMessage(err));
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
console.log("AXIOS BASE URL:", API_BASE_URL);
|
||||
|
||||
fetchDashboardData();
|
||||
}, []);
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full overflow-hidden">
|
||||
@@ -149,7 +315,9 @@ const Dashboard: React.FC = () => {
|
||||
|
||||
{/* Loading / Error State */}
|
||||
{loading && (
|
||||
<p className="text-center text-gray-600">Memuat data dashboard...</p>
|
||||
<p className="text-center text-gray-600">
|
||||
Memuat data dashboard...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loading && errorMessage && (
|
||||
@@ -329,9 +497,7 @@ const Dashboard: React.FC = () => {
|
||||
<Cell
|
||||
key={`negara-cell-${index}`}
|
||||
fill={
|
||||
NEGARA_COLORS[
|
||||
index % NEGARA_COLORS.length
|
||||
]
|
||||
NEGARA_COLORS[index % NEGARA_COLORS.length]
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -216,7 +216,9 @@ const Landing = () => {
|
||||
</div>
|
||||
|
||||
{/* Hero 5 Section FaQ */}
|
||||
<Faq />
|
||||
<div id="faq">
|
||||
<Faq />
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<Footer />
|
||||
|
||||
@@ -117,7 +117,7 @@ const ListFaq = () => {
|
||||
<button
|
||||
className="bg-[#3BE21D] cursor-pointer text-white p-2 rounded-xl"
|
||||
onClick={() =>
|
||||
window.open(`/details-product/${faq.id}`, "_blank")
|
||||
window.open(`/?faq=${faq.id}#faq`, "_blank")
|
||||
}
|
||||
>
|
||||
<svg
|
||||
|
||||
Reference in New Issue
Block a user