Compare commits
7 Commits
ce38183467
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| be2f01a7fa | |||
| 25357e52c0 | |||
| f622f155d0 | |||
| ef90251b5c | |||
| 694a02e0d3 | |||
| 8ad5aa1c7e | |||
| c169fafb2e |
+54
-664
@@ -6,114 +6,7 @@
|
|||||||
<title>Jira Ticket Extractor</title>
|
<title>Jira Ticket Extractor</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=DM+Sans:wght@300;400;500;600;700;900&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=DM+Sans:wght@300;400;500;600;700;900&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
|
||||||
<style>
|
<link rel="stylesheet" href="css/style.css">
|
||||||
:root {
|
|
||||||
--bg: #0a0f0d; --bg-elevated: #111a16; --bg-card: #152019; --bg-input: #0d1511;
|
|
||||||
--fg: #e8f0ec; --fg-muted: #7a9488; --fg-dim: #4a6558;
|
|
||||||
--accent: #00e68a; --accent-dim: #00b36b; --accent-glow: rgba(0,230,138,0.15); --accent-glow-strong: rgba(0,230,138,0.3);
|
|
||||||
--border: #1e3028; --danger: #ff4d6a; --warning: #ffb84d; --info: #4dc8ff;
|
|
||||||
--radius: 10px; --radius-lg: 16px; --shadow: 0 4px 24px rgba(0,0,0,0.4);
|
|
||||||
}
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
body { font-family: 'DM Sans', sans-serif; background: var(--bg); color: var(--fg); min-height: 100vh; }
|
|
||||||
.bg-grid { position: fixed; inset: 0; z-index: 0; pointer-events: none; background-image: linear-gradient(var(--border) 1px, transparent 1px), linear-gradient(90deg, var(--border) 1px, transparent 1px); background-size: 60px 60px; opacity: 0.3; mask-image: radial-gradient(ellipse 70% 60% at 50% 30%, black 20%, transparent 70%); }
|
|
||||||
.bg-glow { position: fixed; z-index: 0; pointer-events: none; width: 600px; height: 600px; border-radius: 50%; filter: blur(120px); opacity: 0.12; }
|
|
||||||
.bg-glow--1 { top: -200px; right: -100px; background: var(--accent); animation: gf 12s ease-in-out infinite; }
|
|
||||||
@keyframes gf { 0%,100%{transform:translate(0,0) scale(1)} 50%{transform:translate(30px,-40px) scale(1.1)} }
|
|
||||||
|
|
||||||
.app { position: relative; z-index: 1; max-width: 1280px; margin: 0 auto; padding: 32px 24px 80px; }
|
|
||||||
.header { display: flex; align-items: center; gap: 16px; margin-bottom: 40px; padding-bottom: 24px; border-bottom: 1px solid var(--border); }
|
|
||||||
.header-icon { width: 52px; height: 52px; background: linear-gradient(135deg, var(--accent), var(--accent-dim)); border-radius: 14px; display: flex; align-items: center; justify-content: center; font-size: 22px; color: var(--bg); box-shadow: 0 0 30px var(--accent-glow-strong); }
|
|
||||||
.header-text h1 { font-size: 26px; font-weight: 900; letter-spacing: -0.5px; }
|
|
||||||
.header-text p { color: var(--fg-muted); font-size: 14px; margin-top: 4px; }
|
|
||||||
.badge-api { background: var(--accent-glow); color: var(--accent); font-size: 11px; font-weight: 700; padding: 4px 10px; border-radius: 20px; border: 1px solid rgba(0,230,138,0.2); margin-left: auto; font-family: 'JetBrains Mono', monospace; }
|
|
||||||
|
|
||||||
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 28px; margin-bottom: 24px; box-shadow: var(--shadow); }
|
|
||||||
.card-title { font-size: 16px; font-weight: 700; margin-bottom: 20px; display: flex; align-items: center; gap: 10px; }
|
|
||||||
.card-title i { color: var(--accent); font-size: 14px; }
|
|
||||||
|
|
||||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
||||||
.form-grid .full { grid-column: 1 / -1; }
|
|
||||||
.form-group { display: flex; flex-direction: column; gap: 6px; }
|
|
||||||
.form-group label { font-size: 12px; font-weight: 600; color: var(--fg-muted); text-transform: uppercase; letter-spacing: 0.8px; }
|
|
||||||
.form-group input, .form-group textarea { background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--radius); padding: 11px 14px; color: var(--fg); font-family: 'DM Sans', sans-serif; font-size: 14px; outline: none; transition: border-color 0.25s, box-shadow 0.25s; }
|
|
||||||
.form-group input:focus, .form-group textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
|
|
||||||
.form-group textarea { resize: vertical; min-height: 60px; font-family: 'JetBrains Mono', monospace; font-size: 12px; }
|
|
||||||
.hint { font-size: 11px; color: var(--fg-dim); font-style: italic; }
|
|
||||||
|
|
||||||
.btn { display: inline-flex; align-items: center; gap: 8px; padding: 12px 24px; border-radius: var(--radius); border: none; font-family: 'DM Sans', sans-serif; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.25s; outline: none; }
|
|
||||||
.btn:focus-visible { box-shadow: 0 0 0 3px var(--accent-glow-strong); }
|
|
||||||
.btn-primary { background: linear-gradient(135deg, var(--accent), var(--accent-dim)); color: var(--bg); box-shadow: 0 4px 20px var(--accent-glow-strong); }
|
|
||||||
.btn-primary:hover { transform: translateY(-1px); }
|
|
||||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
|
||||||
.btn-secondary { background: var(--bg-input); color: var(--fg); border: 1px solid var(--border); }
|
|
||||||
.btn-secondary:hover { border-color: var(--fg-dim); }
|
|
||||||
.btn-danger { background: rgba(255,77,106,0.1); color: var(--danger); border: 1px solid rgba(255,77,106,0.2); }
|
|
||||||
.btn-group { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 8px; }
|
|
||||||
|
|
||||||
.alert { padding: 14px 18px; border-radius: var(--radius); font-size: 13px; display: flex; align-items: flex-start; gap: 10px; margin-bottom: 16px; animation: fadeIn 0.3s ease; }
|
|
||||||
@keyframes fadeIn { from{opacity:0;transform:translateY(-8px)} to{opacity:1;transform:translateY(0)} }
|
|
||||||
.alert-info { background: rgba(77,200,255,0.1); border: 1px solid rgba(77,200,255,0.2); color: var(--info); }
|
|
||||||
.alert-danger { background: rgba(255,77,106,0.1); border: 1px solid rgba(255,77,106,0.2); color: var(--danger); }
|
|
||||||
|
|
||||||
.spinner { display: inline-block; width: 18px; height: 18px; border: 2px solid transparent; border-top-color: currentColor; border-radius: 50%; animation: spin 0.7s linear infinite; }
|
|
||||||
@keyframes spin { to{transform:rotate(360deg)} }
|
|
||||||
|
|
||||||
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 24px; }
|
|
||||||
.stat-card { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; text-align: center; transition: transform 0.25s; }
|
|
||||||
.stat-card:hover { transform: translateY(-2px); }
|
|
||||||
.stat-value { font-size: 28px; font-weight: 900; color: var(--accent); font-family: 'JetBrains Mono', monospace; }
|
|
||||||
.stat-label { font-size: 11px; color: var(--fg-muted); text-transform: uppercase; letter-spacing: 0.8px; margin-top: 4px; }
|
|
||||||
|
|
||||||
.tabs { display: flex; gap: 4px; background: var(--bg-input); border-radius: var(--radius); padding: 4px; margin-bottom: 20px; border: 1px solid var(--border); width: fit-content; }
|
|
||||||
.tab-btn { padding: 9px 20px; border: none; background: transparent; color: var(--fg-muted); font-family: 'DM Sans', sans-serif; font-size: 13px; font-weight: 600; border-radius: 7px; cursor: pointer; transition: all 0.25s; outline: none; }
|
|
||||||
.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; }
|
|
||||||
|
|
||||||
.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius); }
|
|
||||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
||||||
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; }
|
|
||||||
td { padding: 10px 14px; border-bottom: 1px solid var(--border); color: var(--fg); max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
tr:hover td { background: rgba(0,230,138,0.03); }
|
|
||||||
.ticket-key { font-family: 'JetBrains Mono', monospace; font-weight: 700; color: var(--accent); font-size: 12px; }
|
|
||||||
.status-badge { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 600; white-space: nowrap; }
|
|
||||||
.s-done { background: rgba(0,230,138,0.15); color: #00e68a; } .s-progress { background: rgba(77,200,255,0.15); color: #4dc8ff; }
|
|
||||||
.s-todo { background: rgba(255,184,77,0.15); color: #ffb84d; } .s-blocked { background: rgba(255,77,106,0.15); color: #ff4d6a; } .s-other { background: rgba(122,148,136,0.15); color: #7a9488; }
|
|
||||||
.empty-cell { color: var(--fg-dim); font-style: italic; }
|
|
||||||
|
|
||||||
/* 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, box-shadow 0.25s; width: 100px; }
|
|
||||||
.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:hover { opacity: 1; }
|
|
||||||
|
|
||||||
/* 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:hover { transform: translateY(-1px); box-shadow: 0 2px 8px var(--accent-glow-strong); }
|
|
||||||
.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 i { font-size: 10px; }
|
|
||||||
|
|
||||||
.json-viewer { background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; max-height: 600px; overflow: auto; font-family: 'JetBrains Mono', monospace; font-size: 12px; line-height: 1.7; white-space: pre-wrap; word-break: break-all; }
|
|
||||||
.json-key { color: #4dc8ff; } .json-string { color: #00e68a; } .json-number { color: #ffb84d; } .json-bool { color: #ff4d6a; } .json-null { color: var(--fg-dim); }
|
|
||||||
|
|
||||||
.search-bar { position: relative; margin-bottom: 16px; }
|
|
||||||
.search-bar i { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); color: var(--fg-dim); }
|
|
||||||
.search-bar input { width: 100%; background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--radius); padding: 11px 14px 11px 38px; color: var(--fg); font-family: 'DM Sans', sans-serif; font-size: 14px; outline: none; transition: border-color 0.25s, box-shadow 0.25s; }
|
|
||||||
.search-bar input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
|
|
||||||
|
|
||||||
#results-section { display: none; } #results-section.visible { display: block; animation: fadeIn 0.4s ease; }
|
|
||||||
.toast-container { position: fixed; top: 24px; right: 24px; z-index: 9999; display: flex; flex-direction: column; gap: 10px; }
|
|
||||||
.toast { padding: 14px 20px; border-radius: var(--radius); font-size: 13px; font-weight: 500; display: flex; align-items: center; gap: 10px; box-shadow: 0 8px 30px rgba(0,0,0,0.5); animation: tIn 0.35s ease, tOut 0.35s ease 3s forwards; max-width: 400px; }
|
|
||||||
.toast-success { background: #0d2818; border: 1px solid rgba(0,230,138,0.3); color: var(--accent); }
|
|
||||||
.toast-error { background: #280d14; border: 1px solid rgba(255,77,106,0.3); color: var(--danger); }
|
|
||||||
@keyframes tIn { from{opacity:0;transform:translateX(40px)} to{opacity:1;transform:translateX(0)} }
|
|
||||||
@keyframes tOut { to{opacity:0;transform:translateX(40px)} }
|
|
||||||
|
|
||||||
@media (max-width: 768px) { .form-grid { grid-template-columns: 1fr; } .btn-group { flex-direction: column; } .btn { width: 100%; justify-content: center; } }
|
|
||||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="bg-grid"></div>
|
<div class="bg-grid"></div>
|
||||||
@@ -127,10 +20,10 @@
|
|||||||
<h1>Jira Ticket Extractor</h1>
|
<h1>Jira Ticket Extractor</h1>
|
||||||
<p>Extraction via API locale — Proxy transparent</p>
|
<p>Extraction via API locale — Proxy transparent</p>
|
||||||
</div>
|
</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>
|
</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="card-title"><i class="fa-solid fa-filter"></i> Paramètres d'extraction</div>
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
<i class="fa-solid fa-circle-info"></i>
|
<i class="fa-solid fa-circle-info"></i>
|
||||||
@@ -148,7 +41,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="projectKey">Clé du projet</label>
|
<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>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="customFields">Champs personnalisés (optionnel)</label>
|
<label for="customFields">Champs personnalisés (optionnel)</label>
|
||||||
@@ -160,7 +53,7 @@
|
|||||||
<textarea id="jqlQuery" placeholder='project = "PROJ" AND status != Deleted ORDER BY created DESC' rows="2"></textarea>
|
<textarea id="jqlQuery" placeholder='project = "PROJ" AND status != Deleted ORDER BY created DESC' rows="2"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</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()">
|
<button class="btn btn-primary" id="extractBtn" onclick="startExtraction()">
|
||||||
<i class="fa-solid fa-download"></i> Lancer l'extraction
|
<i class="fa-solid fa-download"></i> Lancer l'extraction
|
||||||
</button>
|
</button>
|
||||||
@@ -172,575 +65,72 @@
|
|||||||
|
|
||||||
<div id="results-section">
|
<div id="results-section">
|
||||||
<div class="stats-row" id="statsRow"></div>
|
<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">
|
<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 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" style="margin-right:6px;"></i>Roadmap</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" style="margin-right:6px;"></i>JSON Brut</button>
|
<button class="tab-btn" onclick="switchTab('json', this)"><i class="fa-solid fa-code"></i>JSON Brut</button>
|
||||||
</div>
|
</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-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="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-danger" onclick="clearResults()"><i class="fa-solid fa-trash"></i> Effacer</button>
|
<button class="btn btn-danger" onclick="clearResults()"><i class="fa-solid fa-trash"></i> Effacer</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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="tab-panel" id="panel-roadmap">
|
||||||
<div class="card" style="margin-bottom:16px;">
|
<div class="toolbar-roadmap">
|
||||||
<div style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
|
<div class="view-controls">
|
||||||
<span><i class="fa-solid fa-circle-info" style="color:var(--info); margin-right:8px;"></i>Affichage par mois pour plus de lisibilité</span>
|
<span class="mr-8"><i class="fa-solid fa-magnifying-glass-plus"></i> Vue :</span>
|
||||||
<button class="btn btn-primary" onclick="downloadRoadmap()" style="padding:8px 16px; font-size:12px;">
|
<div class="btn-group no-margin">
|
||||||
<i class="fa-solid fa-download" style="margin-right:6px;"></i>Télécharger le .drawio
|
<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 class="export-drawio-box">
|
||||||
|
<span class="mr-8 export-label">
|
||||||
|
<i class="fa-solid fa-diagram-project"></i> EXPORT DRAW.IO (XML)
|
||||||
|
</span>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="exportDrawIOXML('download')">
|
||||||
|
<i class="fa-solid fa-file-download"></i> Télécharger
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="exportDrawIOXML('copy')">
|
||||||
|
<i class="fa-solid fa-copy"></i> Copier XML
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card" style="padding:20px;">
|
</div>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<div class="tab-panel" id="panel-json"><div class="json-viewer" id="jsonViewer"></div></div>
|
<div class="tab-panel" id="panel-json"><div class="json-viewer" id="jsonViewer"></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="/js/script.js"></script>
|
||||||
|
|
||||||
<script>
|
|
||||||
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()} });
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Generated
+1003
File diff suppressed because it is too large
Load Diff
+6
-2
@@ -7,7 +7,11 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.0",
|
"axios": "^1.7.0",
|
||||||
"express": "^4.21.0",
|
"express": "^4.22.1",
|
||||||
"https-proxy-agent": "^9.0.0"
|
"https-proxy-agent": "^9.0.0"
|
||||||
}
|
},
|
||||||
|
"description": "Les labels sont maintenant automatiquement extraits et affichés dans le tableau avec un design visuel distinctif (badges verts).",
|
||||||
|
"main": "server.js",
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC"
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,687 @@
|
|||||||
|
const API_BASE = '';
|
||||||
|
let allTickets = [];
|
||||||
|
let filteredTickets = [];
|
||||||
|
let customFieldsMap = [];
|
||||||
|
let lastUsername = '';
|
||||||
|
let lastApiToken = '';
|
||||||
|
let currentGanttView = 'month';
|
||||||
|
let currentSortCol = null;
|
||||||
|
let isAsc = true;
|
||||||
|
|
||||||
|
|
||||||
|
// Sauvegarde les paramètres dans le stockage local du navigateur
|
||||||
|
function saveSettings() {
|
||||||
|
const settings = {
|
||||||
|
projectKey: document.getElementById('projectKey').value,
|
||||||
|
customFields: document.getElementById('customFields').value,
|
||||||
|
jqlQuery: document.getElementById('jqlQuery').value
|
||||||
|
};
|
||||||
|
localStorage.setItem('jira_roadmap_settings', JSON.stringify(settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charge les paramètres au démarrage
|
||||||
|
function loadSettings() {
|
||||||
|
const saved = localStorage.getItem('jira_roadmap_settings');
|
||||||
|
if (saved) {
|
||||||
|
const settings = JSON.parse(saved);
|
||||||
|
document.getElementById('projectKey').value = settings.projectKey || '';
|
||||||
|
document.getElementById('customFields').value = settings.customFields || '';
|
||||||
|
document.getElementById('jqlQuery').value = settings.jqlQuery || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- UTILITAIRES ---
|
||||||
|
function showToast(msg, type='success') {
|
||||||
|
const c = document.getElementById('toastContainer');
|
||||||
|
const t = document.createElement('div');
|
||||||
|
t.className = `toast toast-${type}`;
|
||||||
|
t.innerHTML = `<i class="fa-solid fa-${type==='success'?'check-circle':'xmark-circle'}"></i><span>${msg}</span>`;
|
||||||
|
c.appendChild(t);
|
||||||
|
setTimeout(() => t.remove(), 3500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoJql() {
|
||||||
|
const key = document.getElementById('projectKey').value.trim().toUpperCase();
|
||||||
|
const jql = document.getElementById('jqlQuery');
|
||||||
|
if (!jql.value.trim() || jql.value.trim().startsWith('project =')) {
|
||||||
|
jql.value = `project = "${key}" ORDER BY created DESC`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pingApi() {
|
||||||
|
saveSettings();
|
||||||
|
|
||||||
|
const btn = event.target.closest('.btn');
|
||||||
|
const orig = btn.innerHTML; btn.innerHTML = '<span class="spinner"></span> Test...'; btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API_BASE}/api/extract`, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({username:'_', apiToken:'_', projectKey:'_'}) });
|
||||||
|
if (r.status === 400) showToast('API locale connectée !', 'success');
|
||||||
|
else showToast('API répond : ' + r.status, 'success');
|
||||||
|
} catch(e) {
|
||||||
|
showToast('API inaccessible. Lancez "npm start".', 'error');
|
||||||
|
} finally { btn.innerHTML = orig; btn.disabled = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startExtraction() {
|
||||||
|
saveSettings();
|
||||||
|
|
||||||
|
const btn = document.getElementById('extractBtn');
|
||||||
|
const user = document.getElementById('username').value.trim();
|
||||||
|
const token = document.getElementById('apiToken').value.trim();
|
||||||
|
const project = document.getElementById('projectKey').value.trim().toUpperCase();
|
||||||
|
const jql = document.getElementById('jqlQuery').value.trim();
|
||||||
|
const custom = document.getElementById('customFields').value.trim();
|
||||||
|
|
||||||
|
if (!user || !token || !project) { showToast('Champs requis manquants.', 'error'); return; }
|
||||||
|
|
||||||
|
btn.innerHTML = '<span class="spinner"></span> Extraction...'; btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${API_BASE}/api/extract`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: user, apiToken: token, projectKey: project, jql: jql, customFields: custom })
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) throw new Error(data.error || `Erreur ${resp.status}`);
|
||||||
|
|
||||||
|
allTickets = data.tickets;
|
||||||
|
filteredTickets = [...allTickets];
|
||||||
|
customFieldsMap = parseCustomFields(custom);
|
||||||
|
lastUsername = user;
|
||||||
|
lastApiToken = token;
|
||||||
|
|
||||||
|
renderResults();
|
||||||
|
showToast(`${allTickets.length} tickets extraits.`, 'success');
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.innerHTML = '<i class="fa-solid fa-download"></i> Lancer l\'extraction'; btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCustomFields(customFields) {
|
||||||
|
const map = [];
|
||||||
|
if (customFields) {
|
||||||
|
customFields.split(',').forEach(cf => {
|
||||||
|
const [id, label] = cf.split(':').map(s => s.trim());
|
||||||
|
if (id) map.push({ id, label: label || id });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortTable(colKey) {
|
||||||
|
// Si on clique sur la même colonne, on inverse l'ordre, sinon on passe en croissant
|
||||||
|
if (currentSortCol === colKey) {
|
||||||
|
isAsc = !isAsc;
|
||||||
|
} else {
|
||||||
|
currentSortCol = colKey;
|
||||||
|
isAsc = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredTickets.sort((a, b) => {
|
||||||
|
let valA = a[colKey] || '';
|
||||||
|
let valB = b[colKey] || '';
|
||||||
|
|
||||||
|
// Gestion spécifique pour les dates pour un tri chronologique correct
|
||||||
|
if (colKey.toLowerCase().includes('date') || colKey === 'created' || colKey === 'dueDate') {
|
||||||
|
valA = new Date(valA);
|
||||||
|
valB = new Date(valB);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valA < valB) return isAsc ? -1 : 1;
|
||||||
|
if (valA > valB) return isAsc ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MISE À JOUR TICKET ---
|
||||||
|
async function updateTicket(ticketKey, dueDate, goLiveDate) {
|
||||||
|
if (!lastUsername || !lastApiToken) {
|
||||||
|
showToast('Veuillez d\'abord extraire les tickets.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.querySelector(`[data-ticket="${ticketKey}"]`);
|
||||||
|
const orig = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<span class="spinner"></span>';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
const goLiveField = customFieldsMap.find(cf => cf.label.toLowerCase().includes('go live'));
|
||||||
|
const updates = {};
|
||||||
|
if (dueDate !== undefined) updates.duedate = dueDate;
|
||||||
|
if (goLiveDate !== undefined && goLiveField) updates[goLiveField.id] = goLiveDate;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${API_BASE}/api/update`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: lastUsername, apiToken: lastApiToken, ticketKey, updates })
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json();
|
||||||
|
throw new Error(data.error || `Erreur HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticket = allTickets.find(t => t.key === ticketKey);
|
||||||
|
if (ticket) {
|
||||||
|
if (updates.duedate !== undefined) ticket.dueDate = updates.duedate;
|
||||||
|
if (goLiveField && updates[goLiveField.id] !== undefined) ticket[goLiveField.label] = updates[goLiveField.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTable();
|
||||||
|
renderRoadmap();
|
||||||
|
showToast(`Ticket ${ticketKey} mis à jour !`, 'success');
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.innerHTML = orig;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enableUpdateBtn(ticketKey) {
|
||||||
|
const btn = document.getElementById(`update-${ticketKey}`);
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdateClick(ticketKey, goLiveColName) {
|
||||||
|
const dueInput = document.getElementById(`due-${ticketKey}`);
|
||||||
|
const dueDate = dueInput && dueInput.value ? dueInput.value : null;
|
||||||
|
|
||||||
|
let goLiveDate = null;
|
||||||
|
if (goLiveColName) {
|
||||||
|
const goLiveInput = document.getElementById(`gl-${ticketKey}`);
|
||||||
|
goLiveDate = goLiveInput && goLiveInput.value ? goLiveInput.value : null;
|
||||||
|
}
|
||||||
|
updateTicket(ticketKey, dueDate, goLiveDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RENDU ---
|
||||||
|
function renderResults() {
|
||||||
|
document.getElementById('results-section').classList.add('visible');
|
||||||
|
renderStats(); renderTable(); renderJSON(); renderRoadmap();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStats() {
|
||||||
|
const unassigned = allTickets.filter(t => !t.assignee).length;
|
||||||
|
const now = new Date();
|
||||||
|
const overdue = allTickets.filter(t => t.dueDate && t.statusCategory !== 'Done' && new Date(t.dueDate) < now).length;
|
||||||
|
document.getElementById('statsRow').innerHTML = `
|
||||||
|
<div class="stat-card"><div class="stat-value">${allTickets.length}</div><div class="stat-label">Tickets</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-value">${new Set(allTickets.map(t=>t.status)).size}</div><div class="stat-label">Statuts</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-value">${unassigned}</div><div class="stat-label">Non assignés</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-value" style="color:${overdue>0?'var(--danger)':'var(--accent)'}">${overdue}</div><div class="stat-label">En retard</div></div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) { if(!s) return ''; const d=document.createElement('div'); d.textContent=String(s); return d.innerHTML; }
|
||||||
|
function fmtDate(d) { if(!d) return '<span class="empty-cell">-</span>'; try { return new Date(d).toLocaleDateString('fr-FR',{day:'2-digit',month:'short',year:'numeric'}); } catch { return escHtml(d); } }
|
||||||
|
function getStatusClass(s) { if(!s) return 's-other'; const l=s.toLowerCase(); if(l.includes('done')||l.includes('ferm')||l.includes('résolu')) return 's-done'; if(l.includes('progress')||l.includes('en cours')||l.includes('review')) return 's-progress'; if(l.includes('to do')||l.includes('open')||l.includes('backlog')) return 's-todo'; if(l.includes('block')) return 's-blocked'; return 's-other'; }
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
let goLiveColName = null;
|
||||||
|
if (allTickets.length > 0) {
|
||||||
|
goLiveColName = Object.keys(allTickets[0]).find(key =>
|
||||||
|
!['key','url','summary','description','status','statusCategory','assignee','reporter','priority','issueType','created','updated','dueDate','resolution','resolutionDate','labels','components','fixVersions','projectName'].includes(key)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapping pour faire le lien entre le texte de l'en-tête et la propriété de l'objet ticket
|
||||||
|
const colMapping = {
|
||||||
|
'Ticket': 'key',
|
||||||
|
'Summary': 'summary',
|
||||||
|
'Statut': 'status',
|
||||||
|
'Assigné': 'assignee',
|
||||||
|
'Priorité': 'priority',
|
||||||
|
'Type': 'issueType',
|
||||||
|
'Due Date': 'dueDate'
|
||||||
|
};
|
||||||
|
|
||||||
|
const cols = ['Ticket','Summary','Statut','Assigné','Priorité','Type','Due Date'];
|
||||||
|
if (goLiveColName) {
|
||||||
|
cols.push(goLiveColName);
|
||||||
|
colMapping[goLiveColName] = goLiveColName; // Ajout dynamique du champ personnalisé
|
||||||
|
}
|
||||||
|
cols.push('Actions');
|
||||||
|
|
||||||
|
// Génération des en-têtes avec gestion du clic pour le tri
|
||||||
|
document.getElementById('tableHead').innerHTML = '<tr>' + cols.map(c => {
|
||||||
|
const key = colMapping[c];
|
||||||
|
if (!key) return `<th>${c}</th>`; // Pour la colonne 'Actions'
|
||||||
|
|
||||||
|
const icon = currentSortCol === key ? (isAsc ? ' ▴' : ' ▾') : '';
|
||||||
|
return `<th onclick="sortTable('${key}')" style="cursor:pointer" class="sortable-th">
|
||||||
|
${c}${icon}
|
||||||
|
</th>`;
|
||||||
|
}).join('') + '</tr>';
|
||||||
|
|
||||||
|
let body = '';
|
||||||
|
filteredTickets.forEach(t => {
|
||||||
|
const dueDateValue = t.dueDate ? t.dueDate.substring(0, 10) : '';
|
||||||
|
body += `<tr>
|
||||||
|
<td><span class="ticket-key" onclick="window.open('${t.url}', '_blank')">${escHtml(t.key)}</span></td>
|
||||||
|
<td title="${escHtml(t.summary)}">${escHtml(t.summary)}</td>
|
||||||
|
<td><span class="status-badge ${getStatusClass(t.status)}">${escHtml(t.status)}</span></td>
|
||||||
|
<td>${escHtml(t.assignee) || '-'}</td>
|
||||||
|
<td>${escHtml(t.priority)}</td>
|
||||||
|
<td>${escHtml(t.issueType)}</td>
|
||||||
|
<td><input type="date" class="date-input" id="due-${t.key}" value="${dueDateValue}" onchange="enableUpdateBtn('${t.key}')"></td>`;
|
||||||
|
|
||||||
|
if (goLiveColName) {
|
||||||
|
const glVal = t[goLiveColName] ? t[goLiveColName].substring(0, 10) : '';
|
||||||
|
body += `<td><input type="date" class="date-input" id="gl-${t.key}" value="${glVal}" onchange="enableUpdateBtn('${t.key}')"></td>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
body += `<td>
|
||||||
|
<button class="btn-update" id="update-${t.key}" data-ticket="${t.key}" onclick="handleUpdateClick('${t.key}', '${goLiveColName || ''}')" disabled>
|
||||||
|
<i class="fa-solid fa-rotate"></i> Update
|
||||||
|
</button>
|
||||||
|
</td></tr>`;
|
||||||
|
});
|
||||||
|
document.getElementById('tableBody').innerHTML = body || '<tr><td colspan="10">Aucun résultat</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusColor(status) {
|
||||||
|
const s = (status || '').toLowerCase();
|
||||||
|
if (s.includes('done') || s.includes('ferm') || s.includes('rsolu')) return '#00e68a';
|
||||||
|
if (s.includes('progress') || s.includes('en cours') || s.includes('review')) return '#4dc8ff';
|
||||||
|
if (s.includes('to do') || s.includes('open') || s.includes('backlog')) return '#ffb84d';
|
||||||
|
if (s.includes('block')) return '#ff4d6a';
|
||||||
|
return '#7a9488';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ROADMAP (GANTT) ---
|
||||||
|
function setGanttView(view) {
|
||||||
|
currentGanttView = view;
|
||||||
|
renderRoadmap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FONCTIONS UTILITAIRES POUR LE GANTT ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrait le titre court et le type selon le pattern : "Titre - Type - Montant - ID"
|
||||||
|
*/
|
||||||
|
function parseTicketSummary(summary) {
|
||||||
|
if (!summary) return { shortTitle: 'Sans titre', type: 'OTHER' };
|
||||||
|
const parts = summary.split(' - ');
|
||||||
|
const shortTitle = parts[0].trim();
|
||||||
|
const typePart = parts[1] ? parts[1].trim().toUpperCase() : 'OTHER';
|
||||||
|
|
||||||
|
// On valide si c'est un type connu, sinon OTHER
|
||||||
|
const type = ['PR', 'CR', 'TR'].includes(typePart) ? typePart : 'OTHER';
|
||||||
|
return { shortTitle, type };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Définit la couleur de la barre selon le type de projet
|
||||||
|
*/
|
||||||
|
function getTypeColor(type) {
|
||||||
|
switch(type) {
|
||||||
|
case 'PR': return '#4dc8ff'; // Bleu info
|
||||||
|
case 'CR': return '#ffb84d'; // Orange warning
|
||||||
|
case 'TR': return '#a371f7'; // Violet
|
||||||
|
default: return '#7a9488'; // Gris par défaut
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FONCTION PRINCIPALE : RENDER ROADMAP
|
||||||
|
*/
|
||||||
|
function renderRoadmap() {
|
||||||
|
const preview = document.getElementById('roadmapPreview');
|
||||||
|
if (!filteredTickets || filteredTickets.length === 0) {
|
||||||
|
preview.innerHTML = `<div style="text-align:center; padding:60px; color:var(--fg-dim);">Aucun ticket à afficher.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Détection dynamique de la colonne Go Live
|
||||||
|
const goLiveColName = Object.keys(filteredTickets[0]).find(k => k.toLowerCase().includes('go live')) || "Go Live Date";
|
||||||
|
|
||||||
|
// Configuration des largeurs
|
||||||
|
const leftWidth = 280; // Un peu plus large pour les titres split 0
|
||||||
|
let colWidth = 140;
|
||||||
|
if (currentGanttView === 'quarter') colWidth = 200;
|
||||||
|
if (currentGanttView === 'year') colWidth = 250;
|
||||||
|
|
||||||
|
// 1. Filtrer les tickets : on ne garde que ceux qui ont au moins une date
|
||||||
|
const ticketsWithDates = filteredTickets.filter(t => {
|
||||||
|
const d1 = t.dueDate ? new Date(t.dueDate) : null;
|
||||||
|
const d2 = t[goLiveColName] ? new Date(t[goLiveColName]) : null;
|
||||||
|
return (d1 && !isNaN(d1.getTime())) || (d2 && !isNaN(d2.getTime()));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ticketsWithDates.length === 0) {
|
||||||
|
preview.innerHTML = `<div style="padding:40px; text-align:center; color:var(--fg-dim);">Aucune date de Due Date ou Go Live renseignée pour les tickets filtrés.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcul des bornes du calendrier
|
||||||
|
const allDates = ticketsWithDates.flatMap(t => [
|
||||||
|
t.dueDate ? new Date(t.dueDate) : null,
|
||||||
|
t[goLiveColName] ? new Date(t[goLiveColName]) : null
|
||||||
|
].filter(d => d !== null));
|
||||||
|
|
||||||
|
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() + 2); // Un peu de marge au bout
|
||||||
|
maxDate.setDate(0);
|
||||||
|
|
||||||
|
// Construction de l'échelle de temps
|
||||||
|
const timeScale = [];
|
||||||
|
let tempDate = new Date(minDate);
|
||||||
|
while (tempDate <= maxDate) {
|
||||||
|
let label = "", key = "";
|
||||||
|
if (currentGanttView === 'month') {
|
||||||
|
label = tempDate.toLocaleDateString('fr-FR', { month: 'short', year: 'numeric' });
|
||||||
|
key = `${tempDate.getFullYear()}-${tempDate.getMonth()}`;
|
||||||
|
tempDate.setMonth(tempDate.getMonth() + 1);
|
||||||
|
} else if (currentGanttView === 'quarter') {
|
||||||
|
const q = Math.floor(tempDate.getMonth() / 3) + 1;
|
||||||
|
label = `Q${q} ${tempDate.getFullYear()}`;
|
||||||
|
key = `${tempDate.getFullYear()}-Q${q}`;
|
||||||
|
tempDate.setMonth(tempDate.getMonth() + 3);
|
||||||
|
} else if (currentGanttView === 'year') {
|
||||||
|
label = `${tempDate.getFullYear()}`;
|
||||||
|
key = `${tempDate.getFullYear()}`;
|
||||||
|
tempDate.setFullYear(tempDate.getFullYear() + 1);
|
||||||
|
}
|
||||||
|
timeScale.push({ key, label });
|
||||||
|
}
|
||||||
|
|
||||||
|
const getXPos = (date) => {
|
||||||
|
if (!date) return 0;
|
||||||
|
const d = new Date(date);
|
||||||
|
let index = -1;
|
||||||
|
if (currentGanttView === 'month') index = timeScale.findIndex(m => m.key === `${d.getFullYear()}-${d.getMonth()}`);
|
||||||
|
else if (currentGanttView === 'quarter') index = timeScale.findIndex(m => m.key === `${d.getFullYear()}-Q${Math.floor(d.getMonth() / 3) + 1}`);
|
||||||
|
else if (currentGanttView === 'year') index = timeScale.findIndex(m => m.key === `${d.getFullYear()}`);
|
||||||
|
|
||||||
|
if (index === -1) return 0;
|
||||||
|
|
||||||
|
let ratio = 0;
|
||||||
|
if (currentGanttView === 'month') ratio = (d.getDate() - 1) / 30;
|
||||||
|
else if (currentGanttView === 'quarter') ratio = ((d.getMonth() % 3) * 30 + d.getDate()) / 90;
|
||||||
|
else if (currentGanttView === 'year') ratio = (d.getMonth() * 30 + d.getDate()) / 365;
|
||||||
|
|
||||||
|
return leftWidth + (index * colWidth) + (ratio * colWidth);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Groupement par assigné (uniquement ceux qui ont des tickets visibles)
|
||||||
|
const grouped = ticketsWithDates.reduce((acc, t) => {
|
||||||
|
const name = t.assignee || "Non assigné";
|
||||||
|
if (!acc[name]) acc[name] = [];
|
||||||
|
acc[name].push(t);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// Début HTML
|
||||||
|
let html = `<div class="gantt-container"><div class="gantt-canvas" style="width: ${leftWidth + (timeScale.length * colWidth)}px;">`;
|
||||||
|
|
||||||
|
// Ligne "Aujourd'hui"
|
||||||
|
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;">Aujourd'hui</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header
|
||||||
|
html += `<div class="gantt-header"><div class="gantt-sticky-col gantt-group-name" style="width:${leftWidth}px">Assigné / Ticket (Titre)</div>`;
|
||||||
|
timeScale.forEach(ts => { html += `<div class="gantt-month-header" style="width:${colWidth}px;">${ts.label}</div>`; });
|
||||||
|
html += `</div>`;
|
||||||
|
|
||||||
|
// Corps du Gantt
|
||||||
|
Object.keys(grouped).sort().forEach(assignee => {
|
||||||
|
// Header de groupe
|
||||||
|
html += `<div class="gantt-group-header">
|
||||||
|
<div class="gantt-sticky-col gantt-group-name" style="width:${leftWidth}px">
|
||||||
|
<i class="fa-solid fa-user-circle mr-8"></i>${assignee}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const sortedTickets = grouped[assignee].sort((a, b) => (new Date(a.dueDate || 0)) - (new Date(b.dueDate || 0)));
|
||||||
|
|
||||||
|
sortedTickets.forEach(t => {
|
||||||
|
const dDue = t.dueDate ? new Date(t.dueDate) : null;
|
||||||
|
const dLive = t[goLiveColName] ? new Date(t[goLiveColName]) : null;
|
||||||
|
|
||||||
|
const start = dDue || dLive;
|
||||||
|
const end = dLive || dDue;
|
||||||
|
const x1 = getXPos(start);
|
||||||
|
const x2 = getXPos(end);
|
||||||
|
|
||||||
|
// Largeur de barre (minimum 150px pour afficher le badge de statut et le titre proprement)
|
||||||
|
const barWidth = Math.max(150, (x2 - x1) + 10);
|
||||||
|
|
||||||
|
const { shortTitle, type } = parseTicketSummary(t.summary);
|
||||||
|
const typeColor = getTypeColor(type);
|
||||||
|
const statusColor = getStatusColor(t.status);
|
||||||
|
|
||||||
|
html += `<div class="gantt-row">
|
||||||
|
<div class="gantt-sticky-col gantt-ticket-id" style="width:${leftWidth}px; font-size:11px;" title="${t.key}: ${t.summary}">
|
||||||
|
${escHtml(shortTitle)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="gantt-bar"
|
||||||
|
onclick="window.open('${t.url}', '_blank')"
|
||||||
|
style="left:${x1}px; width:${barWidth}px; background:${typeColor}22; border-left:4px solid ${typeColor}; cursor:pointer; display:flex; align-items:center; gap:6px; padding:0 8px;">
|
||||||
|
|
||||||
|
<div style="background:${statusColor}; color:#fff; font-size:9px; padding:2px 6px; border-radius:4px; font-weight:bold; text-transform:uppercase; white-space:nowrap; box-shadow: 0 2px 4px rgba(0,0,0,0.2);">
|
||||||
|
${escHtml(t.status)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span style="color:${typeColor}; font-weight:bold; flex-shrink:0;">${type}</span>
|
||||||
|
<span style="white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-size:12px;">${escHtml(shortTitle)}</span>
|
||||||
|
</div>
|
||||||
|
${timeScale.map(() => `<div class="gantt-grid-col" style="width:${colWidth}px;"></div>`).join('')}
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
html += `</div></div>`;
|
||||||
|
preview.innerHTML = html;
|
||||||
|
|
||||||
|
// Focus sur la date du jour
|
||||||
|
setTimeout(() => {
|
||||||
|
const m = document.getElementById('todayMarker');
|
||||||
|
if (m) m.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- TAB & FILTER ---
|
||||||
|
function switchTab(id, btn) {
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
|
||||||
|
btn.classList.add('active'); document.getElementById(`panel-${id}`).classList.add('active');
|
||||||
|
if(id === 'roadmap') renderRoadmap();
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterTable() {
|
||||||
|
const q = document.getElementById('searchInput').value.toLowerCase();
|
||||||
|
filteredTickets = q ? allTickets.filter(t => Object.values(t).some(v => v && String(v).toLowerCase().includes(q))) : [...allTickets];
|
||||||
|
renderTable();
|
||||||
|
if(document.getElementById('panel-roadmap').classList.contains('active')) renderRoadmap();
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportDrawIOXML(mode) {
|
||||||
|
try {
|
||||||
|
// 1. Sécurité : Vérification des données
|
||||||
|
if (!filteredTickets || filteredTickets.length === 0) {
|
||||||
|
showToast("Aucun ticket à exporter", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const goLiveCol = Object.keys(filteredTickets[0]).find(k => k.toLowerCase().includes('go live')) || "dueDate";
|
||||||
|
const ticketsWithDates = filteredTickets.filter(t => t.dueDate || t[goLiveCol]);
|
||||||
|
|
||||||
|
if (ticketsWithDates.length === 0) {
|
||||||
|
showToast("Aucune date de fin trouvée", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Paramètres de vue (Zoom)
|
||||||
|
const leftWidth = 250;
|
||||||
|
const rowHeight = 50;
|
||||||
|
let pixelPerDay = 12; // Mois
|
||||||
|
if (currentGanttView === 'quarter') pixelPerDay = 4;
|
||||||
|
if (currentGanttView === 'year') pixelPerDay = 1.2;
|
||||||
|
|
||||||
|
// 3. Calcul des bornes temporelles
|
||||||
|
const allDates = ticketsWithDates.map(t => new Date(t.dueDate || t[goLiveCol]));
|
||||||
|
let minDate = new Date(Math.min(...allDates));
|
||||||
|
minDate.setDate(1);
|
||||||
|
let maxDate = new Date(Math.max(...allDates));
|
||||||
|
maxDate.setMonth(maxDate.getMonth() + 4);
|
||||||
|
|
||||||
|
const getX = (dateStr) => {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
if (isNaN(d.getTime())) return leftWidth;
|
||||||
|
return Math.floor(leftWidth + ((d - minDate) / 86400000 * pixelPerDay));
|
||||||
|
};
|
||||||
|
|
||||||
|
let xmlNodes = '';
|
||||||
|
|
||||||
|
// 4. TIMELINE & LIGNES VERTICALES
|
||||||
|
let curr = new Date(minDate);
|
||||||
|
while (curr < maxDate) {
|
||||||
|
const xPos = getX(curr);
|
||||||
|
let label = "";
|
||||||
|
|
||||||
|
if (currentGanttView === 'month') {
|
||||||
|
label = curr.toLocaleDateString('fr-FR', { month: 'short', year: 'numeric' }).toUpperCase();
|
||||||
|
curr.setMonth(curr.getMonth() + 1);
|
||||||
|
} else if (currentGanttView === 'quarter') {
|
||||||
|
label = "Q" + (Math.floor(curr.getMonth() / 3) + 1) + " " + curr.getFullYear();
|
||||||
|
curr.setMonth(curr.getMonth() + 3);
|
||||||
|
} else {
|
||||||
|
label = curr.getFullYear();
|
||||||
|
curr.setFullYear(curr.getFullYear() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Titres Timeline agrandis (fontSize=14)
|
||||||
|
xmlNodes += `<mxCell id="m_${curr.getTime()}" value="${label}" style="text;fontColor=#7a9488;fontSize=14;fontStyle=1;align=left;verticalAlign=middle" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="${xPos + 5}" y="5" width="150" height="30" as="geometry"/>
|
||||||
|
</mxCell>`;
|
||||||
|
|
||||||
|
// Lignes verticales de repère
|
||||||
|
xmlNodes += `<mxCell id="l_${curr.getTime()}" value="" style="line;strokeColor=#E0E0E0;direction=south;html=1;opacity=20" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="${xPos}" y="0" width="10" height="4000" as="geometry"/>
|
||||||
|
</mxCell>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. LIGNE TODAY
|
||||||
|
const xToday = getX(new Date());
|
||||||
|
xmlNodes += `<mxCell id="today" value="AUJOURD'HUI" style="line;strokeColor=#FF0000;direction=south;html=1;dashed=1;strokeWidth=2;fontColor=#FF0000;fontSize=12;fontStyle=1;verticalAlign=top" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="${xToday}" y="0" width="10" height="4000" as="geometry"/>
|
||||||
|
</mxCell>`;
|
||||||
|
|
||||||
|
// 6. GÉNÉRATION DES TICKETS
|
||||||
|
let currentY = 80;
|
||||||
|
const groups = ticketsWithDates.reduce((acc, t) => {
|
||||||
|
const a = t.assignee || 'Non assigné';
|
||||||
|
if (!acc[a]) acc[a] = [];
|
||||||
|
acc[a].push(t);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
Object.keys(groups).sort().forEach(assignee => {
|
||||||
|
// Header Assigné
|
||||||
|
xmlNodes += `<mxCell id="as_${currentY}" value="${assignee.toUpperCase()}" style="text;fontColor=#00e68a;fontStyle=1;fontSize=14" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="10" y="${currentY}" width="200" height="30" as="geometry"/>
|
||||||
|
</mxCell>`;
|
||||||
|
currentY += 40;
|
||||||
|
|
||||||
|
groups[assignee].forEach(t => {
|
||||||
|
const { shortTitle, type } = parseTicketSummary(t.summary);
|
||||||
|
const color = getTypeColor(type);
|
||||||
|
|
||||||
|
// Durée réelle (Création -> DueDate)
|
||||||
|
const startDate = t.created ? new Date(t.created) : minDate;
|
||||||
|
const endDate = new Date(t.dueDate || t[goLiveCol]);
|
||||||
|
|
||||||
|
const xStart = getX(startDate);
|
||||||
|
const xEnd = getX(endDate);
|
||||||
|
let barWidth = xEnd - xStart;
|
||||||
|
if (barWidth < 50) barWidth = 140;
|
||||||
|
|
||||||
|
// Label de gauche
|
||||||
|
xmlNodes += `<mxCell id="lbl_${currentY}" value="${escHtml(shortTitle)}" style="text;fontColor=#555555;fontSize=10;align=left;whiteSpace=wrap" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="30" y="${currentY}" width="200" height="30" as="geometry"/>
|
||||||
|
</mxCell>`;
|
||||||
|
|
||||||
|
// Barre de Gantt
|
||||||
|
const label = `[${type}] ${escHtml(shortTitle)}`;
|
||||||
|
xmlNodes += `<mxCell id="bar_${currentY}" value="${label}" style="rounded=1;fillColor=${color};strokeColor=#FFFFFF;fontColor=#000000;fontSize=10;fontStyle=1;spacingLeft=8;align=left" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="${xStart}" y="${currentY}" width="${barWidth}" height="28" as="geometry"/>
|
||||||
|
</mxCell>`;
|
||||||
|
|
||||||
|
currentY += rowHeight;
|
||||||
|
});
|
||||||
|
currentY += 20;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. ASSEMBLAGE FINAL
|
||||||
|
const finalXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<mxfile host="app.diagrams.net">
|
||||||
|
<diagram id="Jira" name="Roadmap">
|
||||||
|
<mxGraphModel dx="1000" dy="1000" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="0" background="none">
|
||||||
|
<root>
|
||||||
|
<mxCell id="0" />
|
||||||
|
<mxCell id="1" parent="0" />
|
||||||
|
${xmlNodes}
|
||||||
|
</root>
|
||||||
|
</mxGraphModel>
|
||||||
|
</diagram>
|
||||||
|
</mxfile>`;
|
||||||
|
|
||||||
|
if (mode === 'download') {
|
||||||
|
const blob = new Blob([finalXml], { type: 'text/xml' });
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = "jira_export.drawio";
|
||||||
|
a.click();
|
||||||
|
} else {
|
||||||
|
navigator.clipboard.writeText(finalXml).then(() => showToast("XML Copié !"));
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
showToast("Erreur lors de l'export XML", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderJSON() { document.getElementById('jsonViewer').innerHTML = syntaxHL(JSON.stringify(allTickets, null, 2)); }
|
||||||
|
function syntaxHL(json) {
|
||||||
|
json = json.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
return json.replace(/("(\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, m => {
|
||||||
|
let c = 'json-number'; if(/^"/.test(m)) c = /:$/.test(m)?'json-key':'json-string'; else if(/true|false/.test(m)) c='json-bool'; else if(/null/.test(m)) c='json-null';
|
||||||
|
return `<span class="${c}">${m}</span>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadJSON() {
|
||||||
|
const blob = new Blob([JSON.stringify(allTickets, null, 2)], {type:'application/json'});
|
||||||
|
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = `export-${Date.now()}.json`; a.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyJSON() { navigator.clipboard.writeText(JSON.stringify(allTickets, null, 2)).then(() => showToast('Copié !')); }
|
||||||
|
|
||||||
|
function clearResults() { allTickets=[]; filteredTickets=[]; document.getElementById('results-section').classList.remove('visible'); }
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => { if((e.ctrlKey||e.metaKey)&&e.key==='Enter'){e.preventDefault();startExtraction()} });
|
||||||
|
// Cet événement se déclenche quand le navigateur a fini de charger le HTML
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadSettings();
|
||||||
|
console.log("Paramètres chargés depuis le stockage local.");
|
||||||
|
});
|
||||||
@@ -2,7 +2,12 @@ import fs from 'fs';
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import https from 'https';
|
import https from 'https';
|
||||||
|
import path from 'path';
|
||||||
import { HttpsProxyAgent } from 'https-proxy-agent'; // <-- AJOUT
|
import { HttpsProxyAgent } from 'https-proxy-agent'; // <-- AJOUT
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Lecture du fichier config.ini (inchangé)
|
// Lecture du fichier config.ini (inchangé)
|
||||||
@@ -81,10 +86,26 @@ jiraClient.interceptors.response.use(
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// Serveur Express
|
// Serveur Express
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
// const app = express();
|
||||||
|
//app.use(express.json({ limit: '10mb' }));
|
||||||
|
|
||||||
|
//app.use(express.static('.')); // Sert index.html à /
|
||||||
|
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
// 1. Middlewares
|
||||||
app.use(express.json({ limit: '10mb' }));
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
|
||||||
app.use(express.static('.')); // Sert index.html à /
|
// 2. Fichiers statiques (CSS, JS client, Images)
|
||||||
|
// On ne sert QUE le contenu du dossier public
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
// 3. Routes HTML
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
// On va chercher l'index dans /views/
|
||||||
|
res.sendFile(path.join(__dirname, 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
// Endpoint d'extraction
|
// Endpoint d'extraction
|
||||||
|
|||||||
Reference in New Issue
Block a user