536 lines
24 KiB
JavaScript
536 lines
24 KiB
JavaScript
|
|
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 = `<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:'_'}) });
|
|
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 = '<span class="spinner"></span> 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 = '<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;
|
|
}
|
|
|
|
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
|
|
})
|
|
});
|
|
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 = `
|
|
<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() {
|
|
// 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 = '<tr>' + cols.map(c=>`<th>${c}</th>`).join('') + '</tr>';
|
|
let body = '';
|
|
filteredTickets.forEach(t => {
|
|
// Due Date éditable
|
|
const dueDateValue = t.dueDate ? t.dueDate.substring(0, 10) : '';
|
|
const dueDateInput = `<input type="date" class="date-input" id="due-${t.key}" value="${dueDateValue}" onchange="enableUpdateBtn('${t.key}')">`;
|
|
|
|
body += `<tr>
|
|
<td><span class="ticket-key">${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) || '<span class="empty-cell">-</span>'}</td>
|
|
<td>${escHtml(t.priority)}</td>
|
|
<td>${escHtml(t.issueType)}</td>
|
|
<td>${dueDateInput}</td>
|
|
<td>${fmtDate(t.created)}</td>
|
|
<td>${fmtDate(t.resolutionDate)}</td>`;
|
|
|
|
if (goLiveColName) {
|
|
const goLiveDateValue = t[goLiveColName] ? t[goLiveColName].substring(0, 10) : '';
|
|
const goLiveDateInput = `<input type="date" class="date-input" id="${goLiveColName.replace(/\s+/g, '-')}-${t.key}" value="${goLiveDateValue}" onchange="enableUpdateBtn('${t.key}')">`;
|
|
body += `<td>${goLiveDateInput}</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>`;
|
|
});
|
|
if(!filteredTickets.length) body = `<tr><td colspan="${cols.length}" style="text-align:center;padding:40px;color:var(--fg-dim)">Aucun ticket</td></tr>`;
|
|
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,'<').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 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 = `<div style="text-align:center; padding:60px; color:var(--fg-dim);"><i class="fa-solid fa-timeline" style="font-size:48px; margin-bottom:16px; opacity:0.5;"></i><p>Aucun ticket a afficher</p></div>`;
|
|
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 = `<div style="text-align:center; padding:60px; color:var(--fg-dim);"><i class="fa-solid fa-calendar-xmark" style="font-size:48px; margin-bottom:16px; opacity:0.5;"></i><p>Aucune date disponible pour la roadmap</p></div>`;
|
|
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 = `<div style="position:relative; min-width:${canvasWidth}px; height:${headerHeight + Object.keys(monthlyTickets).length * rowHeight + 40}px;">`;
|
|
|
|
// En-tête avec noms de mois
|
|
html += `<div style="position:absolute; left:0; top:0; width:${leftWidth}px; height:${headerHeight}px; background:var(--bg-elevated); border:1px solid var(--border); border-right:2px solid var(--border); display:flex; align-items:center; justify-content:center; font-weight:700; color:var(--fg-muted);">Mois</div>`;
|
|
|
|
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 += `<div style="position:absolute; left:${leftWidth + m * monthWidth}px; top:0; width:${monthWidth}px; height:${headerHeight}px; border:1px solid rgba(122,148,136,0.3); display:flex; align-items:center; justify-content:center; font-size:11px; color:var(--fg-muted); font-weight:600;">${monthName}</div>`;
|
|
|
|
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 += `<div style="position:absolute; left:0; top:${y}px; width:${leftWidth}px; height:${rowHeight}px; background:var(--bg-elevated); border-bottom:1px solid var(--border); display:flex; align-items:center; padding:0 12px; font-size:12px; font-weight:700; color:var(--fg);">${monthName}</div>`;
|
|
|
|
// 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 += `<div style="position:absolute; left:${barLeft}px; top:${y + 8}px; width:${barWidth}px; height:${rowHeight - 16}px; background:${color}20; border-left:4px solid ${color}; border-radius:6px; padding:6px 10px; overflow:hidden; cursor:pointer;" title="${escHtml(ticket.key)} - ${escHtml(ticket.summary)}">
|
|
<div style="font-weight:700; font-size:11px; color:var(--fg); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${escHtml(ticket.key)}</div>
|
|
<div style="font-size:10px; color:var(--fg); margin-top:2px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${escHtml(ticket.summary.substring(0, 40))}</div>
|
|
<div style="font-size:9px; color:var(--fg-muted); margin-top:4px;">${escHtml(ticket.assignee || 'N/A')} - ${escHtml(ticket.status || 'N/A')}</div>
|
|
</div>`;
|
|
});
|
|
});
|
|
|
|
html += `</div>`;
|
|
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 = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<mxfile host="app.diagrams.net" modified="${new Date().toISOString()}" agent="Jira Roadmap Generator" version="22.0.0" type="device">
|
|
<diagram id="roadmap" name="Roadmap">
|
|
<mxGraphModel dx="0" dy="0" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="${canvasWidth}" pageHeight="${canvasHeight}" math="0" shadow="0">
|
|
<root>
|
|
<mxCell id="0" />
|
|
<mxCell id="1" parent="0" />`;
|
|
|
|
// Background
|
|
xml += `
|
|
<mxCell id="bg" value="" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#0a0f0d;strokeColor=#000000;" vertex="1" parent="1">
|
|
<mxGeometry x="0" y="0" width="${canvasWidth}" height="${canvasHeight}" as="geometry" />
|
|
</mxCell>`;
|
|
|
|
// Ajouter toutes les cells
|
|
cells.forEach(cell => {
|
|
const safeValue = cell.value
|
|
.replace(/&/g, '\x26amp;')
|
|
.replace(/</g, '\x26lt;')
|
|
.replace(/>/g, '\x26gt;')
|
|
.replace(/"/g, '\x26quot;')
|
|
.replace(/'/g, '\x26apos;');
|
|
xml += `
|
|
<mxCell id="${cell.id}" value="${safeValue}" style="${cell.style}" vertex="1" parent="1">
|
|
<mxGeometry x="${cell.x}" y="${cell.y}" width="${cell.width}" height="${cell.height}" as="geometry" />
|
|
</mxCell>`;
|
|
});
|
|
|
|
xml += `
|
|
</root>
|
|
</mxGraphModel>
|
|
</diagram>
|
|
</mxfile>`;
|
|
|
|
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()} });
|