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 { project, loading, error, refetch } = useProjectDetail(slug ?? "");
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 fileInputRef = useRef<HTMLInputElement>(null);
@@ -7,6 +7,7 @@ import {
Eye,
ChevronLeft,
ChevronRight,
BadgeCheck,
Sparkles,
Check,
Loader2
@@ -43,7 +44,7 @@ function formatDateRange(startDate: string, endDate: string): string {
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 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 (
<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 className={`flex items-center gap-1 text-[11px] font-extrabold tracking-wide ${config.className}`}>
{config.icon}
{config.label}
</div>
);
}
@@ -84,7 +103,7 @@ export default function EligibleInternTable({
const { generate: SubmitCertificates } = useGenerateCertificates();
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 [generating, setGenerating] = useState(false);
@@ -99,7 +118,7 @@ export default function EligibleInternTable({
id: intern.id_user,
id_project: project.id,
id_user: intern.id_user,
certificate_no: "CERT/YYDDMM/XXX",
certificate_no: "CERT/yymmxx/XXX",
issued_at: new Date().toISOString().slice(0, 10),
uuid: "HANYA SEBATAS PREVIEW",
user: {
@@ -121,11 +140,11 @@ export default function EligibleInternTable({
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 selectableIds = interns.filter(i => i.is_eligible && !i.has_certificate).map(i => i.id_user);
const allEligibleSelected = selectableIds.length > 0 && selectableIds.every(id => selected.has(id));
const toggleSelectAll = () => {
setSelected(allEligibleSelected ? new Set() : new Set(eligibleIds));
setSelected(allEligibleSelected ? new Set() : new Set(selectableIds));
};
const toggleOne = (id: number, isEligible: boolean) => {
@@ -158,7 +177,7 @@ export default function EligibleInternTable({
const zip = new JSZip();
const templateUrl = resolveFileUrl(project.certificate_template!);
if (!templateUrl) throw new Error("Template sertifikat tidak ditemukan.");
const duration = formatDateRange(project.start_date, project.end_date);
await Promise.all(
@@ -242,6 +261,8 @@ export default function EligibleInternTable({
{/* Rows */}
<div className="divide-y divide-gray-50">
{paginated.map((intern) => {
const isCertified = intern.has_certificate;
const isSelectable = intern.is_eligible && !isCertified
const isChecked = selected.has(intern.id_user);
const isIncomplete = !intern.is_eligible;
const initials = intern.full_name
@@ -257,8 +278,8 @@ export default function EligibleInternTable({
>
{/* Checkbox */}
<button
onClick={() => toggleOne(intern.id_user, intern.is_eligible)}
disabled={isIncomplete}
onClick={() => toggleOne(intern.id_user, isSelectable)}
disabled={!isSelectable}
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
@@ -266,8 +287,12 @@ export default function EligibleInternTable({
: "border-gray-300 hover:border-gray-400"
}`}
>
{isChecked && !isIncomplete && (
<Check className="w-2.5 h-2.5 text-white" strokeWidth={3} />
{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} />
)
)}
</button>
@@ -291,13 +316,17 @@ export default function EligibleInternTable({
{/* Status */}
<div className="flex justify-center">
<StatusBadge status={intern.is_eligible} />
<StatusBadge
state={
isCertified ? "certified" : intern.is_eligible ? "eligible" : "incomplete"
}
/>
</div>
{/* Action */}
<div className="flex justify-center">
{isIncomplete ? (
<button className="text-[11px] font-extrabold text-[#B30000] hover:underline whitespace-nowrap">
{isIncomplete && !isCertified ? (
<button className="text-[11px] font-extrabold text-red hover:underline whitespace-nowrap">
Nag Intern
</button>
) : (
@@ -379,7 +408,6 @@ export default function EligibleInternTable({
</>
)}
</button>
</div>
</div>
);
+1 -1
View File
@@ -176,5 +176,5 @@ export const useCertificateVerification = (uuid: string) => {
}, [uuid]);
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 { getProjectTasks, getTaskSubmissions } from "../services/TaskService";
import { getCertificatesByProject } from "../services/CertificateService";
import type { AdminTaskDetail } from "../types/task.types";
export interface InternSubmissionProgress {
@@ -11,24 +12,32 @@ export interface InternSubmissionProgress {
submitted: number;
total_tasks: number;
is_eligible: boolean;
has_certificate: boolean;
}
export const useProjectInternProgress = (projectSlug: string) => {
export const useProjectInternProgress = (projectid: number) => {
const [data, setData] = useState<InternSubmissionProgress[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refetch = useCallback(async () => {
if (!projectSlug) return;
if (!projectid) return;
setLoading(true);
setError(null);
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(
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;
for (const sub of taskDetail.submissions) {
// field yang benar: sub.submission (bukan submission_details)
const hasSubmitted = !!sub.submission;
const existing = internMap.get(sub.id_user);
@@ -50,10 +58,11 @@ export const useProjectInternProgress = (projectSlug: string) => {
id_user: sub.id_user,
full_name: sub.full_name,
email: sub.email,
avatar: sub.profile_picture, // field yang benar
avatar: sub.profile_picture,
submitted: hasSubmitted ? 1 : 0,
total_tasks: 1,
is_eligible: false,
has_certificate: certifiedUserIds.has(sub.id_user),
});
}
}
@@ -71,7 +80,7 @@ export const useProjectInternProgress = (projectSlug: string) => {
} finally {
setLoading(false);
}
}, [projectSlug]);
}, [projectid]);
useEffect(() => {
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();
};
// 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
export const useUpdateProfile = () => {
const [loading, setLoading] = useState(false);