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 `
${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 ? `
Recherche de votre position GPS…
Distance
En attente
Trajet
En attente
Technicien
Position en cours de détection
${escHtml(formatCoordinates(t.client_lat, t.client_lng))}
` : ''}
`;
}).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.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);
}
}
}