minor update: mentor-generate sertifikat: tambahin status jika intern sudah ada sertifikat

This commit is contained in:
Kemas M. Aryadary Rasyad
2026-07-21 09:10:47 +07:00
parent 7f4e149819
commit 4170a93173
5 changed files with 68 additions and 39 deletions
@@ -13,7 +13,7 @@ export default function CertificateDetail() {
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
const { project, loading, error, refetch } = useProjectDetail(slug ?? ""); const { project, loading, error, refetch } = useProjectDetail(slug ?? "");
const { upload, loading: uploading, error: uploadError } = useUploadCertificateTemplate(); const { upload, loading: uploading, error: uploadError } = useUploadCertificateTemplate();
const { data: internProgress, loading: progressLoading } = useProjectInternProgress(slug ?? ""); const { data: internProgress, loading: progressLoading } = useProjectInternProgress(project?.id ?? 0);
const eligibleCount = internProgress.filter(i => i.is_eligible).length; const eligibleCount = internProgress.filter(i => i.is_eligible).length;
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -7,6 +7,7 @@ import {
Eye, Eye,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
BadgeCheck,
Sparkles, Sparkles,
Check, Check,
Loader2 Loader2
@@ -43,7 +44,7 @@ function formatDateRange(startDate: string, endDate: string): string {
return `${formatDateIndo(startDate)} - ${formatDateIndo(endDate)}`; return `${formatDateIndo(startDate)} - ${formatDateIndo(endDate)}`;
} }
function ProgressBar({ value, total }: {value: number, total: number}) { function ProgressBar({ value, total }: { value: number, total: number }) {
const pct = total === 0 ? 0 : Math.round((value / total) * 100); const pct = total === 0 ? 0 : Math.round((value / total) * 100);
const isComplete = value === total; const isComplete = value === total;
@@ -62,13 +63,31 @@ function ProgressBar({ value, total }: {value: number, total: number}) {
); );
} }
function StatusBadge({ status }: { status: boolean }) { type BadgeState = "certified" | "eligible" | "incomplete";
function StatusBadge({ state }: { state: BadgeState }) {
const config = {
certified: {
className: "text-gray-500",
icon: <BadgeCheck className="w-3.5 h-3.5" />,
label: "Sudah Ada",
},
eligible: {
className: "text-green-600",
icon: <CheckCircle2 className="w-3.5 h-3.5" />,
label: "Eligible",
},
incomplete: {
className: "text-orange-500",
icon: <AlertCircle className="w-3.5 h-3.5" />,
label: "Incomplete",
},
}[state];
return ( return (
<div className={`flex items-center gap-1 text-[11px] font-extrabold tracking-wide ${status ? "text-green-600" : "text-orange-500"}`}> <div className={`flex items-center gap-1 text-[11px] font-extrabold tracking-wide ${config.className}`}>
{status {config.icon}
? <CheckCircle2 className="w-3.5 h-3.5" /> {config.label}
: <AlertCircle className="w-3.5 h-3.5" />}
{status ? "Eligible" : "Incomplete"}
</div> </div>
); );
} }
@@ -84,7 +103,7 @@ export default function EligibleInternTable({
const { generate: SubmitCertificates } = useGenerateCertificates(); const { generate: SubmitCertificates } = useGenerateCertificates();
const initialSelected = new Set( const initialSelected = new Set(
interns.filter(i => i.is_eligible).map(i => i.id_user) interns.filter(i => i.is_eligible && !i.has_certificate).map(i => i.id_user)
); );
const [selected, setSelected] = useState<Set<number>>(initialSelected); const [selected, setSelected] = useState<Set<number>>(initialSelected);
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
@@ -99,7 +118,7 @@ export default function EligibleInternTable({
id: intern.id_user, id: intern.id_user,
id_project: project.id, id_project: project.id,
id_user: intern.id_user, id_user: intern.id_user,
certificate_no: "CERT/YYDDMM/XXX", certificate_no: "CERT/yymmxx/XXX",
issued_at: new Date().toISOString().slice(0, 10), issued_at: new Date().toISOString().slice(0, 10),
uuid: "HANYA SEBATAS PREVIEW", uuid: "HANYA SEBATAS PREVIEW",
user: { user: {
@@ -121,11 +140,11 @@ export default function EligibleInternTable({
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const totalPages = Math.ceil(interns.length / PER_PAGE); const totalPages = Math.ceil(interns.length / PER_PAGE);
const paginated = interns.slice((page - 1) * PER_PAGE, page * 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 selectableIds = interns.filter(i => i.is_eligible && !i.has_certificate).map(i => i.id_user);
const allEligibleSelected = eligibleIds.every(id => selected.has(id)); const allEligibleSelected = selectableIds.length > 0 && selectableIds.every(id => selected.has(id));
const toggleSelectAll = () => { const toggleSelectAll = () => {
setSelected(allEligibleSelected ? new Set() : new Set(eligibleIds)); setSelected(allEligibleSelected ? new Set() : new Set(selectableIds));
}; };
const toggleOne = (id: number, isEligible: boolean) => { const toggleOne = (id: number, isEligible: boolean) => {
@@ -242,6 +261,8 @@ export default function EligibleInternTable({
{/* Rows */} {/* Rows */}
<div className="divide-y divide-gray-50"> <div className="divide-y divide-gray-50">
{paginated.map((intern) => { {paginated.map((intern) => {
const isCertified = intern.has_certificate;
const isSelectable = intern.is_eligible && !isCertified
const isChecked = selected.has(intern.id_user); const isChecked = selected.has(intern.id_user);
const isIncomplete = !intern.is_eligible; const isIncomplete = !intern.is_eligible;
const initials = intern.full_name const initials = intern.full_name
@@ -257,8 +278,8 @@ export default function EligibleInternTable({
> >
{/* Checkbox */} {/* Checkbox */}
<button <button
onClick={() => toggleOne(intern.id_user, intern.is_eligible)} onClick={() => toggleOne(intern.id_user, isSelectable)}
disabled={isIncomplete} disabled={!isSelectable}
className={`w-4 h-4 rounded border-2 flex items-center justify-center flex-shrink-0 transition-colors ${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" ? "border-gray-200 bg-gray-50 cursor-not-allowed"
: isChecked : isChecked
@@ -266,8 +287,12 @@ export default function EligibleInternTable({
: "border-gray-300 hover:border-gray-400" : "border-gray-300 hover:border-gray-400"
}`} }`}
> >
{isChecked && !isIncomplete && ( {isCertified ? (
<Check className="w-2.5 h-2.5 text-gray-400" strokeWidth={3} />
) : (
isChecked && !isIncomplete && (
<Check className="w-2.5 h-2.5 text-white" strokeWidth={3} /> <Check className="w-2.5 h-2.5 text-white" strokeWidth={3} />
)
)} )}
</button> </button>
@@ -291,13 +316,17 @@ export default function EligibleInternTable({
{/* Status */} {/* Status */}
<div className="flex justify-center"> <div className="flex justify-center">
<StatusBadge status={intern.is_eligible} /> <StatusBadge
state={
isCertified ? "certified" : intern.is_eligible ? "eligible" : "incomplete"
}
/>
</div> </div>
{/* Action */} {/* Action */}
<div className="flex justify-center"> <div className="flex justify-center">
{isIncomplete ? ( {isIncomplete && !isCertified ? (
<button className="text-[11px] font-extrabold text-[#B30000] hover:underline whitespace-nowrap"> <button className="text-[11px] font-extrabold text-red hover:underline whitespace-nowrap">
Nag Intern Nag Intern
</button> </button>
) : ( ) : (
@@ -379,7 +408,6 @@ export default function EligibleInternTable({
</> </>
)} )}
</button> </button>
</div> </div>
</div> </div>
); );
+1 -1
View File
@@ -176,5 +176,5 @@ export const useCertificateVerification = (uuid: string) => {
}, [uuid]); }, [uuid]);
return { verification, loading, error }; return { verification, loading, error };
} }
+17 -8
View File
@@ -1,6 +1,7 @@
// hooks/useProjectInternProgress.ts // hooks/useInternProgress.ts
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { getProjectTasks, getTaskSubmissions } from "../services/TaskService"; import { getProjectTasks, getTaskSubmissions } from "../services/TaskService";
import { getCertificatesByProject } from "../services/CertificateService";
import type { AdminTaskDetail } from "../types/task.types"; import type { AdminTaskDetail } from "../types/task.types";
export interface InternSubmissionProgress { export interface InternSubmissionProgress {
@@ -11,24 +12,32 @@ export interface InternSubmissionProgress {
submitted: number; submitted: number;
total_tasks: number; total_tasks: number;
is_eligible: boolean; is_eligible: boolean;
has_certificate: boolean;
} }
export const useProjectInternProgress = (projectSlug: string) => { export const useProjectInternProgress = (projectid: number) => {
const [data, setData] = useState<InternSubmissionProgress[]>([]); const [data, setData] = useState<InternSubmissionProgress[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const refetch = useCallback(async () => { const refetch = useCallback(async () => {
if (!projectSlug) return; if (!projectid) return;
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const tasks = await getProjectTasks(projectSlug); const [tasks, certificates] = await Promise.all([
getProjectTasks(projectid),
getCertificatesByProject(projectid).catch(() => []),
]);
const certifiedUserIds = new Set(
(certificates ?? []).map((cert) => cert.id_user)
);
const taskDetails = await Promise.all( const taskDetails = await Promise.all(
tasks.map((task) => tasks.map((task) =>
getTaskSubmissions(task.slug, projectSlug) as Promise<AdminTaskDetail> getTaskSubmissions(task.slug, projectid) as Promise<AdminTaskDetail>
) )
); );
@@ -38,7 +47,6 @@ export const useProjectInternProgress = (projectSlug: string) => {
if (!taskDetail?.submissions) continue; if (!taskDetail?.submissions) continue;
for (const sub of taskDetail.submissions) { for (const sub of taskDetail.submissions) {
// field yang benar: sub.submission (bukan submission_details)
const hasSubmitted = !!sub.submission; const hasSubmitted = !!sub.submission;
const existing = internMap.get(sub.id_user); const existing = internMap.get(sub.id_user);
@@ -50,10 +58,11 @@ export const useProjectInternProgress = (projectSlug: string) => {
id_user: sub.id_user, id_user: sub.id_user,
full_name: sub.full_name, full_name: sub.full_name,
email: sub.email, email: sub.email,
avatar: sub.profile_picture, // field yang benar avatar: sub.profile_picture,
submitted: hasSubmitted ? 1 : 0, submitted: hasSubmitted ? 1 : 0,
total_tasks: 1, total_tasks: 1,
is_eligible: false, is_eligible: false,
has_certificate: certifiedUserIds.has(sub.id_user),
}); });
} }
} }
@@ -71,7 +80,7 @@ export const useProjectInternProgress = (projectSlug: string) => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [projectSlug]); }, [projectid]);
useEffect(() => { useEffect(() => {
refetch(); refetch();
-8
View File
@@ -108,14 +108,6 @@ export const getInitials = (fullName?: string | null): string => {
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase(); return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
}; };
// Untuk resolve gambar (duplikat dari utils/...)
// export const resolveFileUrl = (path?: string | null): string | null => {
// if (!path) return null;
// if (/^(https?:|data:|blob:)/i.test(path)) return path;
// const base = import.meta.env.VITE_API_BASE_URL as string;
// return `${base}${path.startsWith("/") ? path : `/${path}`}`;
// };
// PATCH /auth-api/update-profile // PATCH /auth-api/update-profile
export const useUpdateProfile = () => { export const useUpdateProfile = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);