Merge pull request 'gant reviewed, added links, css improvements' (#2) from gautier into main

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-05-11 09:47:27 +00:00
3 changed files with 1267 additions and 452 deletions
+49 -20
View File
@@ -20,10 +20,10 @@
<h1>Jira Ticket Extractor</h1>
<p>Extraction via API locale — Proxy transparent</p>
</div>
<div class="badge-api"><i class="fa-solid fa-server" style="margin-right:6px;"></i>API : localhost:3000</div>
<div class="badge-api"><i class="fa-solid fa-server"></i>API : localhost:3000</div>
</header>
<section class="card">
<section class="card-form">
<div class="card-title"><i class="fa-solid fa-filter"></i> Paramètres d'extraction</div>
<div class="alert alert-info">
<i class="fa-solid fa-circle-info"></i>
@@ -41,7 +41,7 @@
</div>
<div class="form-group">
<label for="projectKey">Clé du projet</label>
<input type="text" id="projectKey" placeholder="PROJ" style="text-transform:uppercase;" oninput="autoJql()">
<input type="text" id="projectKey" class="uppercase-input" placeholder="PROJ" oninput="autoJql()">
</div>
<div class="form-group">
<label for="customFields">Champs personnalisés (optionnel)</label>
@@ -53,7 +53,7 @@
<textarea id="jqlQuery" placeholder='project = "PROJ" AND status != Deleted ORDER BY created DESC' rows="2"></textarea>
</div>
</div>
<div class="btn-group" style="margin-top: 24px;">
<div class="btn-group mt-24">
<button class="btn btn-primary" id="extractBtn" onclick="startExtraction()">
<i class="fa-solid fa-download"></i> Lancer l'extraction
</button>
@@ -65,36 +65,65 @@
<div id="results-section">
<div class="stats-row" id="statsRow"></div>
<div class="card" style="padding: 16px 20px;">
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px;">
<div class="card card-actions">
<div class="actions-wrapper">
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('table', this)"><i class="fa-solid fa-table" style="margin-right:6px;"></i>Tableau</button>
<button class="tab-btn" onclick="switchTab('roadmap', this)"><i class="fa-solid fa-timeline" style="margin-right:6px;"></i>Roadmap</button>
<button class="tab-btn" onclick="switchTab('json', this)"><i class="fa-solid fa-code" style="margin-right:6px;"></i>JSON Brut</button>
<button class="tab-btn active" onclick="switchTab('table', this)"><i class="fa-solid fa-table"></i>Tableau</button>
<button class="tab-btn" onclick="switchTab('roadmap', this)"><i class="fa-solid fa-timeline"></i>Roadmap</button>
<button class="tab-btn" onclick="switchTab('json', this)"><i class="fa-solid fa-code"></i>JSON Brut</button>
</div>
<div class="btn-group" style="margin-top:0;">
<div class="btn-group no-margin">
<button class="btn btn-secondary" onclick="copyJSON()"><i class="fa-solid fa-copy"></i> Copier</button>
<button class="btn btn-primary" onclick="downloadJSON()"><i class="fa-solid fa-file-arrow-down"></i> Telecharger</button>
<button class="btn btn-primary" onclick="downloadRoadmap()" style="background:linear-gradient(135deg, #4dc8ff, #0d8bbf);"><i class="fa-solid fa-diagram-project"></i> Roadmap Draw.io</button>
<button class="btn btn-drawio" onclick="downloadRoadmap()"><i class="fa-solid fa-diagram-project"></i> Roadmap Draw.io</button>
<button class="btn btn-danger" onclick="clearResults()"><i class="fa-solid fa-trash"></i> Effacer</button>
</div>
</div>
</div>
<div class="search-bar"><i class="fa-solid fa-magnifying-glass"></i><input type="text" id="searchInput" placeholder="Rechercher..." oninput="filterTable()"></div>
<div class="tab-panel active" id="panel-table"><div class="table-wrap"><table><thead id="tableHead"></thead><tbody id="tableBody"></tbody></table></div></div>
<div class="search-bar">
<i class="fa-solid fa-magnifying-glass"></i>
<input type="text" id="searchInput" placeholder="Rechercher..." oninput="filterTable()">
</div>
<div class="tab-panel active" id="panel-table">
<div class="table-wrap">
<table>
<thead id="tableHead"></thead>
<tbody id="tableBody"></tbody>
</table>
</div>
</div>
<div class="tab-panel" id="panel-roadmap">
<div class="card" style="margin-bottom:16px;">
<div style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
<span><i class="fa-solid fa-circle-info" style="color:var(--info); margin-right:8px;"></i>Affichage par mois pour plus de lisibilité</span>
<button class="btn btn-primary" onclick="downloadRoadmap()" style="padding:8px 16px; font-size:12px;">
<i class="fa-solid fa-download" style="margin-right:6px;"></i>Télécharger le .drawio
<div class="card mb-16 p-small">
<div class="flex-center-between">
<div class="card mb-16 p-small">
<div class="flex-center-between">
<div class="view-controls">
<span class="mr-8"><i class="fa-solid fa-magnifying-glass-plus"></i> Vue :</span>
<div class="btn-group no-margin">
<button class="btn btn-secondary btn-sm" onclick="setGanttView('month')">Mois</button>
<button class="btn btn-secondary btn-sm" onclick="setGanttView('quarter')">Trimestres</button>
<button class="btn btn-secondary btn-sm" onclick="setGanttView('year')">Années</button>
</div>
</div>
</div>
</div>
<button class="btn btn-primary btn-sm" onclick="downloadRoadmap()">
<i class="fa-solid fa-download"></i>Télécharger le .drawio
</button>
</div>
</div>
<div class="card" style="padding:20px;">
<div id="roadmapPreview" style="overflow-x:auto; overflow-y:auto; max-height:600px; background:var(--bg-input); border-radius:var(--radius); padding:20px; min-height:300px;"></div>
<div class="card p-20">
<div id="roadmapPreview"></div>
</div>
</div>
<div class="tab-panel" id="panel-json"><div class="json-viewer" id="jsonViewer"></div></div>
</div>
</div>
+961 -87
View File
File diff suppressed because it is too large Load Diff
+245 -333
View File
@@ -1,11 +1,14 @@
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');
@@ -28,11 +31,10 @@ async function pingApi() {
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');
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" dans le dossier du serveur.', 'error');
showToast('API inaccessible. Lancez "npm start".', 'error');
} finally { btn.innerHTML = orig; btn.disabled = false; }
}
@@ -44,9 +46,9 @@ async function startExtraction() {
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; }
if (!user || !token || !project) { showToast('Champs requis manquants.', 'error'); return; }
btn.innerHTML = '<span class="spinner"></span> Extraction via API...'; btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Extraction...'; btn.disabled = true;
try {
const resp = await fetch(`${API_BASE}/api/extract`, {
@@ -55,19 +57,16 @@ async function startExtraction() {
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}`);
}
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 avec succès.`, 'success');
renderResults();
showToast(`${allTickets.length} tickets extraits.`, 'success');
} catch (err) {
showToast(err.message, 'error');
} finally {
@@ -86,6 +85,34 @@ function parseCustomFields(customFields) {
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');
@@ -98,27 +125,18 @@ async function updateTicket(ticketKey, dueDate, goLiveDate) {
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;
}
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
})
body: JSON.stringify({ username: lastUsername, apiToken: lastApiToken, ticketKey, updates })
});
const data = await resp.json();
if (!resp.ok) {
const data = await resp.json();
throw new Error(data.error || `Erreur HTTP ${resp.status}`);
}
@@ -129,8 +147,8 @@ async function updateTicket(ticketKey, dueDate, goLiveDate) {
}
renderTable();
showToast(`Ticket ${ticketKey} mis à jour avec succès !`, 'success');
renderRoadmap();
showToast(`Ticket ${ticketKey} mis à jour !`, 'success');
} catch (err) {
showToast(err.message, 'error');
} finally {
@@ -139,9 +157,27 @@ async function updateTicket(ticketKey, dueDate, goLiveDate) {
}
}
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();
renderStats(); renderTable(); renderJSON(); renderRoadmap();
}
function renderStats() {
@@ -161,42 +197,57 @@ function fmtDate(d) { if(!d) return '<span class="empty-cell">-</span>'; try { r
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;
}
});
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)
);
}
const cols = ['Ticket','Summary','Statut','Assigné','Priorité','Type','Due Date','Créé le','Résolu le','Actions'];
// Mapping pour faire le lien entre le texte de l'en-tête et la proprié 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.splice(cols.indexOf('Résolu le'), 0, goLiveColName, 'Actions');
cols.push(goLiveColName);
colMapping[goLiveColName] = goLiveColName; // Ajout dynamique du champ personnalisé
}
document.getElementById('tableHead').innerHTML = '<tr>' + cols.map(c=>`<th>${c}</th>`).join('') + '</tr>';
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 => {
// 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><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) || '<span class="empty-cell">-</span>'}</td>
<td>${escHtml(t.assignee) || '-'}</td>
<td>${escHtml(t.priority)}</td>
<td>${escHtml(t.issueType)}</td>
<td>${dueDateInput}</td>
<td>${fmtDate(t.created)}</td>
<td>${fmtDate(t.resolutionDate)}</td>`;
<td><input type="date" class="date-input" id="due-${t.key}" value="${dueDateValue}" onchange="enableUpdateBtn('${t.key}')"></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>`;
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>
@@ -205,74 +256,7 @@ function renderTable() {
</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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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];
document.getElementById('tableBody').innerHTML = body || '<tr><td colspan="10">Aucun résultat</td></tr>';
}
function getStatusColor(status) {
@@ -284,252 +268,180 @@ function getStatusColor(status) {
return '#7a9488';
}
// --- ROADMAP (GANTT) ---
function setGanttView(view) {
currentGanttView = view;
renderRoadmap();
}
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>`;
if (!filteredTickets || filteredTickets.length === 0) {
preview.innerHTML = `<div style="text-align:center; padding:60px; color:var(--fg-dim);">Aucun ticket à afficher.</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;
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));
});
// 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>`;
preview.innerHTML = `<div style="padding:40px; text-align:center;">Aucune date disponible pour générer la roadmap.</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
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);
// 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);
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 });
}
// 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' });
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);
};
// 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>`;
const grouped = filteredTickets.reduce((acc, t) => {
const name = t.assignee || "Non assigné";
if (!acc[name]) acc[name] = [];
acc[name].push(t);
return acc;
}, {});
// 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;
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>`;
}
const color = getStatusColor(ticket.status);
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>`;
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>
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>`;
html += `</div></div>`;
preview.innerHTML = html;
setTimeout(() => {
const m = document.getElementById('todayMarker');
if (m) m.scrollIntoView({ behavior: 'smooth', inline: 'center' });
}, 150);
}
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;
// --- 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 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 !');
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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()} });