feat: add notification system with real-time updates, certificate generator utility, and intern eligibility tracking components
This commit is contained in:
@@ -20,7 +20,8 @@ export default function NotificationList() {
|
|||||||
const loadNotifications = async () => {
|
const loadNotifications = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await NotificationService.getNotifications();
|
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);
|
setNotifications(mapped);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load notifications:", err);
|
console.error("Failed to load notifications:", err);
|
||||||
@@ -44,6 +45,7 @@ export default function NotificationList() {
|
|||||||
|
|
||||||
socket.on("notification", (notif: BackendNotification) => {
|
socket.on("notification", (notif: BackendNotification) => {
|
||||||
console.log("New realtime notification received:", notif);
|
console.log("New realtime notification received:", notif);
|
||||||
|
if (notif.is_read) return;
|
||||||
const mapped = NotificationService.mapNotificationToUI(notif);
|
const mapped = NotificationService.mapNotificationToUI(notif);
|
||||||
setNotifications(prev => {
|
setNotifications(prev => {
|
||||||
if (prev.some(p => p.id === mapped.id)) {
|
if (prev.some(p => p.id === mapped.id)) {
|
||||||
@@ -66,7 +68,7 @@ export default function NotificationList() {
|
|||||||
const handleMarkAllRead = async () => {
|
const handleMarkAllRead = async () => {
|
||||||
try {
|
try {
|
||||||
await NotificationService.markAllAsRead();
|
await NotificationService.markAllAsRead();
|
||||||
setNotifications(prev => prev.map(n => ({ ...n, isNew: false })));
|
setNotifications([]);
|
||||||
alert("All notifications marked as read!");
|
alert("All notifications marked as read!");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to mark all as read:", err);
|
console.error("Failed to mark all as read:", err);
|
||||||
@@ -130,16 +132,16 @@ export default function NotificationList() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Conditional Actions */}
|
{/* Conditional Actions */}
|
||||||
{item.type === 'new-task' && (
|
{item.type === 'new-task' && item.link &&(
|
||||||
<div className="flex items-center gap-2 pt-3">
|
<div className="flex items-center gap-2 pt-3">
|
||||||
<button
|
<button
|
||||||
className="px-4 py-1.5 bg-[#B30000] hover:bg-[#990000] text-white text-xs font-bold rounded-lg shadow-sm transition-colors"
|
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
|
View Task
|
||||||
</button>
|
</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
|
Later
|
||||||
</button>
|
</button> */}
|
||||||
</div>
|
</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"
|
className="flex items-center gap-1 text-xs font-bold text-[#B30000] hover:underline pt-2"
|
||||||
onClick={() => navigate(`/certificates`)}>
|
onClick={() => navigate(`/certificates`)}>
|
||||||
<Download className="w-3.5 h-3.5" />
|
<Download className="w-3.5 h-3.5" />
|
||||||
Download Certificate
|
Ke Halaman
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,6 +29,20 @@ interface EligibleInternTableProps {
|
|||||||
|
|
||||||
const PER_PAGE = 5;
|
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 }) {
|
function StatusBadge({ status }: { status: boolean }) {
|
||||||
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 ${status ? "text-green-600" : "text-orange-500"}`}>
|
||||||
@@ -49,7 +63,6 @@ export default function EligibleInternTable({
|
|||||||
// Durasi tanggal Proyek
|
// Durasi tanggal Proyek
|
||||||
}: EligibleInternTableProps) {
|
}: EligibleInternTableProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
// const { project } = useProjectDetail(String(projectId));
|
|
||||||
const { generate: SubmitCertificates } = useGenerateCertificates();
|
const { generate: SubmitCertificates } = useGenerateCertificates();
|
||||||
|
|
||||||
const initialSelected = new Set(
|
const initialSelected = new Set(
|
||||||
@@ -99,6 +112,8 @@ export default function EligibleInternTable({
|
|||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
const templateUrl = resolveImageUrl(project.certificate_template!);
|
const templateUrl = resolveImageUrl(project.certificate_template!);
|
||||||
|
|
||||||
|
const duration = formatDateRange(project.start_date, project.end_date);
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
result.map(async (cert) => {
|
result.map(async (cert) => {
|
||||||
const verifyUrl = `${window.location.origin}/verify-certificate/${cert.uuid}`;
|
const verifyUrl = `${window.location.origin}/verify-certificate/${cert.uuid}`;
|
||||||
@@ -108,7 +123,7 @@ export default function EligibleInternTable({
|
|||||||
cert.project.project_name,
|
cert.project.project_name,
|
||||||
cert.certificate_no,
|
cert.certificate_no,
|
||||||
verifyUrl,
|
verifyUrl,
|
||||||
"20 Juni 2024 - 20 September 2024"
|
duration
|
||||||
);
|
);
|
||||||
zip.file(`Sertifikat - ${cert.user.full_name}.png`, blob);
|
zip.file(`Sertifikat - ${cert.user.full_name}.png`, blob);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -80,9 +80,9 @@ export async function generateCertificate(
|
|||||||
ctx.fillText(certificateNo, canvas.width * 0.825, canvas.height * 0.918);
|
ctx.fillText(certificateNo, canvas.width * 0.825, canvas.height * 0.918);
|
||||||
|
|
||||||
//Durasi Project — besar (Grenze)
|
//Durasi Project — besar (Grenze)
|
||||||
ctx.font = `50px "Grenze", serif`;
|
ctx.font = `400 48px "Grenze", serif`;
|
||||||
ctx.fillStyle = "#090909";
|
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
|
// QR code — kalau verifyUrl diisi
|
||||||
const qrDataUrl = await QRCode.toDataURL('http://localhost:5172/verify-certificate/' + verifyUrl, {
|
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;
|
const qrSize = canvas.width * 0.103;
|
||||||
ctx.drawImage(
|
ctx.drawImage(
|
||||||
qrImg,
|
qrImg,
|
||||||
canvas.width * 0.77 - qrSize,
|
canvas.width * 0.8 - qrSize,
|
||||||
canvas.height * 0.715 - qrSize,
|
canvas.height * 0.75 - qrSize,
|
||||||
qrSize,
|
qrSize,
|
||||||
qrSize,
|
qrSize,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -51,6 +51,10 @@
|
|||||||
ctx.fillStyle = "#090909";
|
ctx.fillStyle = "#090909";
|
||||||
ctx.fillText("Internify Platform Dev", canvas.width * 0.57, canvas.height * 0.615);
|
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
|
// 4. Certificate no
|
||||||
ctx.font = `50px Grenze`;
|
ctx.font = `50px Grenze`;
|
||||||
ctx.fillText("CERT/20260609/PRJ2/USR5", canvas.width * 0.665, canvas.height * 0.305);
|
ctx.fillText("CERT/20260609/PRJ2/USR5", canvas.width * 0.665, canvas.height * 0.305);
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export const mapNotificationToUI = (notif: BackendNotification): UINotification
|
|||||||
title: notif.title,
|
title: notif.title,
|
||||||
time: formatRelativeTime(notif.created_at),
|
time: formatRelativeTime(notif.created_at),
|
||||||
isNew: !notif.is_read,
|
isNew: !notif.is_read,
|
||||||
|
link: notif.link ?? "",
|
||||||
group: mapGroup(notif.created_at),
|
group: mapGroup(notif.created_at),
|
||||||
description: notif.message,
|
description: notif.message,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface BackendNotification {
|
|||||||
message: string;
|
message: string;
|
||||||
is_read: boolean;
|
is_read: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
link: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UINotification {
|
export interface UINotification {
|
||||||
|
|||||||
Reference in New Issue
Block a user