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 { 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-white" strokeWidth={3} />
|
<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>
|
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -176,5 +176,5 @@ export const useCertificateVerification = (uuid: string) => {
|
|||||||
}, [uuid]);
|
}, [uuid]);
|
||||||
|
|
||||||
return { verification, loading, error };
|
return { verification, loading, error };
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user