Files
internify-fe/src/Pages/internify/mentor/components/EligibleInternTable.tsx
T

320 lines
12 KiB
TypeScript

// src/pages/mentor/EligibleInternTable.tsx
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
CheckCircle2,
AlertCircle,
Eye,
ChevronLeft,
ChevronRight,
Sparkles,
Check,
Loader2
} from "lucide-react";
import type { InternSubmissionProgress } from "../../../../hooks/useInternProgress";
import JSZip from "jszip";
import { generateCertificate, resolveImageUrl } from "../../../utils/SertificateGenerator";
import { useGenerateCertificates } from "../../../../hooks/useCertificates";
import Avatar from "../../Avatar";
import ProgressBar from "../../ProgressBar";
import { customToast } from "../../../utils/showToast";
import type { Project } from "../../../../types/project.types";
interface EligibleInternTableProps {
interns: InternSubmissionProgress[];
loading: boolean;
eligibleCount: number;
project: Project
}
const PER_PAGE = 5;
function StatusBadge({ status }: { status: boolean }) {
return (
<div className={`flex items-center gap-1 text-[11px] font-extrabold tracking-wide ${status ? "text-green-600" : "text-orange-500"}`}>
{status
? <CheckCircle2 className="w-3.5 h-3.5" />
: <AlertCircle className="w-3.5 h-3.5" />}
{status ? "Eligible" : "Incomplete"}
</div>
);
}
// Main Component
export default function EligibleInternTable({
interns,
loading,
eligibleCount,
project
// Durasi tanggal Proyek
}: EligibleInternTableProps) {
const navigate = useNavigate();
// const { project } = useProjectDetail(String(projectId));
const { generate: SubmitCertificates } = useGenerateCertificates();
const initialSelected = new Set(
interns.filter(i => i.is_eligible).map(i => i.id_user)
);
const [selected, setSelected] = useState<Set<number>>(initialSelected);
const [generating, setGenerating] = useState(false);
const [generateError, setGenerateError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const totalPages = Math.ceil(interns.length / PER_PAGE);
const paginated = interns.slice((page - 1) * PER_PAGE, page * PER_PAGE);
const eligibleIds = interns.filter(i => i.is_eligible).map(i => i.id_user);
const allEligibleSelected = eligibleIds.every(id => selected.has(id));
const toggleSelectAll = () => {
setSelected(allEligibleSelected ? new Set() : new Set(eligibleIds));
};
const toggleOne = (id: number, isEligible: boolean) => {
if (!isEligible) return;
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
const handleGenerate = async () => {
if (!project?.certificate_template) {
customToast.error("Template belum ada", "Template sertifikat belum diupload.");
return;
}
if (selected.size === 0) {
customToast.error("Tidak ada intern dipilih", "Pilih minimal satu intern untuk generate sertifikat.");
return;
}
setGenerating(true);
setGenerateError(null);
const ok = await customToast.promise(
(async () => {
const result = await SubmitCertificates(project.id, Array.from(selected));
if (!result) throw new Error("Tidak ada data sertifikat yang dikembalikan.");
const zip = new JSZip();
const templateUrl = resolveImageUrl(project.certificate_template!);
await Promise.all(
result.map(async (cert) => {
const verifyUrl = `${window.location.origin}/verify-certificate/${cert.uuid}`;
const blob = await generateCertificate(
templateUrl,
cert.user.full_name,
cert.project.project_name,
cert.certificate_no,
verifyUrl,
"20 Juni 2024 - 20 September 2024"
);
zip.file(`Sertifikat - ${cert.user.full_name}.png`, blob);
})
);
})(),
{
loading: "Generating sertifikat...",
success: () => ({
title: "Sertifikat berhasil dibuat",
description: `${selected.size} sertifikat berhasil digenerate.`,
}),
error: (err) => ({
title: "Gagal generate sertifikat",
description: err instanceof Error ? err.message : "Terjadi kesalahan, coba lagi.",
}),
}
).then(() => true).catch(() => false);
setGenerating(false);
if (ok) navigate(`/mentor/certificates/${project.id}/result`);
};
if (loading) {
return (
<div className="lg:col-span-5 flex items-center justify-center py-20 text-gray-400 text-sm">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
Memuat data intern...
</div>
);
}
return (
<div className="lg:col-span-5 flex flex-col gap-4">
{/* Roster Card */}
<div className="border border-box-border rounded-2xl overflow-hidden bg-white shadow-sm">
<div className="flex items-start justify-between px-5 py-4 border-b border-gray-100">
<div>
<h3 className="text-base font-bold text-gray-900">Intern Roster</h3>
<p className="text-xs text-gray-400 mt-0.5 leading-snug max-w-xs">
Confirm completion status before final certificate generation.
</p>
</div>
<button
onClick={toggleSelectAll}
className="flex items-center gap-2 border border-gray-200 rounded-xl px-4 py-2.5 bg-white hover:bg-gray-50 transition-colors flex-shrink-0 ml-4"
>
<div className={`w-4 h-4 rounded border-2 flex items-center justify-center flex-shrink-0 transition-colors ${allEligibleSelected ? "bg-[#B30000] border-[#B30000]" : "border-gray-300"
}`}>
{allEligibleSelected && (
<Check className="w-2.5 h-2.5 text-white" strokeWidth={3} />
)}
</div>
<span className="text-xs font-bold text-gray-700 whitespace-nowrap">
Select All Eligible ({eligibleCount})
</span>
</button>
</div>
{/* Column Headers */}
<div className="grid grid-cols-[16px_1fr_96px_88px_60px] items-center gap-x-3 px-5 py-2.5 bg-gray-50/70 border-b border-gray-100">
<div />
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Intern Name</span>
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider text-center">Task Progress</span>
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider text-center">Status</span>
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider text-center">Action</span>
</div>
{/* Rows */}
<div className="divide-y divide-gray-50">
{paginated.map((intern) => {
const isChecked = selected.has(intern.id_user);
const isIncomplete = !intern.is_eligible;
const initials = intern.full_name
.split(" ")
.slice(0, 2)
.map(n => n[0])
.join("")
.toUpperCase();
return (
<div
key={intern.id_user}
className="grid grid-cols-[16px_1fr_96px_88px_60px] items-center gap-x-3 px-5 py-3.5 hover:bg-gray-50/40 transition-colors"
>
{/* Checkbox */}
<button
onClick={() => toggleOne(intern.id_user, intern.is_eligible)}
disabled={isIncomplete}
className={`w-4 h-4 rounded border-2 flex items-center justify-center flex-shrink-0 transition-colors ${isIncomplete
? "border-gray-200 bg-gray-50 cursor-not-allowed"
: isChecked
? "bg-[#B30000] border-[#B30000]"
: "border-gray-300 hover:border-gray-400"
}`}
>
{isChecked && !isIncomplete && (
<Check className="w-2.5 h-2.5 text-white" strokeWidth={3} />
)}
</button>
{/* Name + role */}
<div className="flex items-center gap-3">
{intern.avatar ? (
<img src={intern.avatar} className="w-10 h-10 rounded-full object-cover" />
) : (
<Avatar initials={initials} />
)}
<div>
<p className="text-sm font-bold text-gray-900">{intern.full_name}</p>
<p className="text-[11px] text-gray-400">{intern.email}</p>
</div>
</div>
{/* Task Progress */}
<div className="flex justify-center">
<ProgressBar value={intern.submitted} total={intern.total_tasks} />
</div>
{/* Status */}
<div className="flex justify-center">
<StatusBadge status={intern.is_eligible} />
</div>
{/* Action */}
<div className="flex justify-center">
{isIncomplete ? (
<button className="text-[11px] font-extrabold text-[#B30000] hover:underline whitespace-nowrap">
Nag Intern
</button>
) : (
<button className="p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-md transition-colors" title="View">
<Eye className="w-4 h-4" />
</button>
)}
</div>
</div>
);
})}
</div>
{/* Pagination Footer */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 px-5 py-3.5 border-t border-gray-100 bg-gray-50/30">
<p className="text-xs text-gray-400 font-medium">
Showing {(page - 1) * PER_PAGE + 1} to {Math.min(page * PER_PAGE, interns.length)} of {interns.length} entries
</p>
<div className="flex items-center gap-1">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="p-1 border border-gray-200 rounded-md bg-white text-gray-400 hover:bg-gray-50 disabled:opacity-40 transition-colors"
>
<ChevronLeft className="w-4 h-4" />
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (
<button
key={n}
onClick={() => setPage(n)}
className={`w-7 h-7 flex items-center justify-center rounded-md text-xs font-bold transition-colors ${n === page
? "bg-[#B30000] text-white shadow-sm"
: "bg-white border border-gray-200 text-gray-600 hover:bg-gray-50"
}`}
>
{n}
</button>
))}
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="p-1 border border-gray-200 rounded-md bg-white text-gray-400 hover:bg-gray-50 disabled:opacity-40 transition-colors"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
</div>
{generateError && (
<p className="text-xs text-red-500 font-medium text-right">{generateError}</p>
)}
{/* Bottom Action Bar */}
<div className="flex items-center justify-between">
<button
className="text-gray-700 hover:underline"
onClick={() => navigate(`/mentor/certificates/${project?.slug}/result`)}>
Ke Hasil
</button>
<button
onClick={handleGenerate}
disabled={generating || selected.size === 0}
className="flex items-center gap-2 px-8 py-3.5 bg-[#B30000] hover:bg-red-800 disabled:opacity-60 text-white font-bold text-sm rounded-xl shadow-md transition-colors"
>
{generating ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Generating...
</>
) : (
<>
Generate Certificates
<Sparkles className="w-4 h-4" />
</>
)}
</button>
</div>
</div>
);
}