1var ntfLists = {}; 2var ntfEmptyStates = {}; 3var ntfState = { items: [] }; 4var ntfCountElements = { badge: null, title: null }; 5var ntfPagers = { 6 all: { page: 1, perPage: 10, loading: false, hasMore: true, loaded: false }, 7 time: { page: 1, perPage: 10, loading: false, hasMore: true, loaded: false }, 8 project: { page: 1, perPage: 10, loading: false, hasMore: true, loaded: false }, 9 apps: { page: 1, perPage: 10, loading: false, hasMore: true, loaded: false }, 10 attendance: { page: 1, perPage: 10, loading: false, hasMore: true, loaded: false }, 11 suspicious: { page: 1, perPage: 10, loading: false, hasMore: true, loaded: false }, 12 general: { page: 1, perPage: 10, loading: false, hasMore: true, loaded: false } 13}; 14 15function stripHtmlTags(text) { 16 return $("<div>").html(text || "").text(); 17} 18 19function truncateText(text, maxChars) { 20 var t = String(text || "").replace(/\s+/g, ' ').trim(); 21 var m = parseInt(maxChars, 10); 22 if (!m || m < 0) return t; 23 if (t.length > m) return t.substring(0, m) + '..'; 24 return t; 25} 26 27function getFirstLetterFromMessage(message) { 28 var text = stripHtmlTags(message || ""); 29 var firstLetter = "A"; 30 var emailMatch = text.match(/\b([a-zA-Z0-9._%+-]+)@/); 31 if (emailMatch && emailMatch[1]) { 32 firstLetter = emailMatch[1].charAt(0).toUpperCase(); 33 } else { 34 var firstWordMatch = text.trim().match(/^([a-zA-Z])/); 35 if (firstWordMatch && firstWordMatch[1]) {
36 firstLetter = firstWordMatch[1].toUpperCase(); 37 } 38 } 39 return firstLetter; 40} 41 42// Format a server timestamp like "2026-01-14 06:30:00" into a friendly time without seconds and with am/pm. 43// If includeDate is true, it will prefix with a short month + day (e.g., "Jan 14, 6:30am"). 44// If a timezone is provided (IANA name), the UTC timestamp will be converted to that timezone before formatting. 45function formatRequestTime(dtStr, includeDate) { 46 // legacy: treat as local formatted string 47 if (!dtStr) return ''; 48 dtStr = String(dtStr).trim(); 49 // Already formatted with am/pm? just return 50 if (/[ap]m$/i.test(dtStr)) return dtStr; 51 // Fallback to naive formatter 52 var m = dtStr.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})(?::\d{2})?$/); 53 if (!m) return dtStr; 54 var y = parseInt(m[1], 10); 55 var mo = parseInt(m[2], 10); 56 var d = parseInt(m[3], 10); 57 var h = parseInt(m[4], 10); 58 var min = m[5]; 59 60 var hour12 = (h % 12) || 12; 61 var ampm = h < 12 ? 'am' : 'pm'; 62 var formatted = hour12 + ':' + min + ampm; 63 if (includeDate) { 64 var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; 65 formatted = months[mo - 1] + ' ' + d + ', ' + formatted; 66 } 67 return normalizeTimeLabel(formatted); 68} 69 70// Convert a UTC timestamp ("YYYY-MM-DD HH:MM:SS") to a formatted time in an IANA timezone (no seconds, am/pm). 71function formatRequestTimeUTC(utcStr, ianaTz, includeDate) { 72 if (!utcStr) return ''; 73 utcStr = String(utcStr).trim(); 74 var m = utcStr.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})(?::\d{2})?$/); 75 if (!m) { 76 // If not in expected format, fall back to formatRequestTime 77 return formatRequestTime(utcStr, includeDate); 78 } 79 // Build ISO string as UTC 80 var iso = m[1] + '-' + m[2] + '-' + m[3] + 'T' + m[4] + ':' + m[5] + ':00Z'; 81 try { 82 var date = new Date(iso); 83 if (isNaN(date.getTime())) return formatRequestTime(utcStr, includeDate); 84 // If timezone provided, format in that timezone using Intl 85 var options = { hour: 'numeric', minute: '2-digit', hour12: true }; 86 if (includeDate) { 87 options.month = 'short'; options.day = 'numeric'; 88 } 89 if (ianaTz && typeof Intl !== 'undefined' && Intl.DateTimeFormat) { 90 var parts = new Intl.DateTimeFormat('en-US', Object.assign({ timeZone: ianaTz }, options)).formatToParts(date); 91 var hour = parts.find(p => p.type === 'hour')?.value || ''; 92 var minute = parts.find(p => p.type === 'minute')?.value || ''; 93 var day = parts.find(p => p.type === 'day')?.value || ''; 94 var month = parts.find(p => p.type === 'month')?.value || ''; 95 var dayStr = includeDate ? (month + ' ' + day + ', ') : ''; 96 var dayperiod = parts.find(p => p.type === 'dayPeriod')?.value || ''; 97 return normalizeTimeLabel(dayStr + hour + ':' + minute + dayperiod.toLowerCase()); 98 } else { 99 // No timezone support: show as local formatted time without seconds 100 var h = date.getHours(); 101 var hour12 = (h % 12) || 12; 102 var minute = ('0' + date.getMinutes()).slice(-2); 103 var ap = h < 12 ? 'am' : 'pm'; 104 var prefix = ''; 105 if (includeDate) { 106 var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; 107 prefix = months[date.getMonth()] + ' ' + date.getDate() + ', '; 108 } 109 return normalizeTimeLabel(prefix + hour12 + ':' + minute + ap); 110 } 111 } catch (e) { 112 return formatRequestTime(utcStr, includeDate); 113 } 114} 115 116function normalizeTimeLabel(label) { 117 if (!label) return label; 118 var match = String(label).trim().match(/^([A-Za-z]{3}\s+\d{1,2},\s*)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)$/i); 119 if (!match) return label; 120 var prefix = match[1] || ''; 121 var hour = parseInt(match[2], 10); 122 var minute = match[3] || '00'; 123 var period = match[4].toLowerCase(); 124 if (hour === 0) hour = 12; 125 return prefix + hour + ':' + minute + period; 126} 127 128function shouldIncludeDateForRange(startUtc, endUtc) { 129 if (!startUtc || !endUtc) return false;
130 var s = String(startUtc).trim(); 131 var e = String(endUtc).trim(); 132 var sm = s.match(/^(\d{4})-(\d{2})-(\d{2})\s+/); 133 var em = e.match(/^(\d{4})-(\d{2})-(\d{2})\s+/); 134 if (!sm || !em) return false; 135 return sm[0] !== em[0]; 136} 137 138// Prefer first name from payload; otherwise show full email (not just prefix) if that is all we have. 139function getActorDisplayName(item, actorFromMessage) { 140 if (item) { 141 var candidate = item.actor_first_name || item.first_name || item.user_first_name || item.name || item.user_name || item.actorName; 142 if (candidate && String(candidate).trim().length) return String(candidate).trim(); 143 } 144 145 var parsed = actorFromMessage && String(actorFromMessage).trim(); 146 if (parsed && parsed.length) { 147 return parsed; 148 } 149 150 return actorFromMessage; 151} 152 153function getCategoryLabel(key) { 154 switch (key) { 155 case "time": 156 return "Time"; 157 case "project": 158 return "Task"; 159 case "apps": 160 return "Apps"; 161 case "attendance": 162 return "Leave"; 163 case "suspicious": 164 return "Suspicious"; 165 case "general": 166 return "General"; 167 default: 168 return ""; 169 } 170} 171 172function categorizeNotification(message, action) { 173 var text = stripHtmlTags(message || "").toLowerCase(); 174 if (/suspicious activity detected/.test(text)) { 175 return "suspicious"; 176 } 177 if (/insights? report is now available|insights? (has been |are )?(updated|generated|ready)/.test(text)) { 178 return "general"; 179 } 180 if (/time request|time entry|timesheet|time log|timer|tracking|overtime|offline request/.test(text)) { 181 return "time"; 182 } 183 if (/\btask\b|task request|task added|assigned to you|commented on\s+.*task|changed status\s+.*task/.test(text)) { 184 return "project"; 185 } 186 187 if (/leave|attendance|late|absent|holiday|away|present/.test(text)) { 188 return "attendance"; 189 } 190 191 if (/\bapp(?:s|lication)?\b|\bintegration\b|\bsnapshot\b|\bscreenshot\b|\bconnected\b|\bdisconnected\b|\binstall\b|\buninstall\b/.test(text)) { 192 return "apps"; 193 } 194 return ""; 195} 196 197function isExplicitTimeRequest(item) { 198 if (!item) return false; 199 var msg = stripHtmlTags(item.message || '').toLowerCase(); 200 if (/time request|time entry|timesheet|time log|timer|tracking|overtime|offline request/.test(msg)) return true; 201 if (item.request_type || item.requestType) return true; 202 return false; 203} 204 205function resolveAvatarSrc(item) { 206 var defaultAvatar = '/img/profile_pic/206862_1558758217.png'; 207 if (!item) return defaultAvatar; 208 209 // Prefer actor-specific fields (actor performing the action), then fall back to legacy fields 210 var candidates = [ 211 item.actor_imgSrc, 212 item.actor_image, 213 item.actor_avatar, 214 item.actor_img, 215 item.imgSrc, 216 item.image, 217 item.user_image, 218 item.profile_pic, 219 item.avatar, 220 item.user_avatar, 221 item.picture, 222 ]; 223 224 for (var i = 0; i < candidates.length; i++) { 225 if (candidates[i] && String(candidates[i]).length) { 226 return candidates[i]; 227 } 228 } 229 return defaultAvatar; 230} 231 232function emphasizeDynamicInHtml(html) { 233 var $tmp = $('<div>').html(html || ""); 234 var emailRegex = /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g; 235 var timeRegex = /(\b\d{1,2}:\d{2}\s*(?:am|pm)\b)/ig; 236 var dateRegex = /\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\b\.?\s+\d{1,2},\s*\d{4}/ig; 237 var statusRegex = /\b(Pending approval|Pending|Approved|Rejected|Completed|In Progress|Pause|Paused)\b/ig; 238 239 $tmp.find('*').addBack().contents().filter(function () { 240 return this.nodeType === 3; // text nodes 241 }).each(function () { 242 var text = this.nodeValue; 243 var replaced = text.replace(emailRegex, '<strong>$1</strong>') 244 .replace(timeRegex, '<strong>$1</strong>') 245 .replace(dateRegex, '<strong>$&</strong>') 246 .replace(statusRegex, function (m) { return '<strong>' + m + '</strong>'; }); 247 if (replaced !== text) { 248 $(this).replaceWith(replaced); 249 } 250 }); 251 return $tmp.html(); 252} 253 254function buildNotificationHtml(item, categoryKey) { 255 var messageText = stripHtmlTags(item.message || ""); 256 var split = splitMessage(messageText, item); 257 var avatarSrc = resolveAvatarSrc(item); 258 var avatar = '<img src="' + avatarSrc + '" alt="notification avatar" class="ntf-notification-avatar" onerror="this.src=\'/img/profile_pic/206862_1558758217.png\'">'; 259 var categoryLabel = getCategoryLabel(categoryKey); 260 var metaParts = []; 261 if (item.time_elapsed) { 262 metaParts.push('<span class="ntf-time-ago">' + item.time_elapsed + '</span>'); 263 } 264 if (categoryLabel) { 265 metaParts.push('<span class="ntf-meta-separator">â¢</span><span class="ntf-department">' + categoryLabel + '</span>'); 266 } 267 var metaHtml = metaParts.join(" "); 268 var actionAttr = item.action ? item.action : "#"; 269 270 // Emphasize dynamic parts in the title (emails, times, dates, statuses) 271 var titleHtml = emphasizeDynamicInHtml(split.title); 272 273 return ( 274 '<div class="ntf-notification-item" data-notifyid="' + 275 item.id + 276 '" data-action="' + 277 actionAttr + 278 '" data-category="' + 279 categoryKey + 280 '">\n' + 281 ' <div class="ntf-notification-avatar-wrapper">' + 282 avatar + 283 "</div>\n" + 284 ' <div class="ntf-notification-content">\n' + 285 ' <div class="ntf-notification-text" style="position:relative; padding-right:26px;">' + 286 titleHtml + 287 '<span class="ntf-read-tick" aria-hidden="false" role="button" title="Dismiss notification" tabindex="0" style="position:absolute;right:0;top:50%;transform:translateY(-50%);width:18px;height:18px;border-radius:50%;border:1px solid #ccc;
287color:#777;text-align:center;line-height:16px;font-size:12px;cursor:pointer;">✓</span>' + 288 "</div>\n" + 289 (metaHtml ? ' <div class="ntf-notification-meta">' + metaHtml + "</div>\n" : "") + 290 (split.detail 291 ? ' <div class="ntf-comment-bubble">' + split.detail + "</div>\n" 292 : "") + 293 " </div>\n" + 294 "</div>" 295 ); 296} 297 298function splitMessage(text, item) { 299 var cleaned = (text || "").trim(); 300 if (!cleaned) { 301 return { title: "", detail: "" }; 302 } 303 304 // Suspicious app detection: emphasize label + employee, keep app/time in detail line 305 var suspiciousParts = cleaned.split(/\s+app\s*:\s*/i); 306 var suspiciousHead = suspiciousParts[0] ? suspiciousParts[0].replace(/[\u2014\-]\s*$/, '').trim() : ''; 307 var suspiciousMatch = suspiciousHead.match(/^suspicious\s+activity\s+detected\s+for\s+(.+)$/i); 308 if (suspiciousMatch && suspiciousMatch[1]) { 309 var rawEmployee = suspiciousMatch[1].trim(); 310 var employeeDisplay = (item && (item.employee_name || item.employeeName)) 311 ? String(item.employee_name || item.employeeName).trim() 312 : rawEmployee; 313 employeeDisplay = stripHtmlTags(employeeDisplay); 314 var detail = suspiciousParts.length > 1 ? ('App: ' + suspiciousParts.slice(1).join(' app: ').trim()) : ''; 315 return { 316 title: '<span style="font-weight:800;color:#111;">Suspicious</span> activity detected for <span style="font-weight:800;color:#111;">
316' + employeeDisplay + '</span>', 317 detail: detail, 318 }; 319 } 320 321 // Insight update: emphasize "Last 3 Month Insight" and keep date/details on second line 322 var insightThreeMonthMatch = cleaned.match(/^(your\s+)last\s*3\s*month\s+insight(\s+updated)([\s\S]*)$/i); 323 if (insightThreeMonthMatch) { 324 var trailing = (insightThreeMonthMatch[3] || '').trim(); 325 if (trailing) { 326 trailing = trailing.replace(/^[\-:,\s]+/, '').trim(); 327 } 328 return { 329 title: 'Your <span style="font-weight:800;color:#111;">Last 3 Month Insight</span> updated', 330 detail: trailing, 331 }; 332 } 333 334 // Task assignment: emphasize assigner name 335 var taskAssignedMatch = cleaned.match(/^a new task\s*(?:\((.*?)\))?,?\s*(?:has been\s*)?assigned to you by\s+(.+)$/i); 336 if (taskAssignedMatch && taskAssignedMatch[2]) { 337 var taskTitle = (taskAssignedMatch[1] || "").trim(); 338 var rawActor = taskAssignedMatch[2].trim(); 339 var actorDisplay = getActorDisplayName(item, rawActor); 340 var taskName = taskTitle || (item && (item.task_name || item.description || item.taskName) ? stripHtmlTags(item.task_name || item.description || item.taskName) : ""); 341 var detailLine = taskName ? ("Task:" + taskName ) : ""; 342 return { 343 title: "A new task assigned to you by <strong>" + actorDisplay + "</strong>", 344 detail: detailLine, 345 }; 346 } 347 348 function mapFromType(v) { 349 if (!v && v !== 0) return null; 350 v = Number(v); 351 if (v === 4) return 'focus'; 352 if (v === 1) return 'private'; 353 if (v === 2) return 'idle'; 354 return 'other time'; 355 } 356 function mapToType(obj) { 357 if (!obj && obj !== 0) return null; 358 try { 359 if (typeof obj === 'string') { 360 obj = JSON.parse(obj); 361 } 362 } catch (e) { 363 } 364 var t = obj && (obj.type || obj); 365 if (!t) return null; 366 if (typeof t === 'object') { 367 t = t.type || t.name || ''; 368 } 369 t = String(t); 370 if (t.indexOf('focus') !== -1) return 'focus'; 371 if (t.indexOf('private') !== -1) return 'private'; 372 if (t.indexOf('work') !== -1) return 'focus'; 373 return t.toLowerCase(); 374 } 375 376 function getLeaveDuration(item) { 377 if (!item) return ''; 378 var leaveType = item.leave_type || item.leaveType || null; 379 var halfdayType = item.halfday_type || item.halfdayType || null; 380 var halfLabel = ''; 381 if (halfdayType === 1 || halfdayType === '1') halfLabel = ' (First Half)'; 382 if (halfdayType === 2 || halfdayType === '2') halfLabel = ' (Second Half)'; 383 if (halfdayType || leaveType === 2 || leaveType === '2') { 384 return 'Half Day' + halfLabel; 385 } 386 if (leaveType === 1 || leaveType === '1') { 387 return 'Full Day'; 388 } 389 return ''; 390 } 391 392 function formatLeaveDate(value) { 393 if (!value) return ''; 394 var raw = String(value).trim(); 395 // Handle already formatted like 'Feb 10, 2026' 396 if (/^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\b/i.test(raw)) { 397 return raw; 398 } 399 // Handle 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS' 400 var m = raw.match(/^(\d{4})-(\d{2})-(\d{2})/); 401 if (m) { 402 var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; 403 return months[parseInt(m[2], 10) - 1] + ' ' + parseInt(m[3], 10) + ', ' + m[1]; 404 } 405 return raw; 406 } 407 408 // Time request: keep actor on top, move requested time/details below 409 var timeReqMatch = cleaned.match(/^time request added by\s+([^,:\-â]+)\s*(?:[:\-â,]\s*)?(.*)$/i); 410 if (timeReqMatch && timeReqMatch[1]) { 411 var timeDetail = (timeReqMatch[2] || "").trim(); 412 if (!timeDetail) { 413 timeDetail = cleaned.replace(/^time request added by\s+[^,:\-â]+/i, "").trim(); 414 } 415 if ((!timeDetail || !timeDetail.length) && item) { 416 var startTime = item.start_time || item.startTime; 417 var endTime = item.end_time || item.endTime; 418 var reqTime = item.requested_time || item.requestedTime; 419 if (startTime || endTime) { 420 // Prefer UTC+display timezone if available 421 var includeDate = shouldIncludeDateForRange(item && item.start_time_utc ? item.start_time_utc : startTime, item && item.end_time_utc ? item.end_time_utc : endTime); 422 var formattedStart = formatRequestTimeUTC(item && item.start_time_utc ? item.start_time_utc : startTime, (item && (item.display_time_zone || item.time_zone)) || null, includeDate); 423 var formattedEnd = formatRequestTimeUTC(item && item.end_time_utc ? item.end_time_utc : endTime, (item && (item.display_time_zone || item.time_zone)) || null, includeDate); 424 if (formattedStart && formattedEnd) { 425 timeDetail = formattedStart + ' to ' + formattedEnd; 426 } else if (formattedStart || formattedEnd) { 427 timeDetail = formattedStart || formattedEnd; 428 } else { 429 timeDetail = (startTime ? startTime : "") + (endTime ? (" to " + endTime) : ""); 430 } 431 } else if (reqTime) { 432 timeDetail = reqTime; 433 } 434 } 435 var statusRaw = (item && (item.status || item.is_approved || item.approval_status)) || ""; 436 var statusText = "Pending approval"; 437 if (statusRaw === 1 || statusRaw === "1" || statusRaw === true) { 438 statusText = "Approved"; 439 } else if (statusRaw === 2 || statusRaw === "2") { 440 statusText = "Rejected"; 441 } else if (statusRaw !== "" && statusRaw !== null && statusRaw !== undefined) { 442 statusText = String(statusRaw); 443 } 444 if (timeDetail && /^time\s*:/i.test(timeDetail)) { 445 timeDetail = timeDetail.replace(/^time\s*:\s*/i, "").trim(); 446 } 447 448 var fromRaw = item && (item.from_type || item.fromType || item.from); 449 var toRaw = item && (item.request_type || item.requestType || item.to); 450 var fromLabel = mapFromType(fromRaw); 451 var toLabel = mapToType(toRaw); 452 var whereText = ''; 453 if (fromLabel && toLabel) { 454 whereText = ' from ' + fromLabel + ' to ' + toLabel; 455 } else if (fromLabel) { 456 whereText = ' from ' + fromLabel; 457 } else if (toLabel) { 458 whereText = ' to ' + toLabel; 459 } 460 461 var detailSentence = timeDetail ? "Requested time: " + timeDetail + whereText : "Time request"; 462 var rawActor = timeReqMatch[1].trim(); 463 var actorDisplay = getActorDisplayName(item, rawActor); 464 return { 465 title: "Time request added by <strong>" + actorDisplay + "</strong>", 466 detail: detailSentence, 467 }; 468 } 469 470 // Task request: include task name and times underneath the title 471 var taskReqMatch = cleaned.match(/^task request added by\s+([^,:\-â]+)\s*(?:[:\-â,]\s*)?(.*)$/i); 472 if (taskReqMatch && taskReqMatch[1]) { 473 var taskDetail = (taskReqMatch[2] || "").trim(); 474 var timeDetail = taskDetail; 475 if ((!timeDetail || !timeDetail.length) && item) { 476 var startTime = item.start_time || item.startTime; 477 var endTime = item.end_time || item.endTime; 478 var reqTime = item.requested_time || item.requestedTime; 479 if (startTime || endTime) { 480 var includeDate = shouldIncludeDateForRange(item && item.start_time_utc ? item.start_time_utc : startTime, item && item.end_time_utc ? item.end_time_utc : endTime); 481 var formattedStart = formatRequestTimeUTC(item && item.start_time_utc ? item.start_time_utc : startTime, (item && (item.display_time_zone || item.time_zone)) || null, includeDate); 482 var formattedEnd = formatRequestTimeUTC(item && item.end_time_utc ? item.end_time_utc : endTime, (item && (item.display_time_zone || item.time_zone)) || null, includeDate); 483 if (formattedStart && formattedEnd) { 484 timeDetail = formattedStart + ' to ' + formattedEnd; 485 } else if (formattedStart || formattedEnd) { 486 timeDetail = formattedStart || formattedEnd; 487 } else { 488 timeDetail = (startTime ? startTime : "") + (endTime ? (" to " + endTime) : ""); 489 } 490 } else if (reqTime) { 491 timeDetail = reqTime; 492 } 493 } 494 var rawActor = taskReqMatch[1].trim(); 495 var actorDisplay = getActorDisplayName(item, rawActor); 496 var taskName = item && (item.task_name || item.description || item.taskName) ? stripHtmlTags(item.task_name || item.description || item.taskName) : ''; 497 var taskNameStr = taskName ? ' for <strong>' + taskName + '</strong>' : ''; 498 var detailSentence = timeDetail ? ('Requested time: ' + timeDetail + taskNameStr) : (taskName ? ('Task: <strong>' + taskName + '</strong>') : ''); 499 return { title: "Task request added by <strong>" + actorDisplay + "</strong>", detail: detailSentence }; 500 } 501 502 // Handle approval/rejection messages for time requests and show details underneath 503 var timeActionMatch = cleaned.match(/^(?:your\s+time\s+request\s+was|time\s+request\s+was)\s+(approved|denied|rejected|deleted)\s*(?:by\s+(.+))?$/i); 504 if (timeActionMatch && timeActionMatch[1]) { 505 var actionWord = timeActionMatch[1].trim(); 506 var actorRaw = timeActionMatch[2] ? timeActionMatch[2].trim() : ''; 507 var actorDisplay = getActorDisplayName(item, actorRaw || ''); 508 var actionCapital = actionWord.charAt(0).toUpperCase() + actionWord.slice(1); 509 var titleText = 'Your time request was <strong>' + actionCapital + '</strong>' + (actorDisplay ? ' by <strong>' + actorDisplay + '</strong>' : ''); 510 // Build detail from payload if available 511 var startTime = item && (item.start_time || ''); 512 var endTime = item && (item.end_time || ''); 513 var reqTime = item && (item.requested_time || ''); 514 var detail = ''; 515 var fStart = formatRequestTimeUTC(item && item.start_time_utc ? item.start_time_utc : startTime, (item && (item.display_time_zone || item.time_zone)) || null, false); 516 var fEnd = formatRequestTimeUTC(item && item.end_time_utc ? item.end_time_utc : endTime, (item && (item.display_time_zone || item.time_zone)) || null, false); 517 if (fStart || fEnd) { 518 detail = (fStart && fEnd) ? ('Requested time: ' + fStart + ' to ' + fEnd) : ('Requested time: ' + (fStart || fEnd)); 519 } else if (reqTime) { 520 detail = 'Requested time: ' + reqTime; 521 } 522 // Append from/to labels (where) if available 523 var fromRaw = item && (item.from_type || item.fromType || item.from); 524 var toRaw = item && (item.request_type || item.requestType || item.to); 525 var fromLabel = mapFromType(fromRaw); 526 var toLabel = mapToType(toRaw); 527 var whereText = ''; 528 if (fromLabel && toLabel) { 529 whereText = ' from ' + fromLabel + ' to ' + toLabel; 530 } else if (fromLabel) { 531 whereText = ' from ' + fromLabel; 532 } else if (toLabel) { 533 whereText = ' to ' + toLabel; 534 } 535 if (whereText) { 536 detail = (detail ? detail + whereText : whereText); 537 } 538 if (item && item.comment) { 539 var commentText = truncateText(stripHtmlTags(item.comment), 20); 540 if (commentText) { 541 detail += (detail ? ' <br>' : '') + 'Comment: ' + commentText; 542 } 543 } 544 return { title: titleText, detail: detail }; 545 } 546 547 // Task approval/rejection: 'Your task request was approved by Bob' -> show task name and time below 548 var taskActionMatch = cleaned.match(/^(?:your\s+task\s+request\s+was|task\s+request\s+was)\s+(approved|denied|rejected|deleted)\s*(?:by\s+(.+))?$/i); 549 if (taskActionMatch && taskActionMatch[1]) { 550 var actionWord = taskActionMatch[1].trim(); 551 var actorRaw = taskActionMatch[2] ? taskActionMatch[2].trim() : ''; 552 var actorDisplay = getActorDisplayName(item, actorRaw || ''); 553 var actionCapital = actionWord.charAt(0).toUpperCase() + actionWord.slice(1); 554 var titleText = 'Your task request was <strong>' + actionCapital + '</strong>' + (actorDisplay ? ' by <strong>' + actorDisplay + '</strong>' : ''); 555 var startTime = item && (item.start_time || ''); 556 var endTime = item && (item.end_time || ''); 557 var includeDate = shouldIncludeDateForRange(item && item.start_time_utc ? item.start_time_utc : startTime, item && item.end_time_utc ? item.end_time_utc : endTime); 558 var fStart = formatRequestTimeUTC(item && item.start_time_utc ? item.start_time_utc : startTime, (item && (item.display_time_zone || item.time_zone)) || null, includeDate); 559 var fEnd = formatRequestTimeUTC(item && item.end_time_utc ? item.end_time_utc : endTime, (item && (item.display_time_zone || item.time_zone)) || null, includeDate); 560 var timeLine = ''; 561 if (fStart || fEnd) { 562 timeLine = (fStart && fEnd) ? (fStart + ' to ' + fEnd) : (fStart || fEnd); 563 } 564 var taskName = item && (item.task_name || item.description || item.taskName) ? stripHtmlTags(item.task_name || item.description || item.taskName) : ''; 565 // Debug: log item when taskName is missing for approved/denied task notifications 566 var detailParts = []; 567 if (taskName) detailParts.push('Task:' + taskName); 568 if (timeLine) detailParts.push('Time:' + timeLine); 569 // Append where labels if present 570 var fromRaw = item && (item.from_type || item.fromType || item.from); 571 var toRaw = item && (item.request_type || item.requestType || item.to); 572 var fromLabel = mapFromType(fromRaw); 573 var toLabel = mapToType(toRaw); 574 if (fromLabel && toLabel) detailParts.push('Where: ' + fromLabel + ' â ' + toLabel); 575 else if (fromLabel) detailParts.push('Where: from ' + fromLabel); 576 else if (toLabel) detailParts.push('Where: to ' + toLabel); 577 var detail = detailParts.join('<br>'); 578 return { title: titleText, detail: detail }; 579 } 580 581 // Admin-added leave: 'Leave Casual added by Bob' 582 var leaveAddedMatch = cleaned.match(/^leave\s+(.+?)\s+added by\s+(.+)$/i); 583 if (leaveAddedMatch) { 584 var leaveTypeName = leaveAddedMatch[1].trim(); 585 var actorName = getActorDisplayName(item, leaveAddedMatch[2].trim()); 586 var titleText = 'Leave <strong>' + leaveTypeName + '</strong> added by <strong>' + actorName + '</strong>'; 587 var startDate = formatLeaveDate(item && (item.start_date || item.startDate || item.start_date_time || item.startDateTime) || ''); 588 var endDate = formatLeaveDate(item && (item.end_date || item.endDate || item.end_date_time || item.endDateTime) || ''); 589 var detail = ''; 590 if (startDate || endDate) { 591 var dateLine = startDate && endDate ? (startDate === endDate ? startDate : (startDate + ' - ' + endDate)) : (startDate || endDate); 592 detail = 'Leave date: ' + dateLine; 593 } 594 var durationText = getLeaveDuration(item); 595 if (durationText) { 596 detail += (detail ? ' <br>' : '') + 'Duration: ' + durationText; 597 } 598 return { title: titleText, detail: detail }; 599 } 600 601 // Leave approval/rejection: 'Your leave request was approved by Bob' -> show dates / reason below 602 var leaveActionMatch = cleaned.match(/^(?:your\s+leave\s+request\s+was|leave\s+request\s+was)\s+(approved|denied|rejected|deleted)\s*(?:by\s+(.+))?$/i); 603 if (leaveActionMatch && leaveActionMatch[1]) { 604 var leaveWord = leaveActionMatch[1].trim(); 605 var actorRaw = leaveActionMatch[2] ? leaveActionMatch[2].trim() : ''; 606 var actorDisplay = getActorDisplayName(item, actorRaw || ''); 607 var leaveCap = leaveWord.charAt(0).toUpperCase() + leaveWord.slice(1); 608 var titleText = 'Your leave request was <strong>' + leaveCap + '</strong>' + (actorDisplay ? ' by <strong>' + actorDisplay + '</strong>' : ''); 609 var startDate = formatLeaveDate(item && (item.start_date || item.startDate || item.start_date_time || item.startDateTime) || ''); 610
610 var endDate = formatLeaveDate(item && (item.end_date || item.endDate || item.end_date_time || item.endDateTime) || ''); 611 var detail = ''; 612 if (startDate || endDate) { 613 var dateLine = startDate && endDate ? (startDate === endDate ? startDate : (startDate + ' - ' + endDate)) : (startDate || endDate); 614 detail = 'Leave date: ' + dateLine; 615 } 616 var durationText = getLeaveDuration(item); 617 if (durationText) { 618 detail += (detail ? ' <br>' : '') + 'Duration: ' + durationText; 619 } 620 if (item && item.reject_reason) { 621 detail += (detail ? ' <br>' : '') + 'Reason: ' + stripHtmlTags(item.reject_reason); 622 } 623 return { title: titleText, detail: detail }; 624 } 625 626 // Leave request: keep main line, show date range/status below 627 var leaveMatch = cleaned.match(/requested leave for\s+(.+)/i); 628 if (leaveMatch) { 629 var leaveType = leaveMatch[1].trim(); 630 var startDate = formatLeaveDate((item && (item.start_date || item.startDate || item.start_date_time || item.startDateTime)) || ""); 631 var endDate = formatLeaveDate((item && (item.end_date || item.endDate || item.end_date_time || item.endDateTime)) || ""); 632 var statusRaw = (item && (item.status || item.leave_status || item.is_approved || item.approval_status)) || ""; 633 var range = startDate && endDate ? startDate + " - " + endDate : (startDate || endDate); 634 var statusText = "Pending approval"; 635 if (statusRaw === 1 || statusRaw === "1" || statusRaw === true) { 636 statusText = "Approved"; 637 } else if (statusRaw === 2 || statusRaw === "2") { 638 statusText = "Rejected"; 639 } else if (statusRaw !== "" && statusRaw !== null && statusRaw !== undefined) { 640 statusText = String(statusRaw); 641 } 642 var detail; 643 if (startDate && endDate) { 644 if (startDate === endDate) { 645 detail = "Leave date: " + startDate; 646 } else { 647 detail = "Leave date: " + startDate + " - " + endDate; 648 } 649 } else if (startDate || endDate) { 650 detail = "Leave date: " + (startDate || endDate); 651 } else { 652 detail = "Leave date: -"; 653 } 654 // Try to extract actor and leave type for bolding (consistent format for all leave types) 655 var leaveTitleMatch = cleaned.match(/^(.+?)\s+requested leave for\s+(.+)$/i); 656 if (leaveTitleMatch) { 657 var rawActorName = leaveTitleMatch[1].trim(); 658 var actorName = getActorDisplayName(item, rawActorName); 659 var leaveTypeName = leaveTitleMatch[2].trim(); 660 var titleText = '<strong>' + actorName + '</strong> requested leave for <strong>' + leaveTypeName + '</strong>'; 661 var durationText = getLeaveDuration(item); 662 if (durationText) { 663 detail = (detail ? detail + ' <br>' : '') + 'Duration: ' + durationText; 664 } 665 return { 666 title: titleText, 667 detail: detail, 668 }; 669 } 670 return { 671 title: cleaned, 672 detail: detail, 673 }; 674 } 675 676 // Comment formatting: "Actor commented on Task" -> show comment below 677 var commentMatch = cleaned.match(/^(.+?)\s+commented on\s+(.+)$/i); 678 if (commentMatch && commentMatch[1]) { 679 var rawActor = commentMatch[1].trim(); 680 var actorDisplay = getActorDisplayName(item, rawActor); 681 var taskName = commentMatch[2].trim(); 682 var detailText = (item && (item.comment || item.comment_text || item.message_detail)) ? stripHtmlTags(item.comment || item.comment_text || item.message_detail) : ''; 683 detailText = truncateText(detailText, 20); 684 return { 685 title: '<strong>' + actorDisplay + '</strong> commented on <strong>' + taskName + '</strong>', 686 detail: detailText ? 'Comment: ' + detailText : '', 687 }; 688 } 689 690 var statusChangeFromMatch = cleaned.match(/^(.+?)\s+changed status from\s+(.+?)\s+to\s+(.+?)\s+for task\s*[-:â]\s*(.+)$/i); 691 if (statusChangeFromMatch && statusChangeFromMatch[1] && statusChangeFromMatch[2] && statusChangeFromMatch[3] && statusChangeFromMatch[4]) { 692 var actorRaw = statusChangeFromMatch[1].trim(); 693 var actor = getActorDisplayName(item, actorRaw); 694 var fromStatus = statusChangeFromMatch[2].trim(); 695 var toStatus = statusChangeFromMatch[3].trim(); 696 var taskName = statusChangeFromMatch[4].trim(); 697 var detailText = "Task: " + taskName; 698 var titleText = '<strong>' + actor + '</strong> changed task status from <strong>' + fromStatus + '</strong> to <strong>' + toStatus + '</strong>'; 699 return { title: titleText, detail: detailText }; 700 } 701 702 var statusChangeMatch = cleaned.match(/^(.+?)\s+changed status to\s+(.+?)\s+for task\s*[-:â]\s*(.+)$/i); 703 if (statusChangeMatch && statusChangeMatch[1] && statusChangeMatch[2] && statusChangeMatch[3]) { 704 var actorOnlyRaw = statusChangeMatch[1].trim(); 705 var actorOnly = getActorDisplayName(item, actorOnlyRaw); 706 var toStatusOnly = statusChangeMatch[2].trim(); 707 var taskNameOnly = statusChangeMatch[3].trim(); 708 var detailTextOnly = "Task: " + taskNameOnly; 709 var titleTextOnly = '<strong>' + actorOnly + '</strong> changed status to <strong>' + toStatusOnly + '</strong>'; 710 return { title: titleTextOnly, detail: detailTextOnly }; 711 } 712 713 // Task-specific formatting: keep main message up top, show task name underneath 714 var taskMatch = cleaned.match(/task\s*\(\s*([^)]+?)\s*\)/i); 715 if (taskMatch && taskMatch[1]) { 716 var taskName = taskMatch[1].trim(); 717 var titleText = cleaned.replace(taskMatch[0], "").replace(/\s{2,}/g, " ").trim(); 718 titleText = titleText.replace(/^[-,:]+/, "").trim(); 719 titleText = titleText.replace(/a new\s*,?\s*has been\s*assigned\s*/i, "A new task assigned ").trim(); 720 if (!titleText) { 721 titleText = "Task"; 722 } 723 724 var detailText = "Task: " + taskName; 725 return { title: titleText, detail: detailText }; 726 } 727 728 // Prefer explicit split points that read well in the UI 729 var newlineIdx = cleaned.indexOf("\n"); 730 if (newlineIdx > -1) { 731 return { title: cleaned.substring(0, newlineIdx).trim(), detail: cleaned.substring(newlineIdx + 1).trim() }; 732 } 733 734 var hasBeenIdx = cleaned.toLowerCase().indexOf(" has been "); 735 if (hasBeenIdx > -1) { 736 return { title: cleaned.substring(0, hasBeenIdx).trim(), detail: cleaned.substring(hasBeenIdx).trim() }; 737 } 738 739 var dashParts = cleaned.split(" - "); 740 if (dashParts.length > 1) { 741 return { title: dashParts[0], detail: dashParts.slice(1).join(" - ") }; 742 } 743 744 var commaIdx = cleaned.indexOf(","); 745 if (commaIdx > -1) { 746 return { title: cleaned.substring(0, commaIdx + 1), detail: cleaned.substring(commaIdx + 1).trim() }; 747 } 748 749 var colonIdx = cleaned.indexOf(":"); 750 if (colonIdx > -1) { 751 return { title: cleaned.substring(0, colonIdx + 1), detail: cleaned.substring(colonIdx + 1).trim() }; 752 } 753 754 return { title: cleaned, detail: "" }; 755} 756 757function syncNotificationCount(count) { 758 var safeCount = Math.max(0, parseInt(count || 0, 10)); 759 if (ntfCountElements.badge && ntfCountElements.badge.length) { 760 ntfCountElements.badge.html(safeCount); 761 } 762 if ($("#count-notify").length) { 763 $("#count-notify").html(safeCount); 764 } 765 if (ntfCountElements.title && ntfCountElements.title.length) { 766 ntfCountElements.title.html(safeCount); 767 } 768} 769 770function clearAllLists() { 771 Object.keys(ntfLists || {}
771).forEach(function (key) { 772 if (ntfLists[key] && ntfLists[key].length) { 773 ntfLists[key].empty(); 774 } 775 }); 776} 777 778function updateEmptyStates(counts) { 779 Object.keys(ntfEmptyStates || {}).forEach(function (key) { 780 if (ntfEmptyStates[key] && ntfEmptyStates[key].length) { 781 ntfEmptyStates[key].toggle((counts[key] || 0) === 0); 782 } 783 }); 784} 785 786function renderNotifications(notifications, unreadCount, preserveBadge) { 787 // preserveBadge: if true, do not change the top badge count (used when opening dropdown) 788 preserveBadge = !!preserveBadge; 789 clearAllLists(); 790 var counts = { all: 0, time: 0, project: 0, apps: 0, attendance: 0, suspicious: 0 }; 791 792 if (!notifications || !notifications.length) { 793 if (!preserveBadge) { 794 syncNotificationCount(typeof unreadCount === "number" ? unreadCount : 0); 795 } 796 updateEmptyStates(counts); 797 return; 798 } 799 800 // FIXED: Make these local variables instead of global to reset on each render 801 var seenIds = {}; 802 var seenSignatures = {}; 803 804 function buildNotificationSignature(item) { 805 if (!item) return ''; 806 var message = stripHtmlTags(item.message || '').replace(/\s+/g, ' ').trim().toLowerCase(); 807 var action = String(item.action || '').trim().toLowerCase(); 808 var taskId = String(item.task_id || item.unique_id || '').trim().toLowerCase(); 809 var taskName = String(item.task_name || item.description || '').trim().toLowerCase(); 810 var start = String(item.start_time_utc || item.start_time || item.start_date_time || item.start_date || item.startDate || '').trim(); 811 var end = String(item.end_time_utc || item.end_time || item.end_date_time || item.end_date || item.endDate || '').trim(); 812 var requested = String(item.requested_time || '').trim(); 813 var status = String(item.status || item.is_approved || item.approval_status || '').trim(); 814 var userId = String(item.user_id || '').trim(); 815 816 var isRequest = /task request|time request/.test(message); 817 var parts = [message, action, taskId, taskName, start, end, requested, status, userId]; 818 if (isRequest && item && item.id) parts.push(String(item.id)); 819 return parts.join('|'); 820 } 821 822 notifications.forEach(function (item) { 823 var itemId = item && item.id !== undefined && item.id !== null ? String(item.id) : null; 824 if (itemId) { 825 if (seenIds[itemId]) { 826 return; 827 } 828 seenIds[itemId] = true; 829 } else { 830 // Only use signature-based dedup for items without a unique ID 831 var signature = buildNotificationSignature(item); 832 if (signature && seenSignatures[signature]) { 833 return; 834 } 835 if (signature) { 836 seenSignatures[signature] = true; 837 } 838 } 839 840 // determine category early so we can dedupe DISPLAY signatures per-category 841 var category = categorizeNotification(item.message, item.action); 842 if (category === 'project' && isExplicitTimeRequest(item)) { 843 category = 'time'; 844 } 845 counts.all += 1; 846 counts[category] = (counts[category] || 0) + 1; 847 848 if (ntfLists.all && ntfLists.all.length) { 849 if (!ntfLists.all.find('.ntf-notification-item[data-notifyid="' + item.id + '"]').length) { 850 ntfLists.all.append(buildNotificationHtml(item, category)); 851 } 852 } 853 if (category && ntfLists[category] && ntfLists[category].length) { 854 if (!ntfLists[category].find('.ntf-notification-item[data-notifyid="' + item.id + '"]').length) { 855 ntfLists[category].append(buildNotificationHtml(item, category)); 856 } 857 } 858 859 }); 860 var renderedTotal = counts.all; 861 if (!preserveBadge) { 862 var finalCount = typeof unreadCount === "number" ? Math.min(unreadCount, renderedTotal) : renderedTotal; 863 syncNotificationCount(finalCount); 864 } 865 updateEmptyStates(counts); 866} 867 868function removeNotificationFromLists(id) { 869 $('.ntf-notification-item[data-notifyid="' + id + '"]').remove(); 870} 871 872function getListCounts() { 873 // Count by actual DOM so Task tab works even if ntfLists was empty at init 874 var counts = { all: 0, time: 0, project: 0, apps: 0, attendance: 0, suspicious: 0, general: 0 }; 875 counts.all = $('#ntf-list-all .ntf-notification-item').length; 876 counts.time = $('#ntf-list-time .ntf-notification-item').length; 877 counts.project = $('#ntf-list-project .ntf-notification-item').length; 878 counts.apps = $('#ntf-list-apps .ntf-notification-item').length; 879 counts.attendance = $('#ntf-list-attendance .ntf-notification-item').length; 880 counts.suspicious = $('#ntf-list-suspicious .ntf-notification-item').length; 881 counts.general = $('#ntf-list-general .ntf-notification-item').length; 882 return counts; 883} 884 885function getBadgeCount() { 886 try { 887 var txt = (ntfCountElements.badge && ntfCountElements.badge.length) ? ntfCountElements.badge.text() : ''; 888 txt = String(txt).trim().replace(/[^0-9]/g, ''); 889 var n = parseInt(txt, 10); 890 return isNaN(n) ? 0 : n; 891 } catch (e) { 892 return 0; 893 } 894} 895 896(function ($) { 897 $(function () { 898 ntfCountElements.badge = $(".ioss-notification-count"); 899 ntfCountElements.title = $(".ntf-title-count"); 900 901 ntfLists = { 902 all: $("#ntf-list-all"), 903 time: $("#ntf-list-time"), 904 project: $("#ntf-list-project"), 905 apps: $("#ntf-list-apps"), 906 attendance: $("#ntf-list-attendance"), 907 suspicious: $("#ntf-list-suspicious"), 908 general: $("#ntf-list-general"), 909 }; 910 911 ntfEmptyStates = { 912 all: $('.ntf-empty-state[data-category="all"]'), 913 time: $('.ntf-empty-state[data-category="time"]'), 914 project: $('.ntf-empty-state[data-category="project"]'), 915 apps: $('.ntf-empty-state[data-category="apps"]'),
916 attendance: $('.ntf-empty-state[data-category="attendance"]'), 917 suspicious: $('.ntf-empty-state[data-category="suspicious"]'), 918 general: $('.ntf-empty-state[data-category="general"]'), 919 }; 920 921 function showCenteredLoader(show) { 922 var $o = $('.ntf-loading-overlay'); 923 if (show) { 924 var $activePane = $('.ntf-tab-content .tab-pane.active'); 925 if ($activePane && $activePane.length) { 926 var off = $activePane.offset(); 927 var w = $activePane.outerWidth(); 928 var h = $activePane.outerHeight(); 929 if (!$o.data('orig-parent')) $o.data('orig-parent', $o.parent()); 930 $o.appendTo('body').css({ position: 'absolute', top: off.top + 'px', left: off.left + 'px', width: w + 'px', height: h + 'px', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 2000 }); 931 } else { 932 $o.css({ display: 'flex', alignItems: 'center', justifyContent: 'center' }); 933 } 934 $o.find('.ntf-loader').attr('aria-hidden', 'false'); 935 } else { 936 $o.hide(); 937 $o.find('.ntf-loader').attr('aria-hidden', 'true'); 938 var $orig = $o.data('orig-parent'); 939 if ($orig && $orig.length) { 940 $o.appendTo($orig).css({ position: '', top: '', left: '', width: '', height: '', display: 'none', alignItems: '', justifyContent: '', zIndex: '' }); 941 $o.removeData('orig-parent'); 942 } 943 } 944 } 945 946 function showBottomLoader(show) { 947 $('.ntf-bottom-loader').toggle(show); 948 } 949 950 function showBottomLoaderInPane(paneSelector, show) { 951 var $pane = $(paneSelector); 952 if ($pane && $pane.length) $pane.find('.ntf-bottom-loader').toggle(show); 953 } 954 955 function clearErrorRow() { 956 $('.ntf-error-row').hide().empty(); 957 } 958 959 function showErrorRow(message, page, append, category) { 960 category = category || 'all'; 961 var html = '<div class="ntf-error-message">' + (message || 'Failed to load notifications.') + ' <a href="#" class="ntf-retry" data-page="' + (page || 1) + '" data-append="' + (append ? 1 : 0) + '" data-category="' + category + '">Retry</a></div>'; 962 var $active = $('.ntf-tab-content .tab-pane.active'); 963 if (!$active || !$active.length) $active = $('#ntf-tab-all'); 964 var $row = $active.find('.ntf-error-row'); 965 $row.html(html).show(); 966 } 967 968 function loadNotifications(page, append, category) { 969 category = category || 'all'; 970 var pager = ntfPagers[category] || ntfPagers.all; 971 if (pager.loading) return; 972 pager.loading = true; 973 clearErrorRow(); 974 975 if (append) { 976 showBottomLoader(true); 977 } else if (category === 'all') { 978 showBottomLoaderInPane('#ntf-tab-all', true); 979 } else { 980 var paneId = category === 'project' ? '#ntf-tab-project' : category === 'time' ? '#ntf-tab-time-tracking' : category === 'suspicious' ? '#ntf-tab-suspicious' : category === 'general' ? '#ntf-tab-general' : '#ntf-tab-attendance'; 981 // Hide empty state while loading so first click shows only loader, not "No ... notifications" + loader 982 if (ntfEmptyStates[category] && ntfEmptyStates[category].length) { 983 ntfEmptyStates[category].hide(); 984 } 985 showBottomLoaderInPane(paneId, true); 986 } 987 988 $.ajax({ 989 type: "GET", 990 url: "/notification/get", 991 data: { 992 _token: $('meta[name="csrf-token"]').attr("content"), 993 page: page, 994 per_page: pager.perPage, 995 category: category 996 }, 997 dataType: "JSON", 998 cache: false, 999 success: function (response) { 1000 var notification = response ? response.notification || [] : []; 1001 pager.page = response && response.page ? response.page : page; 1002 pager.hasMore = response && typeof response.has_more !== 'undefined' ? !!response.has_more : false;
1003 1004 if (category === 'all') { 1005 if (!append) { 1006 ntfState.items = notification; 1007 Object.keys(ntfLists).forEach(function (k) { if (ntfLists[k] && ntfLists[k].length) ntfLists[k].empty(); }); 1008 renderNotifications(notification, response ? response.unread_count : null, true); 1009 ntfPagers.all.loaded = true; 1010 ensureNotificationAllTabVisible(); 1011 } else { 1012 ntfState.items = ntfState.items.concat(notification); 1013 appendNotifications(notification); 1014 if (response && typeof response.unread_count === 'number') { 1015 var serverCount = response.unread_count; 1016 var currentBadge = getBadgeCount(); 1017 if (serverCount > currentBadge) { 1018 syncNotificationCount(serverCount); 1019 } 1020 } 1021 updateEmptyStates(getListCounts()); 1022 } 1023 } else { 1024 // Category-specific: always use list by ID (single notification dropdown per page) 1025 var listId = 'ntf-list-' + (category === 'project' ? 'project' : category === 'time' ? 'time' : category === 'attendance' ? 'attendance' : category === 'apps' ? 'apps' : category === 'suspicious' ? 'suspicious' : category === 'general' ? 'general' : 'all'); 1026 var $targetList = $('#' + listId); 1027 pager.loaded = true; 1028 if (!append && $targetList.length) $targetList.empty(); 1029 if ($targetList.length && notification && notification.length) { 1030 notification.forEach(function (item) { 1031 var actualCategory = category; 1032 if (category === 'project' && isExplicitTimeRequest(item)) { 1033 actualCategory = 'time'; 1034 } 1035 var $list = actualCategory === category ? $targetList : $('#' + 'ntf-list-' + (actualCategory === 'time' ? 'time' : actualCategory === 'project' ? 'project' : actualCategory === 'attendance' ? 'attendance' : actualCategory === 'general' ? 'general' : 'apps')); 1036 if (!$list.length) $list = $targetList; 1037 if ($list.find('.ntf-notification-item[data-notifyid="' + item.id + '"]').length) return; 1038 try { 1039 $list.append(buildNotificationHtml(item, actualCategory)); 1040 } catch (e) {} 1041 var exists = ntfState.items.some(function (it) { return it && it.id === item.id; }); 1042 if (!exists) ntfState.items.push(item); 1043 }); 1044 } 1045 // Use live DOM counts so empty state hides for Task tab 1046 updateEmptyStates(getListCounts()); 1047 if (category === 'project' && notification.length) { 1048 $('.ntf-empty-state[data-category="project"]').hide(); 1049 } 1050 if (response && typeof response.unread_count === 'number') { 1051 var serverCount = response.unread_count; 1052 var currentBadge = getBadgeCount(); 1053 if (serverCount > currentBadge) syncNotificationCount(serverCount); 1054 } 1055 } 1056 1057 if (!append && (!notification || !notification.length) && category === 'all') { 1058 renderNotifications([], response ? response.unread_count : null, true); 1059 ensureNotificationAllTabVisible(); 1060 }
1061 if (!append && category !== 'all') { 1062 updateEmptyStates(getListCounts()); 1063 } 1064 clearErrorRow(); 1065 }, 1066 error: function (xhr, status, err) { 1067 showErrorRow('Failed to load notifications. Please try again.', page, append, category); 1068 }, 1069 complete: function () { 1070 showBottomLoader(false); 1071 showCenteredLoader(false); 1072 showBottomLoaderInPane('#ntf-tab-project', false); 1073 showBottomLoaderInPane('#ntf-tab-time-tracking', false); 1074 showBottomLoaderInPane('#ntf-tab-attendance', false); 1075 showBottomLoaderInPane('#ntf-tab-suspicious', false); 1076 showBottomLoaderInPane('#ntf-tab-general', false); 1077 pager.loading = false; 1078 } 1079 }); 1080 } 1081 window.loadNotifications = loadNotifications; 1082 1083 function appendNotifications(notifications, targetCategory) { 1084 if (!notifications || !notifications.length) return; 1085 showBottomLoader(false); 1086 clearErrorRow(); 1087 1088 notifications.forEach(function (item) { 1089 var category = categorizeNotification(item.message, item.action); 1090 if (targetCategory) { 1091 if (ntfLists[targetCategory] && ntfLists[targetCategory].find('.ntf-notification-item[data-notifyid="' + item.id + '"]').length) return; 1092 } else { 1093 if ($('.ntf-notification-item[data-notifyid="' + item.id + '"]').length) return; 1094 } 1095 1096 // If a targetCategory is provided, append only to that category; otherwise append to the ALL list only 1097 if (!targetCategory) { 1098 if (ntfLists.all && ntfLists.all.length) { 1099 ntfLists.all.append(buildNotificationHtml(item, category)); 1100 } 1101 } else { 1102 var actualCategory = targetCategory; 1103 if (targetCategory === 'project' && isExplicitTimeRequest(item)) actualCategory = 'time'; 1104 if (ntfLists[actualCategory] && ntfLists[actualCategory].length) { 1105 ntfLists[actualCategory].append(buildNotificationHtml(item, actualCategory)); 1106 } 1107 } 1108 }); 1109 } 1110 1111 $(document).on('click', '.ntf-error-row .ntf-retry', function (e) { 1112 e.preventDefault(); 1113 var $btn = $(this); 1114 var page = parseInt($btn.data('page')) || 1; 1115 var append = $btn.data('append') === 1 || $btn.data('append') === '1'; 1116 var category = $btn.data('category') || 'all'; 1117 clearErrorRow(); 1118 loadNotifications(page, append, category); 1119 }); 1120 1121 function fetchNotificationCountOnly() { 1122 $.ajax({ 1123 type: "GET", 1124 url: "/notification/get", 1125 data: { 1126 _token: $('meta[name="csrf-token"]').attr("content"), 1127 count_only: 1 1128 }, 1129 dataType: "JSON", 1130 cache: false, 1131 success: function (response) { 1132 var count = response && typeof response.unread_count !== 'undefined' ? response.unread_count : 0; 1133 syncNotificationCount(count); 1134 } 1135 }); 1136 } 1137 1138 fetchNotificationCountOnly(); 1139 1140 function ensureNotificationAllTabVisible() { 1141 var $pane = $('#ntf-tab-all'); 1142 var $content = $('.ntf-tab-content'); 1143 if (!$pane.length || !$content.length) return; 1144 $content.find('.tab-pane').removeClass('active in').addClass('fade'); 1145 $pane.removeClass('fade').addClass('active in'); 1146 $('.ntf-tab-item').removeClass('ntf-tab-active'); 1147 $('a.ntf-tab-link[href="#ntf-tab-all"]').closest('.ntf-tab-item').addClass('ntf-tab-active'); 1148 } 1149 1150 $(document).on('show.bs.dropdown', 'li.dropdown', function (e) { 1151 if ($(this).find('.notification-dropdown-menu').length) { 1152 ensureNotificationAllTabVisible(); 1153 if (!ntfPagers.all.loaded && !ntfPagers.all.loading) { 1154 loadNotifications(1, false, 'all'); 1155 ntfPagers.all.loaded = true; 1156 } 1157 } 1158 }); 1159 1160 $('.ntf-tab-content-wrapper').on('scroll', function () { 1161 var $this = $(this); 1162 if ($this.scrollTop() + $this.innerHeight() >= this.scrollHeight - 120) { 1163 // determine active tab/category 1164 var activePane = $('.ntf-tab-content .tab-pane.active'); 1165 var category = 'all'; 1166 if (activePane.is('#ntf-tab-time-tracking')) category = 'time'; 1167 else if (activePane.is('#ntf-tab-project')) category = 'project'; 1168 else if (activePane.is('#ntf-tab-attendance')) category = 'attendance'; 1169 else if (activePane.is('#ntf-tab-suspicious')) category = 'suspicious'; 1170 else if (activePane.is('#ntf-tab-general')) category = 'general'; 1171 else category = 'all'; 1172 1173 var pager = ntfPagers[category] || ntfPagers.all; 1174 if (pager.hasMore && !pager.loading) { 1175 loadNotifications(pager.page + 1, true, category); 1176 } 1177 } 1178 }); 1179 1180 $(document).on('click', '.dropdown-menu.w-xl', function (e) { 1181 e.stopPropagation(); 1182 }); 1183 1184 $(document).on('click', '.ntf-notification-item', function (e) { 1185 if ($(e.target).closest('.ntf-read-tick').length) return; 1186 e.preventDefault(); 1187 var id = $(this).data('notifyid'); 1188 var action = $(this).attr('data-action') || '#'; 1189 1190 var isComment = $(this).find('.ntf-comment-bubble').length > 0 || /^(.+?)\s+commented on\s+/i.test($(this).find('.ntf-notification-text').text()); 1191 try { console.debug('Notification click', { id: id, action: action, isComment: isComment }); } catch (e) {} 1192 if (isComment && (!action || action === '#')) { 1193 markSingleRead(id, '#'); 1194 return; 1195 } 1196 1197 var titleText = $(this).find('.ntf-notification-text').text() || ''; 1198 var approvedDeniedMatch = titleText.match(/(?:your|time|task|leave).{0,40}\bwas\s+(approved|denied|rejected|deleted)\b/i); 1199 if (approvedDeniedMatch) { 1200 markSingleRead(id, '#'); 1201 return; 1202 } 1203 1204 // If there is a valid action URL, navigate immediately and fire the mark-as-read request in the background. 1205 if (action && action !== '#') { 1206 // Best effort background mark: send GET then navigate (request may be cancelled by n
1206avigation, but server will process most of the time). 1207 $.ajax({ 1208 type: "GET", 1209 url: "/notification/markSingleRead/" + id, 1210 data: { 1211 _token: $('meta[name="csrf-token"]').attr("content"), 1212 }, 1213 dataType: "JSON", 1214 cache: false, 1215 }); 1216 1217 var _current = getBadgeCount(); 1218 syncNotificationCount(Math.max(0, _current - 1)); 1219 1220 // Navigate immediately to the action URL 1221 window.location.href = action; 1222 } else { 1223 // No navigation target: mark and remove locally as before 1224 markSingleRead(id, action); 1225 } 1226 }); 1227 1228 $(document).on('click', '.ntf-clear-all-text', function (e) { 1229 e.preventDefault(); 1230 markAllRead(); 1231 }); 1232 1233 // Click on the visual tick should dismiss the notification (mark as read) without navigating 1234 $(document).on('click', '.ntf-read-tick', function (e) { 1235 e.preventDefault(); 1236 e.stopPropagation(); 1237 var $item = $(this).closest('.ntf-notification-item'); 1238 var id = $item.data('notifyid'); 1239 // Call markSingleRead with '#' as action to avoid redirect 1240 markSingleRead(id, '#'); 1241 }); 1242 1243 // Allow keyboard activation (Enter/Space) for the tick 1244 $(document).on('keydown', '.ntf-read-tick', function (e) { 1245 if (e.key === 'Enter' || e.key === ' ') { 1246 e.preventDefault(); 1247 $(this).trigger('click'); 1248 } 1249 }); 1250 1251 $(document).on('click', '.ntf-settings-btn', function (e) { 1252 e.preventDefault(); 1253 if (typeof window.openNotificationSettings === 'function') { 1254 window.openNotificationSettings(); 1255 } else if ($('#notificationsettings_Modal').length) { 1256 $('#notificationsettings_Modal').modal('show'); 1257 } 1258 }); 1259 1260 window.markAllRead = markAllRead; 1261 function markAllRead() { 1262 $.ajax({ 1263 type: "GET", 1264 url: "/notification/markAsRead", 1265 data: { 1266 _token: $('meta[name="csrf-token"]').attr("content"), 1267 }, 1268 dataType: "JSON", 1269 cache: false, 1270 success: function () { 1271 ntfState.items = []; 1272 // Reset all category pagers 1273 Object.keys(ntfPagers).forEach(function (k) { 1274 ntfPagers[k].page = 1; 1275 ntfPagers[k].hasMore = false; 1276 ntfPagers[k].loaded = false; 1277 ntfPagers[k].loading = false; 1278 }); 1279 Object.keys(ntfLists).forEach(function (k) { if (ntfLists[k] && ntfLists[k].length) ntfLists[k].empty(); }); 1280 renderNotifications([], 0); 1281 }, 1282 1283 1284 }); 1285 } 1286 }); 1287})(jQuery); 1288 1289function markSingleRead(_id, _action) { 1290 $.ajax({ 1291 type: "GET", 1292 url: "/notification/markSingleRead/" + _id, 1293 data: { 1294 _token: $('meta[name="csrf-token"]').attr("content"), 1295 }, 1296 dataType: "JSON", 1297 cache: false, 1298 success: function () { 1299 ntfState.items = ntfState.items.filter(function (item) { 1300 return item.id !== _id; 1301 }); 1302 removeNotificationFromLists(_id); 1303 var current = getBadgeCount(); 1304 syncNotificationCount(Math.max(0, current - 1)); 1305 updateEmptyStates(getListCounts()); 1306 if (_action && _action !== '#') { 1307 window.location.href = _action; 1308 } 1309 }, 1310 1311 }); 1312} 1313 1314(function () { 1315 'use strict'; 1316 1317 $(document).ready(function () { 1318 $(document).on('click', '.ntf-tab-link', function (e) { 1319 e.preventDefault(); 1320 e.stopPropagation(); 1321 1322 var targetTab = $(this).attr('href'); 1323 if (!targetTab || targetTab === '#') return; 1324 1325 $('.ntf-tab-item').removeClass('ntf-tab-active'); 1326 $(this).closest('.ntf-tab-item').addClass('ntf-tab-active'); 1327 1328 $('.ntf-tab-content .tab-pane').removeClass('active in').addClass('fade'); 1329 $(targetTab).removeClass('fade').addClass('active in'); 1330 1331 /* Force content wrapper to recalc height so panel shrinks when switching from Leave back to Task */ 1332 var $wrap = $('.ntf-tab-content-wrapper'); 1333 if ($wrap.length) { 1334 $wrap.css({ height: 'auto', minHeight: '0' }); 1335 $wrap[0].offsetHeight; /* reflow */ 1336 } 1337 1338 var category = 'all'; 1339 if (targetTab === '#ntf-tab-time-tracking') category = 'time'; 1340 else if (targetTab === '#ntf-tab-project') category = 'project'; 1341 else if (targetTab === '#ntf-tab-attendance') category = 'attendance'; 1342 else if (targetTab === '#ntf-tab-suspicious') category = 'suspicious'; 1343 else if (targetTab === '#ntf-tab-general') category = 'general'; 1344 1345 var pager = ntfPagers[category] || ntfPagers.all; 1346 if (pager.loading) return; 1347 1348 var hasRenderedItems = ntfLists[category] && ntfLists[category].length 1349 ? ntfLists[category].find('.ntf-notification-item').length > 0 1350 : false;
1351 1352 if (category !== 'all' && pager.loaded && hasRenderedItems) { 1353 updateEmptyStates(getListCounts()); 1354 return; 1355 } 1356 1357 pager.page = 1; 1358 pager.hasMore = true; 1359 pager.loaded = false; 1360 1361 if (ntfLists[category] && ntfLists[category].length) ntfLists[category].empty(); 1362 loadNotifications(1, false, category); 1363 }); 1364 }); 1365})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.