const API_BASE = ''; let allTickets = []; let filteredTickets = []; let customFieldsMap = []; let lastUsername = ''; let lastApiToken = ''; function showToast(msg, type='success') { const c = document.getElementById('toastContainer'); const t = document.createElement('div'); t.className = `toast toast-${type}`; t.innerHTML = `${msg}`; c.appendChild(t); setTimeout(() => t.remove(), 3500); } function autoJql() { const key = document.getElementById('projectKey').value.trim().toUpperCase(); const jql = document.getElementById('jqlQuery'); if (!jql.value.trim() || jql.value.trim().startsWith('project =')) { jql.value = `project = "${key}" ORDER BY created DESC`; } } async function pingApi() { const btn = event.target.closest('.btn'); const orig = btn.innerHTML; btn.innerHTML = ' Test...'; btn.disabled = true; try { const r = await fetch(`${API_BASE}/api/extract`, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({username:'_', apiToken:'_', projectKey:'_'}) }); const d = await r.json(); if (r.status === 400) showToast('API locale connectée ! (Erreur 400 = normal, on a envoyé de fausses données)', 'success'); else showToast('API répond : ' + r.status, 'success'); } catch(e) { showToast('API inaccessible. Lancez "npm start" dans le dossier du serveur.', 'error'); } finally { btn.innerHTML = orig; btn.disabled = false; } } async function startExtraction() { const btn = document.getElementById('extractBtn'); const user = document.getElementById('username').value.trim(); const token = document.getElementById('apiToken').value.trim(); const project = document.getElementById('projectKey').value.trim().toUpperCase(); const jql = document.getElementById('jqlQuery').value.trim(); const custom = document.getElementById('customFields').value.trim(); if (!user || !token || !project) { showToast('Remplissez utilisateur, token et clé du projet.', 'error'); return; } btn.innerHTML = ' Extraction via API...'; btn.disabled = true; try { const resp = await fetch(`${API_BASE}/api/extract`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: user, apiToken: token, projectKey: project, jql: jql, customFields: custom }) }); const data = await resp.json(); if (!resp.ok) { throw new Error(data.error || `Erreur HTTP ${resp.status}`); } allTickets = data.tickets; filteredTickets = [...allTickets]; customFieldsMap = parseCustomFields(custom); lastUsername = user; lastApiToken = token; renderResults(); showToast(`${allTickets.length} tickets extraits avec succès.`, 'success'); } catch (err) { showToast(err.message, 'error'); } finally { btn.innerHTML = ' Lancer l\'extraction'; btn.disabled = false; } } function parseCustomFields(customFields) { const map = []; if (customFields) { customFields.split(',').forEach(cf => { const [id, label] = cf.split(':').map(s => s.trim()); if (id) map.push({ id, label: label || id }); }); } return map; } async function updateTicket(ticketKey, dueDate, goLiveDate) { if (!lastUsername || !lastApiToken) { showToast('Veuillez d\'abord extraire les tickets.', 'error'); return; } const btn = document.querySelector(`[data-ticket="${ticketKey}"]`); const orig = btn.innerHTML; btn.innerHTML = ''; btn.disabled = true; const goLiveField = customFieldsMap.find(cf => cf.label.toLowerCase().includes('go live')); const updates = {}; if (dueDate !== undefined) updates.duedate = dueDate; if (goLiveDate !== undefined && goLiveField) { updates[goLiveField.id] = goLiveDate; } try { const resp = await fetch(`${API_BASE}/api/update`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: lastUsername, apiToken: lastApiToken, ticketKey, updates }) }); const data = await resp.json(); if (!resp.ok) { throw new Error(data.error || `Erreur HTTP ${resp.status}`); } const ticket = allTickets.find(t => t.key === ticketKey); if (ticket) { if (updates.duedate !== undefined) ticket.dueDate = updates.duedate; if (goLiveField && updates[goLiveField.id] !== undefined) ticket[goLiveField.label] = updates[goLiveField.id]; } renderTable(); showToast(`Ticket ${ticketKey} mis à jour avec succès !`, 'success'); } catch (err) { showToast(err.message, 'error'); } finally { btn.innerHTML = orig; btn.disabled = false; } } function renderResults() { document.getElementById('results-section').classList.add('visible'); renderStats(); renderTable(); renderJSON(); } function renderStats() { const unassigned = allTickets.filter(t => !t.assignee).length; const now = new Date(); const overdue = allTickets.filter(t => t.dueDate && t.statusCategory !== 'Done' && new Date(t.dueDate) < now).length; document.getElementById('statsRow').innerHTML = `
${allTickets.length}
Tickets
${new Set(allTickets.map(t=>t.status)).size}
Statuts
${unassigned}
Non assignés
${overdue}
En retard
`; } function escHtml(s) { if(!s) return ''; const d=document.createElement('div'); d.textContent=String(s); return d.innerHTML; } function fmtDate(d) { if(!d) return '-'; try { return new Date(d).toLocaleDateString('fr-FR',{day:'2-digit',month:'short',year:'numeric'}); } catch { return escHtml(d); } } function getStatusClass(s) { if(!s) return 's-other'; const l=s.toLowerCase(); if(l.includes('done')||l.includes('ferm')||l.includes('résolu')) return 's-done'; if(l.includes('progress')||l.includes('en cours')||l.includes('review')) return 's-progress'; if(l.includes('to do')||l.includes('open')||l.includes('backlog')) return 's-todo'; if(l.includes('block')) return 's-blocked'; return 's-other'; } function renderTable() { // Trouver le champ Go Live Date let goLiveColName = null; if (allTickets.length > 0) { Object.keys(allTickets[0]).forEach(key => { if (!['key','url','summary','description','status','statusCategory','assignee','reporter','priority','issueType','created','updated','dueDate','resolution','resolutionDate','labels','components','fixVersions','projectName'].includes(key)) { goLiveColName = key; } }); } const cols = ['Ticket','Summary','Statut','Assigné','Priorité','Type','Due Date','Créé le','Résolu le','Actions']; if (goLiveColName) { cols.splice(cols.indexOf('Résolu le'), 0, goLiveColName, 'Actions'); } document.getElementById('tableHead').innerHTML = '' + cols.map(c=>`${c}`).join('') + ''; let body = ''; filteredTickets.forEach(t => { // Due Date éditable const dueDateValue = t.dueDate ? t.dueDate.substring(0, 10) : ''; const dueDateInput = ``; body += ` ${escHtml(t.key)} ${escHtml(t.summary)} ${escHtml(t.status)} ${escHtml(t.assignee) || '-'} ${escHtml(t.priority)} ${escHtml(t.issueType)} ${dueDateInput} ${fmtDate(t.created)} ${fmtDate(t.resolutionDate)}`; if (goLiveColName) { const goLiveDateValue = t[goLiveColName] ? t[goLiveColName].substring(0, 10) : ''; const goLiveDateInput = ``; body += `${goLiveDateInput}`; } body += ` `; }); if(!filteredTickets.length) body = `Aucun ticket`; document.getElementById('tableBody').innerHTML = body; } function enableUpdateBtn(ticketKey) { const btn = document.getElementById(`update-${ticketKey}`); if (btn) btn.disabled = false; } function handleUpdateClick(ticketKey, goLiveColName) { const dueInput = document.getElementById(`due-${ticketKey}`); const dueDate = dueInput && dueInput.value ? dueInput.value : null; let goLiveDate = null; if (goLiveColName) { const goLiveInput = document.getElementById(`${goLiveColName.replace(/\s+/g, '-')}-${ticketKey}`); goLiveDate = goLiveInput && goLiveInput.value ? goLiveInput.value : null; } updateTicket(ticketKey, dueDate, goLiveDate); } function renderJSON() { document.getElementById('jsonViewer').innerHTML = syntaxHL(JSON.stringify(allTickets, null, 2)); } function syntaxHL(json) { json = json.replace(/&/g,'&').replace(//g,'>'); return json.replace(/("(\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, m => { let c = 'json-number'; if(/^"/.test(m)) c = /:$/.test(m)?'json-key':'json-string'; else if(/true|false/.test(m)) c='json-bool'; else if(/null/.test(m)) c='json-null'; return `${m}`; }); } function switchTab(id, btn) { document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active')); document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active')); btn.classList.add('active'); document.getElementById(`panel-${id}`).classList.add('active'); } function filterTable() { const q = document.getElementById('searchInput').value.toLowerCase(); filteredTickets = q ? allTickets.filter(t => Object.values(t).some(v => v && typeof v === 'string' && v.toLowerCase().includes(q))) : [...allTickets]; renderTable(); } function copyJSON() { navigator.clipboard.writeText(JSON.stringify(allTickets, null, 2)).then(()=>showToast('JSON copié.')).catch(()=>showToast('Erreur copie.','error')); } function downloadJSON() { const blob = new Blob([JSON.stringify(allTickets, null, 2)], {type:'application/json'}); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `jira-${document.getElementById('projectKey').value||'export'}-${Date.now()}.json`; a.click(); URL.revokeObjectURL(a.href); showToast('Fichier téléchargé.'); } function clearResults() { allTickets=[]; filteredTickets=[]; document.getElementById('results-section').classList.remove('visible'); document.getElementById('searchInput').value=''; } // Fonction d'arrondi par mois pour la roadmap function roundToMonth(date) { if (!date) return null; const d = new Date(date); // Arrondir au 1er jour du mois return new Date(d.getFullYear(), d.getMonth(), 1).toISOString().split('T')[0]; } function getStatusColor(status) { const s = (status || '').toLowerCase(); if (s.includes('done') || s.includes('ferm') || s.includes('rsolu')) return '#00e68a'; if (s.includes('progress') || s.includes('en cours') || s.includes('review')) return '#4dc8ff'; if (s.includes('to do') || s.includes('open') || s.includes('backlog')) return '#ffb84d'; if (s.includes('block')) return '#ff4d6a'; return '#7a9488'; } function renderRoadmap() { const preview = document.getElementById('roadmapPreview'); if (filteredTickets.length === 0) { preview.innerHTML = `

Aucun ticket a afficher

`; return; } // Trouver le champ Go Live Date const goLiveColName = filteredTickets.length > 0 ? Object.keys(filteredTickets[0]).find(k => k.toLowerCase().includes('go live')) : null; // Calculer les dates min et max avec arrondi par mois const allDates = filteredTickets.flatMap(t => [t.dueDate, goLiveColName ? t[goLiveColName] : null].filter(d => d)); if (allDates.length === 0) { preview.innerHTML = `

Aucune date disponible pour la roadmap

`; return; } const minDate = new Date(Math.min(...allDates.map(d => new Date(d)))); const maxDate = new Date(Math.max(...allDates.map(d => new Date(d)))); // Arrondir aux mois entiers minDate.setDate(1); maxDate.setMonth(maxDate.getMonth() + 1); maxDate.setDate(0); // Calculer le nombre de mois const totalMonths = (maxDate.getFullYear() - minDate.getFullYear()) * 12 + (maxDate.getMonth() - minDate.getMonth()); const monthWidth = 120; const rowHeight = 60; const headerHeight = 40; const leftWidth = 280; const canvasWidth = leftWidth + totalMonths * monthWidth + 50; // Grouper les tickets par mois de début et fin const monthlyTickets = {}; filteredTickets.forEach(ticket => { const due = ticket.dueDate ? new Date(ticket.dueDate) : null; const goLive = goLiveColName && ticket[goLiveColName] ? new Date(ticket[goLiveColName]) : null; if (!due && !goLive) return; const start = due ? due : goLive; const end = goLive ? goLive : due; const startMonth = `${start.getFullYear()}-${start.getMonth()}`; const endMonth = `${end.getFullYear()}-${end.getMonth()}`; if (!monthlyTickets[startMonth]) monthlyTickets[startMonth] = []; monthlyTickets[startMonth].push({ ticket, start, end }); }); let html = `
`; // En-tête avec noms de mois html += `
Mois
`; const months = []; const currentMonth = new Date(minDate); for (let m = 0; m <= totalMonths; m++) { const monthKey = `${currentMonth.getFullYear()}-${currentMonth.getMonth()}`; months.push(monthKey); const monthName = currentMonth.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }); html += `
${monthName}
`; currentMonth.setMonth(currentMonth.getMonth() + 1); } // Afficher les tickets par ligne (chaque ligne = un mois de début) Object.keys(monthlyTickets).forEach((monthKey, idx) => { const y = headerHeight + idx * rowHeight + 20; const [year, month] = monthKey.split('-').map(Number); const monthName = new Date(year, month, 1).toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }); // Ligne de séparation du mois html += `
${monthName}
`; // Tickets de ce mois const monthTickets = monthlyTickets[monthKey] || []; monthTickets.forEach(({ticket, start, end}) => { const startMonthIdx = months.indexOf(`${start.getFullYear()}-${start.getMonth()}`); const endMonthIdx = months.indexOf(`${end.getFullYear()}-${end.getMonth()}`); const barLeft = leftWidth + startMonthIdx * monthWidth + 10; const barWidth = (endMonthIdx - startMonthIdx + 1) * monthWidth - 20; const color = getStatusColor(ticket.status); html += `
${escHtml(ticket.key)}
${escHtml(ticket.summary.substring(0, 40))}
${escHtml(ticket.assignee || 'N/A')} - ${escHtml(ticket.status || 'N/A')}
`; }); }); html += `
`; preview.innerHTML = html; } function generateDrawioXML(tickets) { // Trouver le champ Go Live Date const goLiveColName = tickets.length > 0 ? Object.keys(tickets[0]).find(k => k.toLowerCase().includes('go live')) : null; // Calculer les dates min et max avec arrondi par mois const allDates = tickets.flatMap(t => [t.dueDate, goLiveColName ? t[goLiveColName] : null].filter(d => d)); if (allDates.length === 0) return null; const minDate = new Date(Math.min(...allDates.map(d => new Date(d)))); const maxDate = new Date(Math.max(...allDates.map(d => new Date(d)))); // Arrondir aux mois entiers minDate.setDate(1); maxDate.setMonth(maxDate.getMonth() + 1); maxDate.setDate(0); const totalMonths = (maxDate.getFullYear() - minDate.getFullYear()) * 12 + (maxDate.getMonth() - minDate.getMonth()); const monthWidth = 100; const rowHeight = 70; const headerHeight = 50; const leftWidth = 200; const canvasWidth = Math.max(827, leftWidth + totalMonths * monthWidth + 100); const canvasHeight = Math.max(1169, headerHeight + tickets.length * rowHeight + 50); let cellId = 2; let cells = []; // Header background cells.push({ id: 'header', value: 'Roadmap Jira (par mois)', style: 'rounded=0;whiteSpace=wrap;html=1;fillColor=#111a16;strokeColor=#1e3028;fontColor=#e8f0ec;fontSize=14;fontStyle=1', x: 0, y: 0, width: canvasWidth, height: headerHeight }); // En-têtes de mois const months = []; const currentMonth = new Date(minDate); for (let m = 0; m <= totalMonths; m++) { const monthName = currentMonth.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }); cells.push({ id: `month${cellId}`, value: monthName, style: 'rounded=0;whiteSpace=wrap;html=1;fillColor=#152019;strokeColor=#1e3028;fontColor=#7a9488;fontSize=10;align=center;', x: leftWidth + m * monthWidth, y: headerHeight + 10, width: monthWidth, height: 30 }); cellId++; currentMonth.setMonth(currentMonth.getMonth() + 1); } // Tickets tickets.forEach((ticket, idx) => { const y = headerHeight + idx * rowHeight + 50; const due = ticket.dueDate ? new Date(ticket.dueDate) : null; const goLive = goLiveColName && ticket[goLiveColName] ? new Date(ticket[goLiveColName]) : null; if (due || goLive) { const start = due ? due : goLive; const end = goLive ? goLive : due; const startMonthIdx = months.indexOf(`${start.getFullYear()}-${start.getMonth()}`); const endMonthIdx = months.indexOf(`${end.getFullYear()}-${end.getMonth()}`); const barWidth = Math.max(monthWidth, (endMonthIdx - startMonthIdx + 1) * monthWidth); const color = getStatusColor(ticket.status); const labelParts = [ `${ticket.key}: ${ticket.summary.substring(0, 40)}`, `${ticket.assignee || 'N/A'} - ${ticket.status || 'N/A'}` ]; const label = labelParts.join('\\n'); cells.push({ id: `ticket${cellId}`, value: label, style: `rounded=1;whiteSpace=wrap;html=1;fillColor=${color};strokeColor=#000000;strokeWidth=2;fontColor=#e8f0ec;fontSize=11;align=left;verticalAlign=top;spacingLeft=8;spacingTop=4;`, x: leftWidth + startMonthIdx * monthWidth + 10, y: y, width: barWidth - 20, height: rowHeight - 10 }); cellId++; } }); // Construire le XML let xml = ` `; // Background xml += ` `; // Ajouter toutes les cells cells.forEach(cell => { const safeValue = cell.value .replace(/&/g, '\x26amp;') .replace(//g, '\x26gt;') .replace(/"/g, '\x26quot;') .replace(/'/g, '\x26apos;'); xml += ` `; }); xml += ` `; return xml; } function downloadRoadmap() { if (allTickets.length === 0) { showToast('Aucun ticket a exporter.', 'error'); return; } const xml = generateDrawioXML(filteredTickets); if (!xml) { showToast('Impossible de generer la roadmap : aucune date disponible.', 'error'); return; } const blob = new Blob([xml], { type: 'text/xml;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `roadmap-${document.getElementById('projectKey').value || 'export'}.xml`; a.click(); URL.revokeObjectURL(a.href); showToast('Roadmap Draw.io telechargee avec succes !'); } document.addEventListener('keydown', e => { if((e.ctrlKey||e.metaKey)&&e.key==='Enter'){e.preventDefault();startExtraction()} });