import { apiGet, apiPostUrlEncoded } from '../api.js'; import { destroyLiveRouteMap, formatCoordinates, hasValidCoordinates, launchDirections, mountLiveRouteMap, openExternalMap, } from '../navigation.js'; import { syncNotifications } from '../notifications.js'; const STATUS_LABELS = { nouveau: { label: 'Nouveau', cls: 'text-bg-primary' }, assigné: { label: 'Assigné', cls: 'text-bg-warning' }, en_cours: { label: 'En cours', cls: 'text-bg-info' }, attente_planification: { label: 'Attente planification', cls: 'text-bg-dark' }, traité: { label: 'Traité', cls: 'text-bg-success' }, validé: { label: 'Validé', cls: 'text-bg-success' }, clôturé: { label: 'Clôturé', cls: 'text-bg-secondary' }, }; const PRIORITY_CLS = { Urgent: 'text-bg-danger', Haute: 'text-bg-warning', Moyenne:'text-bg-primary', Basse: 'text-bg-secondary', }; const REPORT_LABELS = { brouillon: '📝 Brouillon', soumis: '⏳ En attente validation', validé: '✅ Validé', rejeté: '❌ Rejeté', null: '— À remplir', }; const VISIT_STATUS_LABELS = { intervention_effectuee: 'Intervention réalisée', client_indisponible: 'Client indisponible', reprogrammation_demandee: 'Reprogrammation demandée', }; const FTTH_FLASH_KEY = 'insuite_ftth_flash'; const RACCORDES_FIELD_CANDIDATES = { subscription: ['NumeroAbonnement', 'Numero Abonnement', 'Numéro Abonnement', 'Abonnement'], customerName: ['NomClient', 'Nom client', 'Nom du client'], address: ['Localisation', 'LOCALISATION', 'Adresse intervention'], contact1: ['Contact1', 'CONTACT CLIENT 1', 'Contact client 1'], contact2: ['Contact2', 'CONTACT CLIENT 2', 'Contact client 2'], plaque: ['PLAQUE', 'Plaque'], jdv: ['JDV', 'JDV CLIENT'], pco: ['PCO', 'PCO CLIENT'], snont: ['SNONT'], nd: ['ND'], progress: ['Avancement'], company: ['EntrepriseFTTH', 'Entreprise'], city: ['Ville'], commune: ['Commune'], }; export async function renderFtthList(root, { experience } = {}) { const currentExperience = experience || { moduleKey: 'mixed', moduleListTitle: 'Interventions assignées', moduleHeroSubtitle: 'Raccordements clients et maintenance FTTH regroupés dans une seule vue terrain.', }; const routeMapControllers = new Map(); root.innerHTML = `
portefeuille terrain
${escHtml(currentExperience.moduleListTitle || 'Interventions assignées')}

${escHtml(currentExperience.moduleHeroSubtitle || 'Raccordements clients et maintenance FTTH regroupés dans une seule vue terrain.')}

Chargement…
`; const flashBox = root.querySelector('#ftthFlashBox'); const loadBox = root.querySelector('#loadBox'); const listBox = root.querySelector('#listBox'); renderFlashMessage(flashBox); const isSupervisor = isValidationSupervisor(experience?.profile?.role_key); const validationBox = root.querySelector('#validationBox'); async function load() { routeMapControllers.forEach((controller, key) => { controller?.destroy?.(); routeMapControllers.delete(key); }); loadBox.style.display = 'block'; loadBox.textContent = 'Chargement…'; listBox.innerHTML = ''; if (validationBox) validationBox.style.display = 'none'; try { const res = await apiGet('/api/mobile/ftth/assigned'); syncNotifications({ tickets: res?.tickets || [] }); loadBox.style.display = 'none'; if (!res?.ok || !res.tickets?.length) { listBox.innerHTML = `

Aucune intervention assignée.

Le portefeuille se mettra à jour dès qu'une mission terrain vous sera affectée.

`; } else { listBox.innerHTML = res.tickets.map(t => { const context = buildTicketContext(t); const st = STATUS_LABELS[t.status] || { label: t.status, cls: 'text-bg-secondary' }; const pri = PRIORITY_CLS[t.priority] || 'text-bg-secondary'; const rep = REPORT_LABELS[t.report_status] || REPORT_LABELS['null']; const visitStatus = VISIT_STATUS_LABELS[t.client_visit_status] || ''; const plannedSlot = t.planned_intervention_date ? `${fmtDate(t.planned_intervention_date)}${t.planned_intervention_time ? ` a ${String(t.planned_intervention_time).slice(0, 5)}` : ''}` : ''; const hasGps = hasValidCoordinates(t.client_lat, t.client_lng); return `
${escHtml(context.title)}
${escHtml(context.subtitle)}
${escHtml(t.priority)}
${context.address ? `
${escHtml(context.address)}
` : ''}
${escHtml(context.headline)}
${context.networkPath ? `
${escHtml(context.networkPath)}
` : ''} ${context.secondaryLine ? `
${escHtml(context.secondaryLine)}
` : ''}
${st.label} ${rep} ${escHtml(context.modeLabel)} ${visitStatus ? `${escHtml(visitStatus)}` : ''} ${hasGps ? 'GPS' : ''}
Assigné le ${fmtDate(t.assigned_at)}
Ouvrir
${plannedSlot ? `
Replanifié pour ${escHtml(plannedSlot)}
` : ''} ${hasGps ? `
` : ''}
`; }).join(''); listBox.querySelectorAll('[data-id]').forEach(card => { card.addEventListener('click', () => { location.hash = '#/ftth-report?id=' + card.dataset.id; }); }); listBox.querySelectorAll('[data-card-action]').forEach((node) => { node.addEventListener('click', (event) => { event.stopPropagation(); }); }); listBox.querySelectorAll('.js-toggle-ftth-map').forEach((button) => { button.addEventListener('click', (event) => { event.stopPropagation(); const targetId = button.getAttribute('data-map-target') || ''; const mapCard = targetId ? document.getElementById(targetId) : null; if (!mapCard) return; const willShow = mapCard.hidden; mapCard.hidden = !willShow; const ticketId = String(targetId).replace('ftth-map-', ''); if (willShow) { const mapCanvas = document.getElementById(`ftth-map-canvas-${ticketId}`); const statusEl = document.getElementById(`ftth-map-status-${ticketId}`); const distanceEl = document.getElementById(`ftth-map-distance-${ticketId}`); const durationEl = document.getElementById(`ftth-map-duration-${ticketId}`); const technicianEl = document.getElementById(`ftth-map-tech-${ticketId}`); const label = button.getAttribute('data-label') || ''; const card = button.closest('[data-id]'); const ticket = res.tickets.find((item) => String(item.id) === String(card?.dataset.id || ticketId)); if (ticket && mapCanvas) { const context = buildTicketContext(ticket); const controller = mountLiveRouteMap({ mapElement: mapCanvas, destinationLat: ticket.client_lat, destinationLng: ticket.client_lng, destinationLabel: context.mapLabel, statusElement: statusEl, distanceElement: distanceEl, durationElement: durationEl, technicianCoordsElement: technicianEl, }); routeMapControllers.set(ticketId, controller); } } else { const existingController = routeMapControllers.get(ticketId); existingController?.destroy?.(); routeMapControllers.delete(ticketId); const mapCanvas = document.getElementById(`ftth-map-canvas-${ticketId}`); destroyLiveRouteMap(mapCanvas); } button.innerHTML = willShow ? 'Masquer carte' : 'Carte'; }); }); listBox.querySelectorAll('.js-start-route').forEach((button) => { button.addEventListener('click', (event) => { event.stopPropagation(); launchDirections( button.getAttribute('data-lat'), button.getAttribute('data-lng'), button.getAttribute('data-label') || '' ); }); }); listBox.querySelectorAll('.js-open-ftth-map-external').forEach((button) => { button.addEventListener('click', (event) => { event.stopPropagation(); openExternalMap( button.getAttribute('data-lat'), button.getAttribute('data-lng'), button.getAttribute('data-label') || '' ); }); }); } // fin else tickets if (isSupervisor && validationBox) { await loadValidations(validationBox); } } catch (e) { loadBox.style.display = 'block'; loadBox.innerHTML = `Erreur de chargement. Vérifiez votre connexion.`; } } root.querySelector('#btnRefresh').addEventListener('click', load); await load(); } function isValidationSupervisor(roleKey) { const rk = String(roleKey || '').toLowerCase().trim(); return ['admin', 'manager', 'superviseur', 'supervisor'].includes(rk); } async function loadValidations(container) { container.style.display = 'block'; container.innerHTML = `
supervision
Validations en attente
Chargement des validations...
`; const vList = container.querySelector('#validationList'); try { const [res, techsRes] = await Promise.all([ apiGet('/api/mobile/ftth/validations'), apiGet('/api/mobile/ftth/raccordement-techs').catch(() => ({ ok: false, techs: [] })), ]); if (!res?.ok) { vList.innerHTML = '
Impossible de charger les validations.
'; return; } const items = Array.isArray(res.items) ? res.items : []; const raccTechs = Array.isArray(techsRes?.techs) ? techsRes.techs : []; if (!items.length) { vList.innerHTML = '

Aucun rapport en attente de validation.

'; return; } const techOptions = raccTechs.map(t => `` ).join(''); vList.innerHTML = items.map(item => { const isRaccHandoff = !!(item.study_submitted_at) && item.workflow_phase === 'installation'; const extraFields = parseExtraFields(item.extra_fields); const isRacc = isRaccordementTicket(item, extraFields) || isRaccHandoff; const dateStr = fmtDate(item.study_submitted_at || item.submitted_at); const needsTech = isRaccHandoff && raccTechs.length > 0; const noTechAvail = isRaccHandoff && raccTechs.length === 0; return `
${escHtml(item.client_name || 'Client')}
${escHtml(item.ref_code || ('Ticket #' + item.ticket_id))}
En attente
${escHtml(item.technician_name)}  · ${escHtml(dateStr)}
${isRacc ? (isRaccHandoff ? 'Etude raccordement' : 'Installation') : 'Maintenance FTTH'}
${needsTech ? `
` : ''} ${noTechAvail ? `
Aucun technicien raccordement disponible. Utilisez la webapp pour assigner.
` : ''}
`; }).join(''); vList.querySelectorAll('.js-btn-validate').forEach((btn) => { btn.addEventListener('click', async () => { const card = btn.closest('[data-report-id]'); const reportId = card ? card.dataset.reportId : ''; const isRaccHandoff = card && card.dataset.isRaccHandoff === '1'; const statusEl = card ? card.querySelector('.js-validation-status') : null; let installTechId = ''; if (isRaccHandoff) { const sel = card ? card.querySelector('.js-racc-tech-select') : null; installTechId = sel ? sel.value : ''; if (!installTechId) { if (statusEl) { statusEl.style.display = ''; statusEl.innerHTML = '
Selectionnez un technicien raccordement.
'; } return; } } btn.disabled = true; if (statusEl) { statusEl.style.display = ''; statusEl.innerHTML = '
Validation en cours...
'; } try { const payload = { report_id: reportId, action: 'validate' }; if (installTechId) payload.installation_technician_id = installTechId; const resp = await apiPostUrlEncoded('/api/mobile/ftth/validate', payload); if (resp && resp.ok) { if (card) card.style.opacity = '0.5'; if (statusEl) statusEl.innerHTML = '
Valide avec succes.
'; window.setTimeout(() => { if (card) card.remove(); }, 1500); } else { const msg = (resp && (resp.message || resp.error)) || 'Erreur lors de la validation.'; if (statusEl) statusEl.innerHTML = '
' + escHtml(msg) + '
'; btn.disabled = false; } } catch (err) { if (statusEl) statusEl.innerHTML = '
Erreur reseau.
'; btn.disabled = false; } }); }); vList.querySelectorAll('.js-btn-reject').forEach((btn) => { btn.addEventListener('click', async () => { const card = btn.closest('[data-report-id]'); const reportId = card ? card.dataset.reportId : ''; const statusEl = card ? card.querySelector('.js-validation-status') : null; const reason = window.prompt('Motif du rejet :') || ''; if (!reason.trim()) return; btn.disabled = true; if (statusEl) { statusEl.style.display = ''; statusEl.innerHTML = '
Rejet en cours...
'; } try { const resp = await apiPostUrlEncoded('/api/mobile/ftth/validate', { report_id: reportId, action: 'reject', reason }); if (resp && resp.ok) { if (card) card.style.opacity = '0.5'; if (statusEl) statusEl.innerHTML = '
Rejete.
'; window.setTimeout(() => { if (card) card.remove(); }, 1500); } else { const msg = (resp && (resp.message || resp.error)) || 'Erreur.'; if (statusEl) statusEl.innerHTML = '
' + escHtml(msg) + '
'; btn.disabled = false; } } catch (err) { if (statusEl) statusEl.innerHTML = '
Erreur reseau.
'; btn.disabled = false; } }); }); } catch (err) { vList.innerHTML = '
Erreur de chargement des validations.
'; } } function buildTicketContext(ticket) { const extraFields = parseExtraFields(ticket.extra_fields); const isRaccordement = isRaccordementTicket(ticket, extraFields); const subscription = firstNonEmpty(ticket.client_code, getImportedField(extraFields, RACCORDES_FIELD_CANDIDATES.subscription)); const customerName = firstNonEmpty(ticket.client_name, getImportedField(extraFields, RACCORDES_FIELD_CANDIDATES.customerName)); const address = firstNonEmpty(ticket.client_address, getImportedField(extraFields, RACCORDES_FIELD_CANDIDATES.address), ticket.site_name); const contact = firstNonEmpty(ticket.client_phone, getImportedField(extraFields, RACCORDES_FIELD_CANDIDATES.contact1)); const secondaryContact = firstNonEmpty(ticket.client_phone2, getImportedField(extraFields, RACCORDES_FIELD_CANDIDATES.contact2)); const plaque = firstNonEmpty(ticket.sro_client, getImportedField(extraFields, RACCORDES_FIELD_CANDIDATES.plaque)); const jdv = firstNonEmpty(ticket.jdv_client, getImportedField(extraFields, RACCORDES_FIELD_CANDIDATES.jdv)); const pco = firstNonEmpty(ticket.pco_client, getImportedField(extraFields, RACCORDES_FIELD_CANDIDATES.pco)); const snont = getImportedField(extraFields, RACCORDES_FIELD_CANDIDATES.snont); const nd = getImportedField(extraFields, RACCORDES_FIELD_CANDIDATES.nd); const progress = firstNonEmpty(ticket.avancement, getImportedField(extraFields, RACCORDES_FIELD_CANDIDATES.progress)); const company = firstNonEmpty(ticket.company_name, getImportedField(extraFields, RACCORDES_FIELD_CANDIDATES.company)); const city = getImportedField(extraFields, RACCORDES_FIELD_CANDIDATES.city); const commune = getImportedField(extraFields, RACCORDES_FIELD_CANDIDATES.commune); const networkPath = [ plaque ? `PLAQUE ${plaque}` : '', jdv ? `JDV ${jdv}` : '', pco ? `PCO ${pco}` : '', ].filter(Boolean).join(' => '); if (isRaccordement) { return { isRaccordement, modeLabel: 'Raccordement', title: customerName || 'Client raccordement', subtitle: subscription || ticket.ref_code || (`RACC-${ticket.id}`), address, headline: progress || 'Raccordement client', networkPath, secondaryLine: [contact, secondaryContact, city && commune ? `${city} / ${commune}` : (city || commune), company, snont, nd] .filter(Boolean) .join(' · '), mapLabel: customerName || subscription || ticket.ref_code || (`RACC-${ticket.id}`), }; } return { isRaccordement, modeLabel: 'FTTH', title: ticket.client_name || 'Client FTTH', subtitle: ticket.ref_code || (`FTTH-${ticket.id}`), address, headline: ticket.nature_intervention || 'Maintenance FTTH B2B', networkPath, secondaryLine: [contact, secondaryContact, company].filter(Boolean).join(' · '), mapLabel: ticket.client_name || ticket.ref_code || (`FTTH-${ticket.id}`), }; } function parseExtraFields(rawValue) { if (!rawValue) return {}; if (typeof rawValue === 'object') return rawValue; try { const parsed = JSON.parse(rawValue); return parsed && typeof parsed === 'object' ? parsed : {}; } catch { return {}; } } function getImportedField(extraFields, candidates) { const entries = Object.entries(extraFields || {}); for (const candidate of candidates) { const normalizedCandidate = normalizeImportKey(candidate); for (const [label, value] of entries) { const normalizedLabel = normalizeImportKey(label); if (!normalizedLabel || value == null || String(value).trim() === '') continue; if (normalizedLabel === normalizedCandidate || normalizedLabel.includes(normalizedCandidate) || normalizedCandidate.includes(normalizedLabel)) { return String(value).trim(); } } } return ''; } function isRaccordementTicket(ticket, extraFields) { if (typeof ticket?.mobile_is_raccordement === 'boolean') { return ticket.mobile_is_raccordement; } const moduleValue = normalizeImportKey(extraFields?.module || ''); return moduleValue === 'raccordement clients'; } function normalizeImportKey(value) { return String(value || '') .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .toLowerCase() .replace(/[^a-z0-9]+/g, ' ') .trim(); } function firstNonEmpty(...values) { for (const value of values) { if (value != null && String(value).trim() !== '') { return String(value).trim(); } } return ''; } function escHtml(str) { return String(str ?? '').replace(/&/g,'&').replace(//g,'>'); } function escAttr(str) { return String(str ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function fmtDate(str) { if (!str) return '—'; const d = new Date(str.replace(' ','T')); return isNaN(d) ? str : d.toLocaleDateString('fr-FR', { day:'2-digit', month:'2-digit', year:'numeric' }); } function renderFlashMessage(box) { if (!box) return; const raw = sessionStorage.getItem(FTTH_FLASH_KEY); if (!raw) return; try { const flash = JSON.parse(raw); if (!flash?.message || flash.scope !== 'list') return; box.innerHTML = `
${escHtml(flash.message)}
`; box.style.display = 'block'; } catch { box.style.display = 'none'; } finally { try { const flash = JSON.parse(raw); if (flash?.scope === 'list') { sessionStorage.removeItem(FTTH_FLASH_KEY); } } catch { sessionStorage.removeItem(FTTH_FLASH_KEY); } } }