447 lines
20 KiB
JavaScript
447 lines
20 KiB
JavaScript
const API_BASE = '';
|
|
let allTickets = [];
|
|
let filteredTickets = [];
|
|
let customFieldsMap = [];
|
|
let lastUsername = '';
|
|
let lastApiToken = '';
|
|
let currentGanttView = 'month';
|
|
let currentSortCol = null;
|
|
let isAsc = true;
|
|
|
|
// --- UTILITAIRES ---
|
|
function showToast(msg, type='success') {
|
|
const c = document.getElementById('toastContainer');
|
|
const t = document.createElement('div');
|
|
t.className = `toast toast-${type}`;
|
|
t.innerHTML = `<i class="fa-solid fa-${type==='success'?'check-circle':'xmark-circle'}"></i><span>${msg}</span>`;
|
|
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 = '<span class="spinner"></span> 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:'_'}) });
|
|
if (r.status === 400) showToast('API locale connectée !', 'success');
|
|
else showToast('API répond : ' + r.status, 'success');
|
|
} catch(e) {
|
|
showToast('API inaccessible. Lancez "npm start".', '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('Champs requis manquants.', 'error'); return; }
|
|
|
|
btn.innerHTML = '<span class="spinner"></span> Extraction...'; 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 ${resp.status}`);
|
|
|
|
allTickets = data.tickets;
|
|
filteredTickets = [...allTickets];
|
|
customFieldsMap = parseCustomFields(custom);
|
|
lastUsername = user;
|
|
lastApiToken = token;
|
|
|
|
renderResults();
|
|
showToast(`${allTickets.length} tickets extraits.`, 'success');
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
} finally {
|
|
btn.innerHTML = '<i class="fa-solid fa-download"></i> 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;
|
|
}
|
|
|
|
function sortTable(colKey) {
|
|
// Si on clique sur la même colonne, on inverse l'ordre, sinon on passe en croissant
|
|
if (currentSortCol === colKey) {
|
|
isAsc = !isAsc;
|
|
} else {
|
|
currentSortCol = colKey;
|
|
isAsc = true;
|
|
}
|
|
|
|
filteredTickets.sort((a, b) => {
|
|
let valA = a[colKey] || '';
|
|
let valB = b[colKey] || '';
|
|
|
|
// Gestion spécifique pour les dates pour un tri chronologique correct
|
|
if (colKey.toLowerCase().includes('date') || colKey === 'created' || colKey === 'dueDate') {
|
|
valA = new Date(valA);
|
|
valB = new Date(valB);
|
|
}
|
|
|
|
if (valA < valB) return isAsc ? -1 : 1;
|
|
if (valA > valB) return isAsc ? 1 : -1;
|
|
return 0;
|
|
});
|
|
|
|
renderTable();
|
|
}
|
|
|
|
// --- MISE À JOUR TICKET ---
|
|
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 = '<span class="spinner"></span>';
|
|
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 })
|
|
});
|
|
if (!resp.ok) {
|
|
const data = await resp.json();
|
|
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();
|
|
renderRoadmap();
|
|
showToast(`Ticket ${ticketKey} mis à jour !`, 'success');
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
} finally {
|
|
btn.innerHTML = orig;
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
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(`gl-${ticketKey}`);
|
|
goLiveDate = goLiveInput && goLiveInput.value ? goLiveInput.value : null;
|
|
}
|
|
updateTicket(ticketKey, dueDate, goLiveDate);
|
|
}
|
|
|
|
// --- RENDU ---
|
|
function renderResults() {
|
|
document.getElementById('results-section').classList.add('visible');
|
|
renderStats(); renderTable(); renderJSON(); renderRoadmap();
|
|
}
|
|
|
|
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 = `
|
|
<div class="stat-card"><div class="stat-value">${allTickets.length}</div><div class="stat-label">Tickets</div></div>
|
|
<div class="stat-card"><div class="stat-value">${new Set(allTickets.map(t=>t.status)).size}</div><div class="stat-label">Statuts</div></div>
|
|
<div class="stat-card"><div class="stat-value">${unassigned}</div><div class="stat-label">Non assignés</div></div>
|
|
<div class="stat-card"><div class="stat-value" style="color:${overdue>0?'var(--danger)':'var(--accent)'}">${overdue}</div><div class="stat-label">En retard</div></div>
|
|
`;
|
|
}
|
|
|
|
function escHtml(s) { if(!s) return ''; const d=document.createElement('div'); d.textContent=String(s); return d.innerHTML; }
|
|
function fmtDate(d) { if(!d) return '<span class="empty-cell">-</span>'; 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() {
|
|
let goLiveColName = null;
|
|
if (allTickets.length > 0) {
|
|
goLiveColName = Object.keys(allTickets[0]).find(key =>
|
|
!['key','url','summary','description','status','statusCategory','assignee','reporter','priority','issueType','created','updated','dueDate','resolution','resolutionDate','labels','components','fixVersions','projectName'].includes(key)
|
|
);
|
|
}
|
|
|
|
// Mapping pour faire le lien entre le texte de l'en-tête et la propriété de l'objet ticket
|
|
const colMapping = {
|
|
'Ticket': 'key',
|
|
'Summary': 'summary',
|
|
'Statut': 'status',
|
|
'Assigné': 'assignee',
|
|
'Priorité': 'priority',
|
|
'Type': 'issueType',
|
|
'Due Date': 'dueDate'
|
|
};
|
|
|
|
const cols = ['Ticket','Summary','Statut','Assigné','Priorité','Type','Due Date'];
|
|
if (goLiveColName) {
|
|
cols.push(goLiveColName);
|
|
colMapping[goLiveColName] = goLiveColName; // Ajout dynamique du champ personnalisé
|
|
}
|
|
cols.push('Actions');
|
|
|
|
// Génération des en-têtes avec gestion du clic pour le tri
|
|
document.getElementById('tableHead').innerHTML = '<tr>' + cols.map(c => {
|
|
const key = colMapping[c];
|
|
if (!key) return `<th>${c}</th>`; // Pour la colonne 'Actions'
|
|
|
|
const icon = currentSortCol === key ? (isAsc ? ' ▴' : ' ▾') : '';
|
|
return `<th onclick="sortTable('${key}')" style="cursor:pointer" class="sortable-th">
|
|
${c}${icon}
|
|
</th>`;
|
|
}).join('') + '</tr>';
|
|
|
|
let body = '';
|
|
filteredTickets.forEach(t => {
|
|
const dueDateValue = t.dueDate ? t.dueDate.substring(0, 10) : '';
|
|
body += `<tr>
|
|
<td><span class="ticket-key" onclick="window.open('${t.url}', '_blank')">${escHtml(t.key)}</span></td>
|
|
<td title="${escHtml(t.summary)}">${escHtml(t.summary)}</td>
|
|
<td><span class="status-badge ${getStatusClass(t.status)}">${escHtml(t.status)}</span></td>
|
|
<td>${escHtml(t.assignee) || '-'}</td>
|
|
<td>${escHtml(t.priority)}</td>
|
|
<td>${escHtml(t.issueType)}</td>
|
|
<td><input type="date" class="date-input" id="due-${t.key}" value="${dueDateValue}" onchange="enableUpdateBtn('${t.key}')"></td>`;
|
|
|
|
if (goLiveColName) {
|
|
const glVal = t[goLiveColName] ? t[goLiveColName].substring(0, 10) : '';
|
|
body += `<td><input type="date" class="date-input" id="gl-${t.key}" value="${glVal}" onchange="enableUpdateBtn('${t.key}')"></td>`;
|
|
}
|
|
|
|
body += `<td>
|
|
<button class="btn-update" id="update-${t.key}" data-ticket="${t.key}" onclick="handleUpdateClick('${t.key}', '${goLiveColName || ''}')" disabled>
|
|
<i class="fa-solid fa-rotate"></i> Update
|
|
</button>
|
|
</td></tr>`;
|
|
});
|
|
document.getElementById('tableBody').innerHTML = body || '<tr><td colspan="10">Aucun résultat</td></tr>';
|
|
}
|
|
|
|
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';
|
|
}
|
|
|
|
// --- ROADMAP (GANTT) ---
|
|
function setGanttView(view) {
|
|
currentGanttView = view;
|
|
renderRoadmap();
|
|
}
|
|
|
|
function renderRoadmap() {
|
|
const preview = document.getElementById('roadmapPreview');
|
|
if (!filteredTickets || filteredTickets.length === 0) {
|
|
preview.innerHTML = `<div style="text-align:center; padding:60px; color:var(--fg-dim);">Aucun ticket à afficher.</div>`;
|
|
return;
|
|
}
|
|
|
|
const goLiveColName = Object.keys(filteredTickets[0]).find(k => k.toLowerCase().includes('go live')) || "Go Live Date";
|
|
const leftWidth = 250;
|
|
let colWidth = 140;
|
|
if (currentGanttView === 'quarter') colWidth = 200;
|
|
if (currentGanttView === 'year') colWidth = 250;
|
|
|
|
const allDates = filteredTickets.flatMap(t => {
|
|
const d1 = t.dueDate ? new Date(t.dueDate) : null;
|
|
const d2 = t[goLiveColName] ? new Date(t[goLiveColName]) : null;
|
|
return [d1, d2].filter(d => d && !isNaN(d));
|
|
});
|
|
|
|
if (allDates.length === 0) {
|
|
preview.innerHTML = `<div style="padding:40px; text-align:center;">Aucune date disponible pour générer la roadmap.</div>`;
|
|
return;
|
|
}
|
|
|
|
let minDate = new Date(Math.min(...allDates));
|
|
let maxDate = new Date(Math.max(...allDates));
|
|
minDate.setDate(1);
|
|
if (currentGanttView === 'quarter') minDate.setMonth(Math.floor(minDate.getMonth() / 3) * 3);
|
|
if (currentGanttView === 'year') minDate.setMonth(0);
|
|
maxDate.setMonth(maxDate.getMonth() + 1);
|
|
maxDate.setDate(0);
|
|
|
|
const timeScale = [];
|
|
let tempDate = new Date(minDate);
|
|
while (tempDate <= maxDate) {
|
|
let label = "", key = "";
|
|
if (currentGanttView === 'month') {
|
|
label = tempDate.toLocaleDateString('fr-FR', { month: 'short', year: 'numeric' });
|
|
key = `${tempDate.getFullYear()}-${tempDate.getMonth()}`;
|
|
tempDate.setMonth(tempDate.getMonth() + 1);
|
|
} else if (currentGanttView === 'quarter') {
|
|
const q = Math.floor(tempDate.getMonth() / 3) + 1;
|
|
label = `Q${q} ${tempDate.getFullYear()}`;
|
|
key = `${tempDate.getFullYear()}-Q${q}`;
|
|
tempDate.setMonth(tempDate.getMonth() + 3);
|
|
} else if (currentGanttView === 'year') {
|
|
label = `${tempDate.getFullYear()}`;
|
|
key = `${tempDate.getFullYear()}`;
|
|
tempDate.setFullYear(tempDate.getFullYear() + 1);
|
|
}
|
|
timeScale.push({ key, label });
|
|
}
|
|
|
|
const getXPos = (date) => {
|
|
if (!date) return 0;
|
|
const d = new Date(date);
|
|
let index = -1;
|
|
if (currentGanttView === 'month') index = timeScale.findIndex(m => m.key === `${d.getFullYear()}-${d.getMonth()}`);
|
|
else if (currentGanttView === 'quarter') index = timeScale.findIndex(m => m.key === `${d.getFullYear()}-Q${Math.floor(d.getMonth() / 3) + 1}`);
|
|
else if (currentGanttView === 'year') index = timeScale.findIndex(m => m.key === `${d.getFullYear()}`);
|
|
if (index === -1) return 0;
|
|
let ratio = 0;
|
|
if (currentGanttView === 'month') ratio = (d.getDate() - 1) / 30;
|
|
else if (currentGanttView === 'quarter') ratio = ((d.getMonth() % 3) * 30 + d.getDate()) / 90;
|
|
else if (currentGanttView === 'year') ratio = (d.getMonth() * 30 + d.getDate()) / 365;
|
|
return leftWidth + (index * colWidth) + (ratio * colWidth);
|
|
};
|
|
|
|
const grouped = filteredTickets.reduce((acc, t) => {
|
|
const name = t.assignee || "Non assigné";
|
|
if (!acc[name]) acc[name] = [];
|
|
acc[name].push(t);
|
|
return acc;
|
|
}, {});
|
|
|
|
let html = `<div class="gantt-container"><div class="gantt-canvas" style="width: ${leftWidth + (timeScale.length * colWidth)}px;">`;
|
|
const todayX = getXPos(new Date());
|
|
if (todayX > leftWidth) {
|
|
html += `<div class="gantt-today-line" style="left: ${todayX}px;"></div>`;
|
|
html += `<div class="gantt-today-label" id="todayMarker" style="left: ${todayX}px;">Auj.</div>`;
|
|
}
|
|
|
|
html += `<div class="gantt-header"><div class="gantt-sticky-col gantt-group-name">Assigné / Ticket</div>`;
|
|
timeScale.forEach(ts => { html += `<div class="gantt-month-header" style="width:${colWidth}px;">${ts.label}</div>`; });
|
|
html += `</div>`;
|
|
|
|
Object.keys(grouped).sort().forEach(assignee => {
|
|
html += `<div class="gantt-group-header"><div class="gantt-sticky-col gantt-group-name"><i class="fa-solid fa-user-circle mr-8"></i>${assignee}</div></div>`;
|
|
|
|
// --- TRI CHRONOLOGIQUE PAR DUE DATE ---
|
|
const sortedTickets = grouped[assignee].sort((a, b) => {
|
|
const dateA = a.dueDate ? new Date(a.dueDate) : new Date(0);
|
|
const dateB = b.dueDate ? new Date(b.dueDate) : new Date(0);
|
|
return dateA - dateB;
|
|
});
|
|
|
|
sortedTickets.forEach(t => {
|
|
const dDue = t.dueDate ? new Date(t.dueDate) : null;
|
|
const dLive = t[goLiveColName] ? new Date(t[goLiveColName]) : null;
|
|
if (!dDue && !dLive) return;
|
|
|
|
const start = dDue || dLive;
|
|
const end = dLive || dDue;
|
|
|
|
const x1 = getXPos(start);
|
|
const x2 = getXPos(end);
|
|
const barWidth = Math.max(35, (x2 - x1) + 10);
|
|
const color = getStatusColor(t.status);
|
|
|
|
// Préparation des infos pour le tooltip
|
|
const fmtDue = dDue ? dDue.toLocaleDateString('fr-FR') : 'Non définie';
|
|
const fmtLive = dLive ? dLive.toLocaleDateString('fr-FR') : 'Non définie';
|
|
const description = t.description ? `\n\nDescription: ${t.description.substring(0, 150)}${t.description.length > 150 ? '...' : ''}` : '';
|
|
|
|
const tooltipText = `Ticket: ${t.key}\nRésumé: ${t.summary}${description}\n\n📅 Due Date: ${fmtDue}\n🚀 Go Live: ${fmtLive}\n\n(Cliquez pour ouvrir dans Jira)`;
|
|
|
|
html += `<div class="gantt-row">
|
|
<div class="gantt-sticky-col gantt-ticket-id">${t.key}</div>
|
|
<div class="gantt-bar"
|
|
title="${escHtml(tooltipText)}"
|
|
onclick="window.open('${t.url}', '_blank')"
|
|
style="left:${x1}px; width:${barWidth}px; background:${color}33; border-left:4px solid ${color}; cursor:pointer;">
|
|
${escHtml(t.summary)}
|
|
</div>
|
|
${timeScale.map(() => `<div class="gantt-grid-col" style="width:${colWidth}px;"></div>`).join('')}
|
|
</div>`;
|
|
});
|
|
});
|
|
html += `</div></div>`;
|
|
preview.innerHTML = html;
|
|
setTimeout(() => {
|
|
const m = document.getElementById('todayMarker');
|
|
if (m) m.scrollIntoView({ behavior: 'smooth', inline: 'center' });
|
|
}, 150);
|
|
}
|
|
|
|
// --- TAB & FILTER ---
|
|
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');
|
|
if(id === 'roadmap') renderRoadmap();
|
|
}
|
|
|
|
function filterTable() {
|
|
const q = document.getElementById('searchInput').value.toLowerCase();
|
|
filteredTickets = q ? allTickets.filter(t => Object.values(t).some(v => v && String(v).toLowerCase().includes(q))) : [...allTickets];
|
|
renderTable();
|
|
if(document.getElementById('panel-roadmap').classList.contains('active')) renderRoadmap();
|
|
}
|
|
|
|
function renderJSON() { document.getElementById('jsonViewer').innerHTML = syntaxHL(JSON.stringify(allTickets, null, 2)); }
|
|
function syntaxHL(json) {
|
|
json = json.replace(/&/g,'&').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 `<span class="${c}">${m}</span>`;
|
|
});
|
|
}
|
|
|
|
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 = `export-${Date.now()}.json`; a.click();
|
|
}
|
|
|
|
function copyJSON() { navigator.clipboard.writeText(JSON.stringify(allTickets, null, 2)).then(() => showToast('Copié !')); }
|
|
|
|
function clearResults() { allTickets=[]; filteredTickets=[]; document.getElementById('results-section').classList.remove('visible'); }
|
|
|
|
document.addEventListener('keydown', e => { if((e.ctrlKey||e.metaKey)&&e.key==='Enter'){e.preventDefault();startExtraction()} }); |