Upload files to "/"

This commit is contained in:
2026-05-08 09:54:03 +00:00
parent 821eaf4fa3
commit a85ab19d3b
+147 -178
View File
@@ -70,7 +70,7 @@
.tab-btn.active { background: var(--accent); color: var(--bg); box-shadow: 0 2px 12px var(--accent-glow-strong); } .tab-btn.active { background: var(--accent); color: var(--bg); box-shadow: 0 2px 12px var(--accent-glow-strong); }
.tab-panel { display: none; } .tab-panel.active { display: block; animation: fadeIn 0.3s ease; } .tab-panel { display: none; } .tab-panel.active { display: block; animation: fadeIn 0.3s ease; }
.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius); width: 100%; } .table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius); }
table { width: 100%; border-collapse: collapse; font-size: 13px; } table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead { background: var(--bg-elevated); position: sticky; top: 0; z-index: 2; } thead { background: var(--bg-elevated); position: sticky; top: 0; z-index: 2; }
th { padding: 12px 14px; text-align: left; font-weight: 700; font-size: 11px; text-transform: uppercase; letter-spacing: 0.8px; color: var(--fg-muted); border-bottom: 2px solid var(--border); white-space: nowrap; } th { padding: 12px 14px; text-align: left; font-weight: 700; font-size: 11px; text-transform: uppercase; letter-spacing: 0.8px; color: var(--fg-muted); border-bottom: 2px solid var(--border); white-space: nowrap; }
@@ -83,7 +83,7 @@
.empty-cell { color: var(--fg-dim); font-style: italic; } .empty-cell { color: var(--fg-dim); font-style: italic; }
/* Date input styling */ /* Date input styling */
.date-input { background: var(--bg-input); border: 1px solid var(--border); border-radius: 6px; padding: 4px 8px; color: var(--fg); font-family: 'DM Sans', sans-serif; font-size: 12px; outline: none; transition: border-color 0.25s; width: 120px; } .date-input { background: var(--bg-input); border: 1px solid var(--border); border-radius: 6px; padding: 4px 8px; color: var(--fg); font-family: 'DM Sans', sans-serif; font-size: 12px; outline: none; transition: border-color 0.25s, box-shadow 0.25s; width: 100px; }
.date-input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-glow); } .date-input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-glow); }
.date-input::-webkit-calendar-picker-indicator { filter: invert(1); opacity: 0.6; cursor: pointer; } .date-input::-webkit-calendar-picker-indicator { filter: invert(1); opacity: 0.6; cursor: pointer; }
.date-input::-webkit-calendar-picker-indicator:hover { opacity: 1; } .date-input::-webkit-calendar-picker-indicator:hover { opacity: 1; }
@@ -91,7 +91,7 @@
/* Update button styling */ /* Update button styling */
.btn-update { display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: 6px; border: none; font-family: 'DM Sans', sans-serif; font-size: 11px; font-weight: 600; cursor: pointer; transition: all 0.25s; background: linear-gradient(135deg, var(--accent), var(--accent-dim)); color: var(--bg); } .btn-update { display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: 6px; border: none; font-family: 'DM Sans', sans-serif; font-size: 11px; font-weight: 600; cursor: pointer; transition: all 0.25s; background: linear-gradient(135deg, var(--accent), var(--accent-dim)); color: var(--bg); }
.btn-update:hover { transform: translateY(-1px); box-shadow: 0 2px 8px var(--accent-glow-strong); } .btn-update:hover { transform: translateY(-1px); box-shadow: 0 2px 8px var(--accent-glow-strong); }
.btn-update:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } .btn-update:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; }
.btn-update .spinner { width: 12px; height: 12px; border-width: 2px; } .btn-update .spinner { width: 12px; height: 12px; border-width: 2px; }
.btn-update i { font-size: 10px; } .btn-update i { font-size: 10px; }
@@ -152,8 +152,8 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="customFields">Champs personnalisés (optionnel)</label> <label for="customFields">Champs personnalisés (optionnel)</label>
<input type="text" id="customFields" placeholder="customfield_10001:Go Live Date"> <input type="text" id="customFields" placeholder="customfield_10001:Production Date">
<span class="hint">Format : customfield_ID:NomAffiché, séparés par virgules. Exemple: customfield_10001:Go Live Date</span> <span class="hint">Format : customfield_ID:NomAffiché, séparés par virgules</span>
</div> </div>
<div class="form-group full"> <div class="form-group full">
<label for="jqlQuery">Requête JQL (optionnel)</label> <label for="jqlQuery">Requête JQL (optionnel)</label>
@@ -192,12 +192,7 @@
<div class="tab-panel" id="panel-roadmap"> <div class="tab-panel" id="panel-roadmap">
<div class="card" style="margin-bottom:16px;"> <div class="card" style="margin-bottom:16px;">
<div style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;"> <div style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
<span><i class="fa-solid fa-info-circle" style="color:var(--info); margin-right:8px;"></i>Filtrer la roadmap :</span> <span><i class="fa-solid fa-circle-info" style="color:var(--info); margin-right:8px;"></i>Affichage par mois pour plus de lisibilité</span>
<select id="roadmapFilter" onchange="renderRoadmap()" style="background:var(--bg-input); border:1px solid var(--border); border-radius:var(--radius); padding:8px 12px; color:var(--fg); font-family:'DM Sans',sans-serif; font-size:13px; outline:none;">
<option value="all">Tous les tickets</option>
<option value="inprogress">En cours uniquement</option>
<option value="hasDates">Avec dates uniquement</option>
</select>
<button class="btn btn-primary" onclick="downloadRoadmap()" style="padding:8px 16px; font-size:12px;"> <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 <i class="fa-solid fa-download" style="margin-right:6px;"></i>Télécharger le .drawio
</button> </button>
@@ -212,7 +207,7 @@
</div> </div>
<script> <script>
const API_URL = 'http://localhost:3000'; const API_BASE = '';
let allTickets = []; let allTickets = [];
let filteredTickets = []; let filteredTickets = [];
let customFieldsMap = []; let customFieldsMap = [];
@@ -240,7 +235,7 @@
const btn = event.target.closest('.btn'); const btn = event.target.closest('.btn');
const orig = btn.innerHTML; btn.innerHTML = '<span class="spinner"></span> Test...'; btn.disabled = true; const orig = btn.innerHTML; btn.innerHTML = '<span class="spinner"></span> Test...'; btn.disabled = true;
try { try {
const r = await fetch(`${API_URL}/api/extract`, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({username:'_', apiToken:'_', projectKey:'_'}) }); 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(); 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 ! (Erreur 400 = normal, on a envoyé de fausses données)', 'success');
else showToast('API répond : ' + r.status, 'success'); else showToast('API répond : ' + r.status, 'success');
@@ -262,7 +257,7 @@
btn.innerHTML = '<span class="spinner"></span> Extraction via API...'; btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Extraction via API...'; btn.disabled = true;
try { try {
const resp = await fetch(`${API_URL}/api/extract`, { const resp = await fetch(`${API_BASE}/api/extract`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, apiToken: token, projectKey: project, jql: jql, customFields: custom }) body: JSON.stringify({ username: user, apiToken: token, projectKey: project, jql: jql, customFields: custom })
@@ -310,7 +305,6 @@
btn.innerHTML = '<span class="spinner"></span>'; btn.innerHTML = '<span class="spinner"></span>';
btn.disabled = true; btn.disabled = true;
// Trouver le customfield ID pour Go Live Date (défini AVANT le bloc if)
const goLiveField = customFieldsMap.find(cf => cf.label.toLowerCase().includes('go live')); const goLiveField = customFieldsMap.find(cf => cf.label.toLowerCase().includes('go live'));
const updates = {}; const updates = {};
@@ -320,7 +314,7 @@
} }
try { try {
const resp = await fetch(`${API_URL}/api/update`, { const resp = await fetch(`${API_BASE}/api/update`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -336,7 +330,6 @@
throw new Error(data.error || `Erreur HTTP ${resp.status}`); throw new Error(data.error || `Erreur HTTP ${resp.status}`);
} }
// Mise à jour locale des données
const ticket = allTickets.find(t => t.key === ticketKey); const ticket = allTickets.find(t => t.key === ticketKey);
if (ticket) { if (ticket) {
if (updates.duedate !== undefined) ticket.dueDate = updates.duedate; if (updates.duedate !== undefined) ticket.dueDate = updates.duedate;
@@ -356,22 +349,18 @@
function renderResults() { function renderResults() {
document.getElementById('results-section').classList.add('visible'); document.getElementById('results-section').classList.add('visible');
renderStats(); renderTable(); renderJSON(); renderRoadmap(); renderStats(); renderTable(); renderJSON();
} }
function renderStats() { function renderStats() {
const unassigned = allTickets.filter(t => !t.assignee).length; const unassigned = allTickets.filter(t => !t.assignee).length;
const now = new Date(); const now = new Date();
const overdue = allTickets.filter(t => t.dueDate && t.statusCategory !== 'Done' && new Date(t.dueDate) < now).length; const overdue = allTickets.filter(t => t.dueDate && t.statusCategory !== 'Done' && new Date(t.dueDate) < now).length;
const uniqueLabels = new Set(allTickets.flatMap(t => t.labels || [])).size;
const withLabels = allTickets.filter(t => t.labels && t.labels.length > 0).length;
document.getElementById('statsRow').innerHTML = ` 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">${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">${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">${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> <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>
<div class="stat-card"><div class="stat-value">${uniqueLabels}</div><div class="stat-label">Labels différents</div></div>
<div class="stat-card"><div class="stat-value">${withLabels}</div><div class="stat-label">Avec labels</div></div>
`; `;
} }
@@ -380,45 +369,27 @@
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 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() { function renderTable() {
// Détection dynamique des colonnes personnalisées (ex: Go Live Date) // Trouver le champ Go Live Date
const customCols = [];
let goLiveColName = null; let goLiveColName = null;
if (allTickets.length > 0) { if (allTickets.length > 0) {
const firstTicket = allTickets[0]; Object.keys(allTickets[0]).forEach(key => {
Object.keys(firstTicket).forEach(key => {
if (!['key','url','summary','description','status','statusCategory','assignee','reporter','priority','issueType','created','updated','dueDate','resolution','resolutionDate','labels','components','fixVersions','projectName'].includes(key)) { if (!['key','url','summary','description','status','statusCategory','assignee','reporter','priority','issueType','created','updated','dueDate','resolution','resolutionDate','labels','components','fixVersions','projectName'].includes(key)) {
customCols.push(key); goLiveColName = key;
if (key.toLowerCase().includes('go live')) {
goLiveColName = key;
}
} }
}); });
} }
// Colonnes standard + custom + Actions const cols = ['Ticket','Summary','Statut','Assigné','Priorité','Type','Due Date','Créé le','Résolu le','Actions'];
const cols = ['Ticket','Summary','Statut','Assigné','Priorité','Type','Due Date','Labels', ...customCols, '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>'; document.getElementById('tableHead').innerHTML = '<tr>' + cols.map(c=>`<th>${c}</th>`).join('') + '</tr>';
let body = ''; let body = '';
filteredTickets.forEach(t => { filteredTickets.forEach(t => {
// Affichage des labels
const labelsHtml = t.labels && t.labels.length > 0
? t.labels.map(l => `<span style="display:inline-block;background:rgba(0,230,138,0.12);color:#00e68a;padding:2px 8px;border-radius:12px;font-size:10px;font-weight:600;margin:1px;">${escHtml(l)}</span>`).join('')
: '<span class="empty-cell">-</span>';
// Due Date éditable // Due Date éditable
const dueDateValue = t.dueDate ? t.dueDate.substring(0, 10) : ''; 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}')">`; const dueDateInput = `<input type="date" class="date-input" id="due-${t.key}" value="${dueDateValue}" onchange="enableUpdateBtn('${t.key}')">`;
// Colonnes personnalisées
const customColsHtml = customCols.map(col => {
const val = t[col];
if (col.toLowerCase().includes('date')) {
const dateValue = val ? val.substring(0, 10) : '';
return `<td><input type="date" class="date-input" id="${col.replace(/\s+/g, '-')}-${t.key}" value="${dateValue}" onchange="enableUpdateBtn('${t.key}')"></td>`;
}
return `<td>${val !== null && val !== undefined ? escHtml(val) : '<span class="empty-cell">-</span>'}</td>`;
}).join('');
body += `<tr> body += `<tr>
<td><span class="ticket-key">${escHtml(t.key)}</span></td> <td><span class="ticket-key">${escHtml(t.key)}</span></td>
<td title="${escHtml(t.summary)}">${escHtml(t.summary)}</td> <td title="${escHtml(t.summary)}">${escHtml(t.summary)}</td>
@@ -427,16 +398,20 @@
<td>${escHtml(t.priority)}</td> <td>${escHtml(t.priority)}</td>
<td>${escHtml(t.issueType)}</td> <td>${escHtml(t.issueType)}</td>
<td>${dueDateInput}</td> <td>${dueDateInput}</td>
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${labelsHtml}</td>
${customColsHtml}
<td>${fmtDate(t.created)}</td> <td>${fmtDate(t.created)}</td>
<td>${fmtDate(t.resolutionDate)}</td> <td>${fmtDate(t.resolutionDate)}</td>`;
<td>
<button class="btn-update" id="update-${t.key}" data-ticket="${t.key}" onclick="handleUpdateClick('${t.key}', '${goLiveColName || ''}')" disabled> if (goLiveColName) {
<i class="fa-solid fa-rotate"></i> Update const goLiveDateValue = t[goLiveColName] ? t[goLiveColName].substring(0, 10) : '';
</button> const goLiveDateInput = `<input type="date" class="date-input" id="${goLiveColName.replace(/\s+/g, '-')}-${t.key}" value="${goLiveDateValue}" onchange="enableUpdateBtn('${t.key}')">`;
</td> body += `<td>${goLiveDateInput}</td>`;
</tr>`; }
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>`; 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; document.getElementById('tableBody').innerHTML = body;
@@ -476,14 +451,12 @@
document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active')); document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active')); document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
btn.classList.add('active'); document.getElementById(`panel-${id}`).classList.add('active'); btn.classList.add('active'); document.getElementById(`panel-${id}`).classList.add('active');
if (id === 'roadmap') renderRoadmap();
} }
function filterTable() { function filterTable() {
const q = document.getElementById('searchInput').value.toLowerCase(); 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]; filteredTickets = q ? allTickets.filter(t => Object.values(t).some(v => v && typeof v === 'string' && v.toLowerCase().includes(q))) : [...allTickets];
renderTable(); renderTable();
renderRoadmap();
} }
function copyJSON() { function copyJSON() {
@@ -502,11 +475,17 @@
document.getElementById('searchInput').value=''; document.getElementById('searchInput').value='';
} }
// ==================== Roadmap Draw.io Functions ==================== // 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) { function getStatusColor(status) {
const s = (status || '').toLowerCase(); const s = (status || '').toLowerCase();
if (s.includes('done') || s.includes('ferm') || s.includes('résolu')) return '#00e68a'; 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('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('to do') || s.includes('open') || s.includes('backlog')) return '#ffb84d';
if (s.includes('block')) return '#ff4d6a'; if (s.includes('block')) return '#ff4d6a';
@@ -515,85 +494,96 @@
function renderRoadmap() { function renderRoadmap() {
const preview = document.getElementById('roadmapPreview'); const preview = document.getElementById('roadmapPreview');
const filter = document.getElementById('roadmapFilter').value; 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>`;
let tickets = [...filteredTickets];
// Filtrer selon l'option sélectionnée
if (filter === 'inprogress') {
tickets = tickets.filter(t => t.statusCategory === 'In Progress');
} else if (filter === 'hasDates') {
tickets = tickets.filter(t => t.dueDate || (t['Go Live Date'] || t['go live date']));
}
if (tickets.length === 0) {
preview.innerHTML = `<div style="text-align:center; padding:60px; color:var(--fg-dim);"><i class="fa-solid fa-diagram-project" style="font-size:48px; margin-bottom:16px; opacity:0.5;"></i><p>Aucun ticket à afficher</p></div>`;
return; return;
} }
// Trouver le champ Go Live Date // Trouver le champ Go Live Date
const goLiveColName = Object.keys(tickets[0]).find(k => k.toLowerCase().includes('go live')) || null; const goLiveColName = filteredTickets.length > 0 ? Object.keys(filteredTickets[0]).find(k => k.toLowerCase().includes('go live')) : null;
// Trouver les dates min et max pour l'échelle // Calculer les dates min et max avec arrondi par mois
const allDates = tickets.flatMap(t => [t.dueDate, goLiveColName ? t[goLiveColName] : null].filter(d => d)); const allDates = filteredTickets.flatMap(t => [t.dueDate, goLiveColName ? t[goLiveColName] : null].filter(d => d));
if (allDates.length === 0) { 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="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; return;
} }
const minDate = new Date(Math.min(...allDates.map(d => new Date(d)))); const minDate = new Date(Math.min(...allDates.map(d => new Date(d))));
const maxDate = new Date(Math.max(...allDates.map(d => new Date(d)))); const maxDate = new Date(Math.max(...allDates.map(d => new Date(d))));
// Étendre un peu les dates // Arrondir aux mois entiers
minDate.setDate(minDate.getDate() - 3); minDate.setDate(1);
maxDate.setDate(maxDate.getDate() + 3); maxDate.setMonth(maxDate.getMonth() + 1);
maxDate.setDate(0);
const totalDays = Math.ceil((maxDate - minDate) / (1000 * 60 * 60 * 24)); // Calculer le nombre de mois
const dayWidth = Math.max(30, Math.min(80, 2000 / totalDays)); const totalMonths = (maxDate.getFullYear() - minDate.getFullYear()) * 12 + (maxDate.getMonth() - minDate.getMonth());
const monthWidth = 120;
const rowHeight = 60; const rowHeight = 60;
const headerHeight = 40; const headerHeight = 40;
const leftWidth = 30; const leftWidth = 280;
const canvasWidth = leftWidth + totalMonths * monthWidth + 50;
let html = `<div style="position:relative; min-width:${leftWidth + totalDays * dayWidth}px; height:${headerHeight + tickets.length * rowHeight + 40}px;">`; // Grouper les tickets par mois de début et fin
const monthlyTickets = {};
// En-tête avec dates filteredTickets.forEach(ticket => {
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);">Date</div>`;
for (let d = 0; d <= totalDays; d++) {
const currentDate = new Date(minDate);
currentDate.setDate(currentDate.getDate() + d);
const dateStr = currentDate.toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' });
const showLabel = d % Math.ceil(30 / dayWidth) === 0;
html += `<div style="position:absolute; left:${leftWidth + d * dayWidth}px; top:0; width:${dayWidth}px; height:${headerHeight}px; border:1px solid rgba(122,148,136,0.3); display:flex; align-items:${showLabel ? 'flex-end' : 'center'}; justify-content:${showLabel ? 'flex-start' : 'center'}; flex-direction:column; font-size:10px; color:var(--fg-dim);">
${showLabel ? `<span style="padding:2px 4px; background:var(--bg); font-weight:600;">${dateStr}</span>` : ''}
</div>`;
}
// Lignes de tickets
tickets.forEach((ticket, idx) => {
const y = headerHeight + idx * rowHeight;
const due = ticket.dueDate ? new Date(ticket.dueDate) : null; const due = ticket.dueDate ? new Date(ticket.dueDate) : null;
const goLive = goLiveColName && ticket[goLiveColName] ? new Date(ticket[goLiveColName]) : null; const goLive = goLiveColName && ticket[goLiveColName] ? new Date(ticket[goLiveColName]) : null;
if (!due && !goLive) return;
if (due || goLive) { const start = due ? due : goLive;
const start = due ? due : goLive; const end = goLive ? goLive : due;
const end = goLive ? goLive : due;
const startDays = Math.ceil((start - minDate) / (1000 * 60 * 60 * 24)); const startMonth = `${start.getFullYear()}-${start.getMonth()}`;
const endDays = Math.ceil((end - minDate) / (1000 * 60 * 60 * 24)); const endMonth = `${end.getFullYear()}-${end.getMonth()}`;
const barWidth = Math.max(dayWidth, (endDays - startDays + 1) * dayWidth);
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); const color = getStatusColor(ticket.status);
html += `<div style="position:absolute; left:${leftWidth + startDays * dayWidth}px; top:${y + 8}px; width:${barWidth}px; height:${rowHeight - 16}px; background:${color}33; border-left:4px solid ${color}; border-radius:6px; padding:8px 12px; overflow:hidden; display:flex; flex-direction:column; gap:2px; cursor:pointer;" title="${escHtml(ticket.key)} - ${escHtml(ticket.summary)}"> 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:12px; color:var(--fg); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${escHtml(ticket.key)}</div> <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:11px; color:var(--fg); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${escHtml(ticket.summary)}</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="display:flex; gap:8px; font-size:10px; color:var(--fg-muted);"> <div style="font-size:9px; color:var(--fg-muted); margin-top:4px;">${escHtml(ticket.assignee || 'N/A')} - ${escHtml(ticket.status || 'N/A')}</div>
<span><i class="fa-solid fa-user"></i> ${escHtml(ticket.assignee || 'N/A')}</span>
<span><i class="fa-solid fa-circle" style="font-size:6px; color:${color};"></i> ${escHtml(ticket.status || 'N/A')}</span>
</div>
</div>`; </div>`;
} });
}); });
html += `</div>`; html += `</div>`;
@@ -604,23 +594,24 @@
// Trouver le champ Go Live Date // Trouver le champ Go Live Date
const goLiveColName = tickets.length > 0 ? Object.keys(tickets[0]).find(k => k.toLowerCase().includes('go live')) : null; const goLiveColName = tickets.length > 0 ? Object.keys(tickets[0]).find(k => k.toLowerCase().includes('go live')) : null;
// Trouver les dates min et max // Calculer les dates min et max avec arrondi par mois
const allDates = tickets.flatMap(t => [t.dueDate, goLiveColName ? t[goLiveColName] : null].filter(d => d)); const allDates = tickets.flatMap(t => [t.dueDate, goLiveColName ? t[goLiveColName] : null].filter(d => d));
if (allDates.length === 0) return null; if (allDates.length === 0) return null;
const minDate = new Date(Math.min(...allDates.map(d => new Date(d)))); const minDate = new Date(Math.min(...allDates.map(d => new Date(d))));
const maxDate = new Date(Math.max(...allDates.map(d => new Date(d)))); const maxDate = new Date(Math.max(...allDates.map(d => new Date(d))));
// Étendre un peu les dates // Arrondir aux mois entiers
minDate.setDate(minDate.getDate() - 3); minDate.setDate(1);
maxDate.setDate(maxDate.getDate() + 3); maxDate.setMonth(maxDate.getMonth() + 1);
maxDate.setDate(0);
const totalDays = Math.ceil((maxDate - minDate) / (1000 * 60 * 60 * 24)); const totalMonths = (maxDate.getFullYear() - minDate.getFullYear()) * 12 + (maxDate.getMonth() - minDate.getMonth());
const dayWidth = 40; const monthWidth = 100;
const rowHeight = 70; const rowHeight = 70;
const headerHeight = 50; const headerHeight = 50;
const leftWidth = 40; const leftWidth = 200;
const canvasWidth = Math.max(827, leftWidth + totalDays * dayWidth + 100); const canvasWidth = Math.max(827, leftWidth + totalMonths * monthWidth + 100);
const canvasHeight = Math.max(1169, headerHeight + tickets.length * rowHeight + 50); const canvasHeight = Math.max(1169, headerHeight + tickets.length * rowHeight + 50);
let cellId = 2; let cellId = 2;
@@ -629,7 +620,7 @@
// Header background // Header background
cells.push({ cells.push({
id: 'header', id: 'header',
value: 'Roadmap Jira', value: 'Roadmap Jira (par mois)',
style: 'rounded=0;whiteSpace=wrap;html=1;fillColor=#111a16;strokeColor=#1e3028;fontColor=#e8f0ec;fontSize=14;fontStyle=1', style: 'rounded=0;whiteSpace=wrap;html=1;fillColor=#111a16;strokeColor=#1e3028;fontColor=#e8f0ec;fontSize=14;fontStyle=1',
x: 0, x: 0,
y: 0, y: 0,
@@ -637,40 +628,39 @@
height: headerHeight height: headerHeight
}); });
// Date headers (afficher une date tous les 10 jours) // En-têtes de mois
for (let d = 0; d <= totalDays; d += 10) { const months = [];
const currentDate = new Date(minDate); const currentMonth = new Date(minDate);
currentDate.setDate(currentDate.getDate() + d); for (let m = 0; m <= totalMonths; m++) {
const dateStr = currentDate.toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' }); const monthName = currentMonth.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' });
cells.push({ cells.push({
id: `date${cellId}`, id: `month${cellId}`,
value: dateStr, value: monthName,
style: 'rounded=0;whiteSpace=wrap;html=1;fillColor=#152019;strokeColor=#1e3028;fontColor=#7a9488;fontSize=10;align=center;', style: 'rounded=0;whiteSpace=wrap;html=1;fillColor=#152019;strokeColor=#1e3028;fontColor=#7a9488;fontSize=10;align=center;',
x: leftWidth + d * dayWidth, x: leftWidth + m * monthWidth,
y: headerHeight + 5, y: headerHeight + 10,
width: dayWidth * 10, width: monthWidth,
height: 30 height: 30
}); });
cellId++; cellId++;
currentMonth.setMonth(currentMonth.getMonth() + 1);
} }
// Tickets // Tickets
tickets.forEach((ticket, idx) => { tickets.forEach((ticket, idx) => {
const y = headerHeight + idx * rowHeight + 15; const y = headerHeight + idx * rowHeight + 50;
const due = ticket.dueDate ? new Date(ticket.dueDate) : null; const due = ticket.dueDate ? new Date(ticket.dueDate) : null;
const goLive = goLiveColName && ticket[goLiveColName] ? new Date(ticket[goLiveColName]) : null; const goLive = goLiveColName && ticket[goLiveColName] ? new Date(ticket[goLiveColName]) : null;
if (due || goLive) { if (due || goLive) {
const start = due ? due : goLive; const start = due ? due : goLive;
const end = goLive ? goLive : due; const end = goLive ? goLive : due;
const startDays = Math.ceil((start - minDate) / (1000 * 60 * 60 * 24));
const endDays = Math.ceil((end - minDate) / (1000 * 60 * 60 * 24)); const startMonthIdx = months.indexOf(`${start.getFullYear()}-${start.getMonth()}`);
const barWidth = Math.max(dayWidth * 3, (endDays - startDays + 1) * dayWidth); const endMonthIdx = months.indexOf(`${end.getFullYear()}-${end.getMonth()}`);
const barWidth = Math.max(monthWidth, (endMonthIdx - startMonthIdx + 1) * monthWidth);
const color = getStatusColor(ticket.status); const color = getStatusColor(ticket.status);
// Ticket bar - utiliser des lignes séparées au lieu de \n
const labelParts = [ const labelParts = [
`${ticket.key}: ${ticket.summary.substring(0, 40)}`, `${ticket.key}: ${ticket.summary.substring(0, 40)}`,
`${ticket.assignee || 'N/A'} - ${ticket.status || 'N/A'}` `${ticket.assignee || 'N/A'} - ${ticket.status || 'N/A'}`
@@ -681,20 +671,20 @@
id: `ticket${cellId}`, id: `ticket${cellId}`,
value: label, 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;`, 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 + startDays * dayWidth, x: leftWidth + startMonthIdx * monthWidth + 10,
y: y, y: y,
width: barWidth, width: barWidth - 20,
height: rowHeight - 10 height: rowHeight - 10
}); });
cellId++; cellId++;
} }
}); });
// Construire le XML avec le format exact de Draw.io // Construire le XML
let xml = `<?xml version="1.0" encoding="UTF-8"?> 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"> <mxfile host="app.diagrams.net" modified="${new Date().toISOString()}" agent="Jira Roadmap Generator" version="22.0.0" type="device">
<diagram id="roadmap" name="Roadmap"> <diagram id="roadmap" name="Roadmap">
<mxGraphModel dx="1422" dy="794" 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"> <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> <root>
<mxCell id="0" /> <mxCell id="0" />
<mxCell id="1" parent="0" />`; <mxCell id="1" parent="0" />`;
@@ -707,23 +697,12 @@
// Ajouter toutes les cells // Ajouter toutes les cells
cells.forEach(cell => { cells.forEach(cell => {
// Fonction d'échappement XML robuste const safeValue = cell.value
function escapeXml(str) { .replace(/&/g, '\x26amp;')
if (!str) return ''; .replace(/</g, '\x26lt;')
let result = String(str); .replace(/>/g, '\x26gt;')
const map = { .replace(/"/g, '\x26quot;')
'\x26': '\x26amp;', // & .replace(/'/g, '\x26apos;');
'\x3C': '\x26lt;', // <
'\x3E': '\x26gt;', // >
'\x22': '\x26quot;', // "
'\x27': '\x26apos;' // '
};
for (let char in map) {
result = result.split(char).join(map[char]);
}
return result;
}
const safeValue = escapeXml(cell.value);
xml += ` xml += `
<mxCell id="${cell.id}" value="${safeValue}" style="${cell.style}" vertex="1" parent="1"> <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" /> <mxGeometry x="${cell.x}" y="${cell.y}" width="${cell.width}" height="${cell.height}" as="geometry" />
@@ -741,34 +720,24 @@
function downloadRoadmap() { function downloadRoadmap() {
if (allTickets.length === 0) { if (allTickets.length === 0) {
showToast('Aucun ticket à exporter.', 'error'); showToast('Aucun ticket a exporter.', 'error');
return; return;
} }
const filter = document.getElementById('roadmapFilter')?.value || 'all'; const xml = generateDrawioXML(filteredTickets);
let tickets = [...filteredTickets];
if (filter === 'inprogress') {
tickets = tickets.filter(t => t.statusCategory === 'In Progress');
} else if (filter === 'hasDates') {
tickets = tickets.filter(t => t.dueDate || (t['Go Live Date'] || t['go live date']));
}
const xml = generateDrawioXML(tickets);
if (!xml) { if (!xml) {
showToast('Impossible de générer la roadmap : aucune date disponible.', 'error'); showToast('Impossible de generer la roadmap : aucune date disponible.', 'error');
return; return;
} }
// Créer un blob avec l'encodage UTF-8 explicite
const blob = new Blob([xml], { type: 'text/xml;charset=utf-8' }); const blob = new Blob([xml], { type: 'text/xml;charset=utf-8' });
const a = document.createElement('a'); const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.href = URL.createObjectURL(blob);
a.download = `roadmap-${document.getElementById('projectKey').value || 'export'}.xml`; a.download = `roadmap-${document.getElementById('projectKey').value || 'export'}.xml`;
a.click(); a.click();
URL.revokeObjectURL(a.href); URL.revokeObjectURL(a.href);
showToast('Roadmap Draw.io téléchargée avec succès !'); showToast('Roadmap Draw.io telechargee avec succes !');
} }
document.addEventListener('keydown', e => { if((e.ctrlKey||e.metaKey)&&e.key==='Enter'){e.preventDefault();startExtraction()} }); document.addEventListener('keydown', e => { if((e.ctrlKey||e.metaKey)&&e.key==='Enter'){e.preventDefault();startExtraction()} });