feat: add notification system with real-time updates, certificate generator utility, and intern eligibility tracking components

This commit is contained in:
Kemas M. Aryadary Rasyad
2026-07-08 11:31:48 +07:00
parent 77965be7f9
commit 9470d30a9a
6 changed files with 36 additions and 13 deletions
+9 -7
View File
@@ -20,7 +20,8 @@ export default function NotificationList() {
const loadNotifications = async () => {
try {
const data = await NotificationService.getNotifications();
const mapped = data.map(NotificationService.mapNotificationToUI);
const unread = data.filter((n) => !n.is_read);
const mapped = unread.map(NotificationService.mapNotificationToUI);
setNotifications(mapped);
} catch (err) {
console.error("Failed to load notifications:", err);
@@ -44,6 +45,7 @@ export default function NotificationList() {
socket.on("notification", (notif: BackendNotification) => {
console.log("New realtime notification received:", notif);
if (notif.is_read) return;
const mapped = NotificationService.mapNotificationToUI(notif);
setNotifications(prev => {
if (prev.some(p => p.id === mapped.id)) {
@@ -66,7 +68,7 @@ export default function NotificationList() {
const handleMarkAllRead = async () => {
try {
await NotificationService.markAllAsRead();
setNotifications(prev => prev.map(n => ({ ...n, isNew: false })));
setNotifications([]);
alert("All notifications marked as read!");
} catch (err) {
console.error("Failed to mark all as read:", err);
@@ -130,16 +132,16 @@ export default function NotificationList() {
</p>
{/* Conditional Actions */}
{item.type === 'new-task' && (
{item.type === 'new-task' && item.link &&(
<div className="flex items-center gap-2 pt-3">
<button
className="px-4 py-1.5 bg-[#B30000] hover:bg-[#990000] text-white text-xs font-bold rounded-lg shadow-sm transition-colors"
onClick={() => navigate(`/${item.link}`)}>
onClick={() => navigate(`${item.link}`)}>
View Task
</button>
<button className="px-4 py-1.5 border border-gray-200 text-gray-500 hover:text-gray-700 hover:bg-gray-50 text-xs font-semibold rounded-lg transition-colors">
{/* <button className="px-4 py-1.5 border border-gray-200 text-gray-500 hover:text-gray-700 hover:bg-gray-50 text-xs font-semibold rounded-lg transition-colors">
Later
</button>
</button> */}
</div>
)}
@@ -148,7 +150,7 @@ export default function NotificationList() {
className="flex items-center gap-1 text-xs font-bold text-[#B30000] hover:underline pt-2"
onClick={() => navigate(`/certificates`)}>
<Download className="w-3.5 h-3.5" />
Download Certificate
Ke Halaman
</button>
)}
</div>
@@ -29,6 +29,20 @@ interface EligibleInternTableProps {
const PER_PAGE = 5;
const BULAN_ID = [
"Januari", "Februari", "Maret", "April", "Mei", "Juni",
"Juli", "Agustus", "September", "Oktober", "November", "Desember",
];
function formatDateIndo(dateStr: string): string {
const [year, month, day] = dateStr.split("-").map(Number);
return `${day} ${BULAN_ID[month - 1]} ${year}`;
}
function formatDateRange(startDate: string, endDate: string): string {
return `${formatDateIndo(startDate)} - ${formatDateIndo(endDate)}`;
}
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"}`}>
@@ -49,7 +63,6 @@ export default function EligibleInternTable({
// Durasi tanggal Proyek
}: EligibleInternTableProps) {
const navigate = useNavigate();
// const { project } = useProjectDetail(String(projectId));
const { generate: SubmitCertificates } = useGenerateCertificates();
const initialSelected = new Set(
@@ -99,6 +112,8 @@ export default function EligibleInternTable({
const zip = new JSZip();
const templateUrl = resolveImageUrl(project.certificate_template!);
const duration = formatDateRange(project.start_date, project.end_date);
await Promise.all(
result.map(async (cert) => {
const verifyUrl = `${window.location.origin}/verify-certificate/${cert.uuid}`;
@@ -108,7 +123,7 @@ export default function EligibleInternTable({
cert.project.project_name,
cert.certificate_no,
verifyUrl,
"20 Juni 2024 - 20 September 2024"
duration
);
zip.file(`Sertifikat - ${cert.user.full_name}.png`, blob);
})
+4 -4
View File
@@ -80,9 +80,9 @@ export async function generateCertificate(
ctx.fillText(certificateNo, canvas.width * 0.825, canvas.height * 0.918);
//Durasi Project — besar (Grenze)
ctx.font = `50px "Grenze", serif`;
ctx.font = `400 48px "Grenze", serif`;
ctx.fillStyle = "#090909";
ctx.fillText(ProjectDuration, canvas.width * 0.57, canvas.height * 0.715);
ctx.fillText("20 April - 20 Juni 2025", canvas.width * 0.68, canvas.height * 0.66);
// QR code — kalau verifyUrl diisi
const qrDataUrl = await QRCode.toDataURL('http://localhost:5172/verify-certificate/' + verifyUrl, {
@@ -93,8 +93,8 @@ export async function generateCertificate(
const qrSize = canvas.width * 0.103;
ctx.drawImage(
qrImg,
canvas.width * 0.77 - qrSize,
canvas.height * 0.715 - qrSize,
canvas.width * 0.8 - qrSize,
canvas.height * 0.75 - qrSize,
qrSize,
qrSize,
);
+4
View File
@@ -51,6 +51,10 @@
ctx.fillStyle = "#090909";
ctx.fillText("Internify Platform Dev", canvas.width * 0.57, canvas.height * 0.615);
ctx.font = `400 48px "Grenze", serif`;
ctx.fillStyle = "#090909";
ctx.fillText("20 April - 20 Juni 2025", canvas.width * 0.68, canvas.height * 0.66);
// 4. Certificate no
ctx.font = `50px Grenze`;
ctx.fillText("CERT/20260609/PRJ2/USR5", canvas.width * 0.665, canvas.height * 0.305);
+1
View File
@@ -90,6 +90,7 @@ export const mapNotificationToUI = (notif: BackendNotification): UINotification
title: notif.title,
time: formatRelativeTime(notif.created_at),
isNew: !notif.is_read,
link: notif.link ?? "",
group: mapGroup(notif.created_at),
description: notif.message,
};
+1
View File
@@ -6,6 +6,7 @@ export interface BackendNotification {
message: string;
is_read: boolean;
created_at: string;
link: string | null;
}
export interface UINotification {