PageSourceSearch

https://app.desklog.io/js/notification.js

js desklog.io collected 2026-09-24 11:26:57 UTC 64,777 bytes, 1,365 lines download raw bytes

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;">&#10003;</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.