minor update: mentor-generate sertifikat: tambahin status jika intern sudah ada sertifikat
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -176,5 +176,5 @@ export const useCertificateVerification = (uuid: string) => {
|
||||
}, [uuid]);
|
||||
|
||||
return { verification, loading, error };
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user