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 += `