import { apiGet, apiPostUrlEncoded } from './api.js'; import { deriveMobileExperience, getStoredUserProfile } from './mobile-profile.js'; const NOTIFICATIONS_KEY = 'insuite_mobile_notifications'; const REMOTE_NOTIFICATIONS_INIT_KEY = 'insuite_mobile_notifications_remote_init'; export const NOTIFICATIONS_UPDATED_EVENT = 'insuite:notifications-updated'; export function getNotifications() { try { const raw = localStorage.getItem(NOTIFICATIONS_KEY); const parsed = raw ? JSON.parse(raw) : []; return Array.isArray(parsed) ? parsed : []; } catch { return []; } } export function getUnreadNotificationCount() { return getNotifications().filter((item) => !item.read).length; } export function markAllNotificationsRead() { const updated = getNotifications().map((item) => ({ ...item, read: true })); persistNotifications(updated, { silent: true }); return updated; } export async function markAllNotificationsReadEverywhere() { const updated = markAllNotificationsRead(); try { await apiPostUrlEncoded('/api/mobile/notifications/read-all', {}); } catch { return updated; } return updated; } export function syncNotifications({ incidents = [], tickets = [] } = {}) { const current = getNotifications(); const remoteNotifications = current.filter((item) => item.source !== 'local'); const readState = new Map(current.filter((item) => item.source === 'local').map((item) => [item.id, Boolean(item.read)])); const next = buildNotifications({ incidents, tickets }).map((item) => ({ ...item, source: 'local', read: readState.get(item.id) ?? false, })); const merged = sortNotifications([...remoteNotifications, ...next]); persistNotifications(merged, { silent: true }); return merged; } export async function refreshRemoteNotifications({ playSound = false } = {}) { const current = getNotifications(); const localNotifications = current.filter((item) => item.source === 'local'); const previousUnreadIds = new Set(current.filter((item) => item.source === 'server' && !item.read).map((item) => item.id)); const res = await apiGet('/api/mobile/notifications'); const remoteItems = Array.isArray(res?.notifications) ? res.notifications.map((item) => mapRemoteNotification(item)) : []; const hasBootstrapped = localStorage.getItem(REMOTE_NOTIFICATIONS_INIT_KEY) === '1'; const hasNewUnread = hasBootstrapped && remoteItems.some((item) => !item.read && !previousUnreadIds.has(item.id)); const merged = sortNotifications([...remoteItems, ...localNotifications]); persistNotifications(merged, { playSound: playSound && hasNewUnread }); localStorage.setItem(REMOTE_NOTIFICATIONS_INIT_KEY, '1'); return merged; } export function pushLocalNotification(notification, { playSound = true } = {}) { const current = getNotifications(); const remoteNotifications = current.filter((item) => item.source !== 'local'); const localNotifications = current.filter((item) => item.source === 'local'); const nextItem = { id: notification?.id || `local-${Date.now()}`, source: 'local', title: notification?.title || 'Notification', body: notification?.body || '', route: notification?.route || '/dashboard', icon: notification?.icon || 'fa-bell', tone: notification?.tone || 'neutral', read: false, timestamp: notification?.timestamp || new Date().toISOString(), }; const dedupedLocals = localNotifications.filter((item) => item.id !== nextItem.id); const merged = sortNotifications([nextItem, ...dedupedLocals, ...remoteNotifications]); persistNotifications(merged, { playSound }); return nextItem; } function buildNotifications({ incidents, tickets }) { const experience = deriveMobileExperience(getStoredUserProfile()); const notifications = []; const urgentIncidents = incidents.filter((item) => String(item.priority || '').toLowerCase() === 'urgent'); const openFtth = tickets.filter((item) => { const status = String(item.status || '').toLowerCase(); return status && status !== 'traité' && status !== 'validé' && status !== 'clôturé'; }); if (experience.moduleKey === 'backbones' || experience.moduleKey === 'mixed') { notifications.push({ id: `incidents-total-${incidents.length}`, title: incidents.length ? `${incidents.length} incident(s) assigné(s)` : 'Aucun incident assigné', body: incidents.length ? 'Consultez le portefeuille incidents pour prioriser le traitement terrain.' : 'Aucun ticket incident en attente pour le moment.', route: '/assigned', icon: 'fa-triangle-exclamation', tone: incidents.length ? 'warning' : 'neutral', timestamp: new Date().toISOString(), }); } if ((experience.moduleKey === 'backbones' || experience.moduleKey === 'mixed') && urgentIncidents.length) { notifications.push({ id: `incidents-urgent-${urgentIncidents.length}`, title: `${urgentIncidents.length} incident(s) urgent(s) à traiter`, body: 'Des interventions marquées urgentes nécessitent une prise en charge rapide.', route: '/assigned', icon: 'fa-bolt', tone: 'danger', timestamp: new Date().toISOString(), }); } if (experience.moduleKey !== 'backbones') { const moduleAssignmentLabel = describeModuleAssignments(experience, tickets.length); notifications.push({ id: `ftth-total-${tickets.length}`, title: tickets.length ? `${tickets.length} ${moduleAssignmentLabel} assignée(s)` : `Aucune ${moduleAssignmentLabel} assignée`, body: tickets.length ? `Le portefeuille ${experience.moduleLabel} contient des fiches terrain prêtes à être ouvertes.` : `Aucune intervention ${experience.moduleLabel.toLowerCase()} en attente actuellement.`, route: '/ftth', icon: 'fa-network-wired', tone: tickets.length ? 'info' : 'neutral', timestamp: new Date().toISOString(), }); } if (experience.moduleKey !== 'backbones' && openFtth.length) { const moduleAssignmentLabel = describeModuleAssignments(experience, openFtth.length); notifications.push({ id: `ftth-open-${openFtth.length}`, title: `${openFtth.length} ${moduleAssignmentLabel} en cours`, body: 'Pensez à finaliser ou mettre à jour les fiches terrain en attente.', route: '/ftth', icon: 'fa-file-waveform', tone: 'primary', timestamp: new Date().toISOString(), }); } notifications.push({ id: 'dashboard-entry', title: 'Dashboard mobile disponible', body: 'Le tableau de bord centralise maintenant vos compteurs, raccourcis et alertes in-app.', route: '/dashboard', icon: 'fa-chart-line', tone: 'success', timestamp: new Date().toISOString(), }); return notifications.slice(0, 6); } function mapRemoteNotification(item) { const id = `server-${Number(item?.id || 0)}`; return { id, remoteId: Number(item?.id || 0), source: 'server', title: item?.title || 'Notification', body: item?.body || '', route: normalizeNotificationRoute(item?.url || ''), url: item?.url || '', icon: guessNotificationIcon(item), tone: guessNotificationTone(item), read: Number(item?.is_read || 0) === 1, timestamp: item?.created_at || new Date().toISOString(), }; } function normalizeNotificationRoute(url) { const value = String(url || ''); if (!value) return '/dashboard'; if (value.includes('/raccordement-clients')) return '/ftth'; if (value.includes('/maintenance-ftth')) return '/ftth'; if (value.includes('/incidents/treatment') || value.includes('/incidents')) return '/assigned'; if (value.includes('/notifications')) return '/dashboard'; if (value.includes('/dashboard')) return '/dashboard'; return '/dashboard'; } function guessNotificationIcon(item) { const text = `${item?.title || ''} ${item?.body || ''}`.toLowerCase(); if (text.includes('urgent') || text.includes('incident')) return 'fa-triangle-exclamation'; if (text.includes('raccordement')) return 'fa-network-wired'; if (text.includes('ftth') || text.includes('maintenance')) return 'fa-network-wired'; if (text.includes('valid')) return 'fa-check-double'; if (text.includes('rejet')) return 'fa-times-circle'; return 'fa-bell'; } function guessNotificationTone(item) { const text = `${item?.title || ''} ${item?.body || ''}`.toLowerCase(); if (text.includes('urgent') || text.includes('rejet')) return 'danger'; if (text.includes('assign') || text.includes('soumis')) return 'warning'; if (text.includes('valid')) return 'success'; if (text.includes('raccordement')) return 'info'; if (text.includes('ftth')) return 'info'; return 'neutral'; } function sortNotifications(notifications) { return [...notifications].sort((left, right) => { const leftTime = Date.parse(left?.timestamp || '') || 0; const rightTime = Date.parse(right?.timestamp || '') || 0; return rightTime - leftTime; }); } let notificationAudioContext = null; function ensureAudioContext() { const AudioContextCtor = window.AudioContext || window.webkitAudioContext; if (!AudioContextCtor) return null; if (!notificationAudioContext) { notificationAudioContext = new AudioContextCtor(); } return notificationAudioContext; } function armNotificationSound() { const context = ensureAudioContext(); if (context?.state === 'suspended') { context.resume().catch(() => {}); } } ['click', 'touchstart', 'keydown'].forEach((eventName) => { window.addEventListener(eventName, armNotificationSound, { passive: true }); }); function playNotificationSound() { const context = ensureAudioContext(); if (!context) return; const now = context.currentTime; const oscillator = context.createOscillator(); const gainNode = context.createGain(); oscillator.type = 'sine'; oscillator.frequency.setValueAtTime(880, now); oscillator.frequency.exponentialRampToValueAtTime(660, now + 0.18); gainNode.gain.setValueAtTime(0.0001, now); gainNode.gain.exponentialRampToValueAtTime(0.08, now + 0.02); gainNode.gain.exponentialRampToValueAtTime(0.0001, now + 0.22); oscillator.connect(gainNode); gainNode.connect(context.destination); oscillator.start(now); oscillator.stop(now + 0.24); } function persistNotifications(notifications, { silent = false, playSound = false } = {}) { localStorage.setItem(NOTIFICATIONS_KEY, JSON.stringify(notifications)); if (!silent && playSound) { playNotificationSound(); } window.dispatchEvent(new CustomEvent(NOTIFICATIONS_UPDATED_EVENT, { detail: notifications })); } function describeModuleAssignments(experience, count) { if (experience.moduleKey === 'raccordement') { return count > 1 ? 'raccordements' : 'raccordement'; } return count > 1 ? 'missions FTTH' : 'mission FTTH'; }