patchcenter/app/templates/patching_import.html
Admin MPCZ 69ea7aa09a fix(pct/eml): bouton 'Telecharger .eml' via delegation document-level
Bug: le handler etait attache via getElementById('pct-btn-eml') au moment du <script>
mais la modal HTML est plus bas dans le DOM -> element pas encore parse -> bind silencieux KO.

Fix: utilise document.addEventListener('click', ...) avec delegation
(closest('button') + check sur t.id). Marche meme si la modal est ajoutee/retiree.
Inclus aussi pct-btn-cancel et pct-modal-bg dans la delegation pour coherence.
2026-05-07 22:40:41 +02:00

657 lines
35 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends 'base.html' %}
{% block title %}Importer planning patching{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div>
<h2 class="text-xl font-bold text-cyber-accent">Importer planning de patching</h2>
<p class="text-xs text-gray-500 mt-1">
Upload du fichier Excel "Plan de Patching serveurs YYYY". Une feuille = une semaine (S02..S52).
Les onglets historiques (Histo-XXXX) sont ignorés.
</p>
</div>
{% if current_import %}
<a href="/patching/import" class="btn-sm bg-cyber-border text-cyber-accent px-4 py-2">← Liste imports</a>
{% endif %}
</div>
{% if msg == 'ok' %}<div class="bg-cyber-green/20 text-cyber-green p-2 mb-3 text-xs rounded">Import réussi.</div>{% endif %}
{% if msg == 'deleted' %}<div class="bg-cyber-blue/20 text-cyber-blue p-2 mb-3 text-xs rounded">Import supprimé.</div>{% endif %}
{% if err == 'ext' %}<div class="bg-cyber-red/20 text-cyber-red p-2 mb-3 text-xs rounded">Le fichier doit être un .xlsx.</div>{% endif %}
{% if err == 'parse' %}<div class="bg-cyber-red/20 text-cyber-red p-2 mb-3 text-xs rounded">Impossible de parser le fichier.</div>{% endif %}
{% if err == 'denied' %}<div class="bg-cyber-red/20 text-cyber-red p-2 mb-3 text-xs rounded">Permission refusée.</div>{% endif %}
{% if err == 'notfound' %}<div class="bg-cyber-red/20 text-cyber-red p-2 mb-3 text-xs rounded">Import introuvable.</div>{% endif %}
{% if err == 'openpyxl_missing' %}<div class="bg-cyber-red/20 text-cyber-red p-2 mb-3 text-xs rounded">Lib openpyxl manquante côté serveur.</div>{% endif %}
{# ──────────── Upload form ──────────── #}
{% if can_import %}
<div class="card p-4 mb-4">
<h3 class="text-sm font-bold text-cyber-accent mb-2">Nouvel import</h3>
<form method="POST" action="/patching/import/upload" enctype="multipart/form-data" class="flex flex-wrap gap-2 items-center">
<input type="file" name="file" accept=".xlsx" required class="text-xs">
<input type="text" name="note" placeholder="Note (optionnelle)" class="text-xs px-2 py-1 flex-1 min-w-[200px]">
<button type="submit" class="btn-primary px-3 py-1 text-xs">Importer</button>
</form>
</div>
{% endif %}
{# ──────────── Liste des imports ──────────── #}
<div class="card p-3 mb-4">
<h3 class="text-sm font-bold text-cyber-accent mb-2">Imports récents ({{ imports|length }})</h3>
{% if imports %}
<table class="w-full text-xs">
<thead class="text-cyber-accent border-b border-cyber-border">
<tr>
<th class="text-left p-1">ID</th>
<th class="text-left p-1">Fichier</th>
<th class="text-left p-1">Année</th>
<th class="text-right p-1">Feuilles</th>
<th class="text-right p-1">Lignes</th>
<th class="text-left p-1">Date</th>
<th class="text-left p-1">Par</th>
<th class="text-right p-1">Actions</th>
</tr>
</thead>
<tbody>
{% for i in imports %}
<tr class="border-b border-cyber-border/30 {% if current_import and current_import.id == i.id %}bg-cyber-border/30{% endif %}">
<td class="p-1">#{{ i.id }}</td>
<td class="p-1"><a href="/patching/import/{{ i.id }}" class="text-cyber-accent hover:underline">{{ i.filename }}</a></td>
<td class="p-1">{{ i.year or '' }}</td>
<td class="p-1 text-right">{{ i.sheet_count }}</td>
<td class="p-1 text-right">{{ i.row_count }}</td>
<td class="p-1">{{ i.uploaded_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td class="p-1">{{ i.uploaded_by_name or '' }}</td>
<td class="p-1 text-right">
<a href="/patching/import/{{ i.id }}" class="text-cyber-blue hover:underline">Voir</a>
{% if can_import %}
· <form method="POST" action="/patching/import/{{ i.id }}/delete" class="inline" onsubmit="return confirm('Supprimer cet import ?')">
<button type="submit" class="text-cyber-red hover:underline">Suppr</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-xs text-gray-500">Aucun import pour le moment.</p>
{% endif %}
</div>
{# ──────────── Détail de l'import courant ──────────── #}
{% if current_import %}
<div class="card p-4 mb-4">
<div class="flex justify-between items-start mb-3">
<div>
<h3 class="text-sm font-bold text-cyber-accent">Import #{{ current_import.id }} : {{ current_import.filename }}</h3>
<p class="text-xs text-gray-500 mt-1">
{{ current_import.sheet_count }} feuilles · {{ current_import.row_count }} lignes ·
{{ current_import.uploaded_at.strftime('%Y-%m-%d %H:%M') }}
{% if current_import.note %} · <em>{{ current_import.note }}</em>{% endif %}
</p>
</div>
</div>
{# Sélecteur de semaine #}
<div class="flex gap-2 items-center mb-3 flex-wrap">
<label class="text-xs text-gray-400">Semaine :</label>
<select id="sheet-select" class="text-xs px-2 py-1">
<option value="">— Choisir —</option>
{% for s in sheets %}
<option value="{{ s.sheet_name }}">{{ s.sheet_name }} (S{{ '%02d' % s.week_number }}) — {{ s.nb }} serveur(s)</option>
{% endfor %}
</select>
<span id="sheet-summary" class="text-xs text-gray-500"></span>
</div>
{# Tableau dynamique #}
<div id="sheet-table-wrap" class="overflow-x-auto" style="display:none;">
{# Filtres client-side #}
<div class="flex gap-2 items-center mb-2 flex-wrap">
<select id="filter-intervenant" class="text-xs px-2 py-1">
<option value="">— Tous intervenants —</option>
</select>
<select id="filter-env" class="text-xs px-2 py-1">
<option value="">— Tous environnements —</option>
</select>
<button id="filter-reset" class="text-xs text-gray-500 hover:text-cyber-accent">Reset</button>
<span class="text-xs text-gray-400" id="filter-count"></span>
</div>
<div class="flex gap-2 items-center mb-2 flex-wrap">
<label class="text-xs">
<input type="checkbox" id="select-all" class="mr-1"> Tout sélectionner (visibles)
</label>
<span class="text-xs text-gray-400" id="selection-count">0 sélectionné(s)</span>
<div class="flex-1"></div>
<style>
.btn-action {
padding: 6px 14px; font-size: 0.8rem; font-weight: 700;
border-radius: 6px; cursor: pointer; border: 1px solid currentColor;
box-shadow: 0 0 8px currentColor;
transition: all .15s;
text-transform: uppercase; letter-spacing: 0.03em;
}
.btn-action:hover { transform: translateY(-1px); box-shadow: 0 0 14px currentColor; }
.btn-action.btn-add { background: rgba(34,197,94,.15); color: #22c55e; }
.btn-action.btn-add:hover { background: rgba(34,197,94,.30); }
.btn-action.btn-rep { background: rgba(59,130,246,.15); color: #3b82f6; }
.btn-action.btn-rep:hover { background: rgba(59,130,246,.30); }
.btn-action.btn-cancel { background: rgba(156,163,175,.15); color: #9ca3af; }
.btn-action.btn-cancel:hover { background: rgba(156,163,175,.30); color: #f3f4f6; }
.btn-action.btn-pre { background: rgba(245,158,11,.18); color: #f59e0b; }
.btn-action.btn-pre:hover { background: rgba(245,158,11,.35); }
</style>
{% if can_import %}
<button id="btn-add-eligible" class="btn-action btn-add" title="Marque les lignes sélectionnées comme éligibles au patching">
+ Ajouter au patching
</button>
<button id="btn-report" class="btn-action btn-rep" title="Reporter les lignes sélectionnées à une autre semaine">
⤳ Reporter
</button>
<button id="btn-unset" class="btn-action btn-cancel" title="Annuler éligibilité / report sur les lignes sélectionnées">
✕ Annuler
</button>
{% endif %}
<button id="btn-prepatch" class="btn-action btn-pre" title="Lancer le pré-patching (rows éligibles uniquement)">
▶ Pré-patching
</button>
{% if can_import %}
<button id="btn-pct-mail" type="button" style="
padding: 6px 14px; font-size: 0.8rem; font-weight: 700;
border-radius: 6px; cursor: pointer; border: 1px solid #a78bfa;
background: rgba(167,139,250,.15); color: #a78bfa;
box-shadow: 0 0 8px #a78bfa; text-transform: uppercase;
letter-spacing: 0.03em;"
title="Envoie un mail à PCT.reims@sanef.com pour annoncer l'intervention sur les serveurs sélectionnés (preview avant envoi)">
📧 Prévenance PCT
</button>
{% endif %}
</div>
<table class="w-full text-xs" id="sheet-table">
<thead class="text-cyber-accent border-b border-cyber-border">
<tr>
<th class="text-left p-1 w-6"><input type="checkbox" id="select-all-head"></th>
<th class="text-left p-1">État</th>
<th class="text-left p-1 cursor-pointer select-none hover:text-cyber-accent" id="th-asset" title="Cliquer pour trier">
Asset <span id="th-asset-arrow" class="text-[10px] opacity-50"></span>
</th>
<th class="text-left p-1">Env</th>
<th class="text-left p-1">Domaine</th>
<th class="text-left p-1">OS</th>
<th class="text-left p-1">Version OS</th>
<th class="text-left p-1">Application</th>
<th class="text-left p-1">Intervenant</th>
<th class="text-left p-1">Valideur RA</th>
<th class="text-left p-1">Resp. Domaine DTS</th>
<th class="text-left p-1">Référent tech.</th>
<th class="text-left p-1 cursor-pointer select-none hover:text-cyber-accent" id="th-date" title="Cliquer pour trier par date+heure">
Date <span id="th-date-arrow" class="text-[10px] opacity-50"></span>
</th>
<th class="text-left p-1">Heure</th>
</tr>
</thead>
<tbody id="sheet-table-body"></tbody>
</table>
</div>
<div id="sheet-empty" class="text-xs text-gray-500" style="display:none;">Aucune ligne pour cette feuille.</div>
</div>
<script>
(function(){
const importId = {{ current_import.id }};
const sel = document.getElementById('sheet-select');
const wrap = document.getElementById('sheet-table-wrap');
const empty = document.getElementById('sheet-empty');
const tbody = document.getElementById('sheet-table-body');
const summary = document.getElementById('sheet-summary');
const selAll = document.getElementById('select-all');
const selAllHead = document.getElementById('select-all-head');
const selCount = document.getElementById('selection-count');
const btnPre = document.getElementById('btn-prepatch');
const btnAddElig = document.getElementById('btn-add-eligible');
const btnReport = document.getElementById('btn-report');
const btnUnset = document.getElementById('btn-unset');
const fInter = document.getElementById('filter-intervenant');
const fEnv = document.getElementById('filter-env');
const fReset = document.getElementById('filter-reset');
const fCount = document.getElementById('filter-count');
const thAsset = document.getElementById('th-asset');
const thAssetArrow = document.getElementById('th-asset-arrow');
const thDate = document.getElementById('th-date');
const thDateArrow = document.getElementById('th-date-arrow');
let currentRows = [];
// sortKey ∈ {null, 'asset', 'date'} ; sortDir ∈ {1 asc, -1 desc}
let sortKey = null;
let sortDir = 1;
function escapeHTML(s){
if (s === null || s === undefined) return '';
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function dateFR(iso){
// 'YYYY-MM-DD' -> 'DD/MM/YYYY'
if (!iso) return '';
const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})/);
return m ? (m[3] + '/' + m[2] + '/' + m[1]) : iso;
}
function shortOSVersion(v){
if (!v) return '';
const s = String(v);
let m = s.match(/red\s*hat[^0-9]+(\d+)/i);
if (m) return 'RedHat ' + m[1];
m = s.match(/centos[^0-9]+(\d+)/i);
if (m) return 'CentOS ' + m[1];
m = s.match(/oracle\s*linux[^0-9]+(\d+)/i);
if (m) return 'Oracle ' + m[1];
m = s.match(/ubuntu[^0-9]+(\d+(?:\.\d+)?)/i);
if (m) return 'Ubuntu ' + m[1];
m = s.match(/debian[^0-9]+(\d+)/i);
if (m) return 'Debian ' + m[1];
m = s.match(/windows\s*server\s*(\d{4})/i);
if (m) return 'Win ' + m[1];
m = s.match(/windows\s*(\d+)/i);
if (m) return 'Win ' + m[1];
// Fallback : 30 premiers chars
return s.length > 30 ? s.slice(0, 30) + '…' : s;
}
function refreshSelection(){
const visible = tbody.querySelectorAll('tr:not(.row-hidden)');
const visibleCb = tbody.querySelectorAll('tr:not(.row-hidden) input.row-cb');
const checkedCb = Array.from(tbody.querySelectorAll('input.row-cb:checked'));
const checked = checkedCb.length;
const eligibleSelected = checkedCb.filter(cb => cb.dataset.eligible === '1').length;
selCount.textContent = checked + ' sélectionné(s) · ' + visible.length + ' visible(s)' + (eligibleSelected ? ' · ' + eligibleSelected + ' éligible(s)' : '');
const allVisibleChecked = visibleCb.length > 0 && Array.from(visibleCb).every(cb => cb.checked);
selAll.checked = allVisibleChecked;
selAllHead.checked = allVisibleChecked;
// Boutons toujours cliquables : si rien de sélectionné on alerte au clic
// (au lieu de griser un bouton qu'on ne peut pas atteindre).
// Atténuation visuelle quand pas de sélection pour donner un feedback :
const hasSel = checked > 0;
const dimIfEmpty = (btn, active) => {
if (!btn) return;
btn.classList.toggle('opacity-50', !active);
};
dimIfEmpty(btnAddElig, hasSel);
dimIfEmpty(btnReport, hasSel);
dimIfEmpty(btnUnset, hasSel);
dimIfEmpty(btnPre, eligibleSelected > 0);
}
function getSelectedRowIds(){
return Array.from(tbody.querySelectorAll('input.row-cb:checked')).map(cb => parseInt(cb.dataset.id, 10)).filter(x => x);
}
async function postAction(payload){
const r = await fetch('/patching/import/' + importId + '/rows/action', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify(payload),
});
const j = await r.json();
if (!j.ok) { alert('Erreur : ' + (j.msg || 'inconnue')); return false; }
return true;
}
async function reloadCurrentSheet(){
if (sel.value) await loadSheet(sel.value);
}
function applyFilters(){
// Comparaisons case-insensitive (Production == production, etc.)
const fi = (fInter.value || '').trim().toLowerCase();
const fe = (fEnv.value || '').trim().toLowerCase();
let visibleCount = 0;
tbody.querySelectorAll('tr').forEach(tr => {
const i = (tr.dataset.intervenant || '').toLowerCase();
const e = (tr.dataset.env || '').toLowerCase();
const ok = (!fi || i === fi) && (!fe || e === fe);
if (ok) { tr.classList.remove('row-hidden'); tr.style.display=''; visibleCount++; }
else { tr.classList.add('row-hidden'); tr.style.display='none'; }
});
fCount.textContent = visibleCount + ' / ' + currentRows.length + ' affichées';
refreshSelection();
}
function rebuildSelectOptions(sel, values, placeholder){
// Dedup case-insensitive : on garde la 1re forme rencontrée comme canonique
// (généralement la majuscule "Production" si elle apparaît avant "production")
const cur = sel.value;
const seen = new Map(); // lowercase -> canonical form
for (const v of values) {
if (!v) continue;
const k = v.toLowerCase();
if (!seen.has(k)) seen.set(k, v);
}
const opts = Array.from(seen.values()).sort((a,b) =>
a.localeCompare(b, 'fr', {sensitivity:'base'}));
sel.innerHTML = '<option value="">' + placeholder + '</option>'
+ opts.map(v => '<option value="' + escapeHTML(v) + '"' + (cur === v ? ' selected' : '') + '>' + escapeHTML(v) + '</option>').join('');
}
function updateSortArrow(){
const arrow = sortDir === 1 ? '▲' : '▼';
thAssetArrow.textContent = sortKey === 'asset' ? arrow : '↕';
thAssetArrow.classList.toggle('opacity-50', sortKey !== 'asset');
thDateArrow.textContent = sortKey === 'date' ? arrow : '↕';
thDateArrow.classList.toggle('opacity-50', sortKey !== 'date');
}
function cycleSort(key){
if (sortKey !== key) { sortKey = key; sortDir = 1; }
else if (sortDir === 1) { sortDir = -1; }
else { sortKey = null; sortDir = 1; }
}
async function loadSheet(name){
if (!name) { wrap.style.display='none'; empty.style.display='none'; return; }
summary.textContent = 'Chargement…';
const r = await fetch('/patching/import/' + importId + '/sheet/' + encodeURIComponent(name));
const j = await r.json();
if (!j.ok) { summary.textContent = j.msg || 'Erreur'; return; }
if (!j.rows.length) {
wrap.style.display='none'; empty.style.display=''; summary.textContent='';
return;
}
empty.style.display='none'; wrap.style.display='';
summary.textContent = j.count + ' lignes';
currentRows = j.rows;
rebuildSelectOptions(fInter, currentRows.map(r => r.intervenant), '— Tous intervenants —');
rebuildSelectOptions(fEnv, currentRows.map(r => r.environnement), '— Tous environnements —');
sortKey = null; sortDir = 1;
updateSortArrow();
renderTable();
}
function renderTable(){
let rows = currentRows.slice();
if (sortKey === 'asset') {
rows.sort((a, b) => {
const av = (a.asset_name || '').toLowerCase();
const bv = (b.asset_name || '').toLowerCase();
if (av < bv) return -sortDir;
if (av > bv) return sortDir;
return 0;
});
} else if (sortKey === 'date') {
rows.sort((a, b) => {
// Lignes sans start_iso (date texte libre) en fin
const av = a.start_iso || '';
const bv = b.start_iso || '';
if (!av && !bv) return 0;
if (!av) return 1;
if (!bv) return -1;
if (av < bv) return -sortDir;
if (av > bv) return sortDir;
return 0;
});
}
tbody.innerHTML = rows.map(r => {
const display = escapeHTML(r.resolved_hostname || r.asset_name || '');
const assetCell = r.server_id
? '<a href="/servers?search=' + encodeURIComponent(r.resolved_hostname || r.asset_name) + '" target="_blank" rel="noopener" class="text-cyber-blue hover:underline">' + display + '</a>'
: '<span class="text-cyber-yellow" title="Pas matché en base PatchCenter">' + escapeHTML(r.asset_name || '') + ' ⚠</span>';
let badge = '';
if (r.is_eligible) {
badge = '<span class="text-cyber-green" title="Éligible au patching">✓ ÉLIG.</span>';
} else if (r.reported_to_sheet) {
const t = r.report_reason ? ('Reporté → ' + r.reported_to_sheet + ' : ' + r.report_reason) : ('Reporté → ' + r.reported_to_sheet);
badge = '<span class="text-cyber-blue" title="' + escapeHTML(t) + '">⤳ ' + escapeHTML(r.reported_to_sheet) + '</span>';
}
return '<tr class="border-b border-cyber-border/20 hover:bg-cyber-border/10"'
+ ' data-asset="' + escapeHTML(r.asset_name||'') + '"'
+ ' data-intervenant="' + escapeHTML(r.intervenant||'') + '"'
+ ' data-env="' + escapeHTML(r.environnement||'') + '">'
+ '<td class="p-1"><input type="checkbox" class="row-cb" data-id="' + r.id + '" data-asset="' + escapeHTML(r.asset_name||'') + '" data-server-id="' + (r.server_id||'') + '" data-eligible="' + (r.is_eligible ? '1':'0') + '"></td>'
+ '<td class="p-1 text-[10px]">' + badge + '</td>'
+ '<td class="p-1 font-mono">' + assetCell + '</td>'
+ '<td class="p-1">' + escapeHTML(r.environnement||'') + '</td>'
+ '<td class="p-1">' + escapeHTML(r.domaine||'') + '</td>'
+ '<td class="p-1">' + escapeHTML(r.os||'') + '</td>'
+ '<td class="p-1" title="' + escapeHTML(r.os_version||'') + '">' + escapeHTML(shortOSVersion(r.os_version)) + '</td>'
+ '<td class="p-1">' + escapeHTML(r.application_name||'') + '</td>'
+ '<td class="p-1">' + escapeHTML(r.intervenant||'') + '</td>'
+ '<td class="p-1">' + escapeHTML(r.valideur_ra||'') + '</td>'
+ '<td class="p-1">' + escapeHTML(r.responsable_domaine_dts||'') + '</td>'
+ '<td class="p-1">' + escapeHTML(r.referent_technique||'') + '</td>'
+ '<td class="p-1">' + (r.jour ? escapeHTML(dateFR(r.jour)) : (r.jour_text ? '<span class="text-cyber-yellow" title="Texte libre">' + escapeHTML(r.jour_text) + '</span>' : '')) + '</td>'
+ '<td class="p-1">' + escapeHTML(r.heure||'') + '</td>'
+ '</tr>';
}).join('');
tbody.querySelectorAll('input.row-cb').forEach(cb => cb.addEventListener('change', refreshSelection));
applyFilters();
}
sel.addEventListener('change', () => loadSheet(sel.value));
function toggleAll(state){
tbody.querySelectorAll('tr:not(.row-hidden) input.row-cb').forEach(cb => cb.checked = state);
refreshSelection();
}
selAll.addEventListener('change', () => toggleAll(selAll.checked));
selAllHead.addEventListener('change', () => toggleAll(selAllHead.checked));
[fInter, fEnv].forEach(el => el.addEventListener('change', applyFilters));
fReset.addEventListener('click', () => {
fInter.value = ''; fEnv.value = '';
sortKey = null; sortDir = 1;
updateSortArrow();
renderTable();
});
thAsset.addEventListener('click', () => { cycleSort('asset'); updateSortArrow(); renderTable(); });
thDate.addEventListener('click', () => { cycleSort('date'); updateSortArrow(); renderTable(); });
if (btnAddElig) btnAddElig.addEventListener('click', async () => {
const ids = getSelectedRowIds();
if (!ids.length) { alert('Veuillez sélectionner au moins un serveur.'); return; }
if (!confirm('Marquer ' + ids.length + ' ligne(s) comme éligibles au patching ?')) return;
if (await postAction({row_ids: ids, action: 'eligible'})) await reloadCurrentSheet();
});
if (btnReport) btnReport.addEventListener('click', async () => {
const ids = getSelectedRowIds();
if (!ids.length) { alert('Veuillez sélectionner au moins un serveur.'); return; }
const target = (prompt('Reporter vers quelle semaine ? (ex: S23)') || '').trim();
if (!target) return;
if (!/^S\d{1,2}$/i.test(target)) { alert('Format attendu : Sxx (ex S23)'); return; }
const reason = (prompt('Raison du report (optionnel) :') || '').trim();
if (await postAction({row_ids: ids, action: 'report', target_sheet: target.toUpperCase(), reason})) await reloadCurrentSheet();
});
if (btnUnset) btnUnset.addEventListener('click', async () => {
const ids = getSelectedRowIds();
if (!ids.length) { alert('Veuillez sélectionner au moins un serveur.'); return; }
if (!confirm('Annuler éligibilité ET report sur ' + ids.length + ' ligne(s) ?')) return;
if (await postAction({row_ids: ids, action: 'unset_eligible'})) {
await postAction({row_ids: ids, action: 'unset_report'});
await reloadCurrentSheet();
}
});
btnPre.addEventListener('click', () => {
const checkedAny = Array.from(tbody.querySelectorAll('input.row-cb:checked'));
if (!checkedAny.length) { alert('Veuillez sélectionner au moins un serveur.'); return; }
const ids = checkedAny
.filter(cb => cb.dataset.eligible === '1')
.map(cb => cb.dataset.id);
if (!ids.length) {
alert('Aucun serveur sélectionné n\'est éligible. Marque-les d\'abord avec "+ Ajouter au patching".');
return;
}
window.location.href = '/patching/iexec?row_ids=' + ids.join(',');
});
// ─── Prévenance PCT — preview puis envoi ─────────────────────────
const btnPctMail = document.getElementById('btn-pct-mail');
if (btnPctMail) {
btnPctMail.addEventListener('click', async () => {
const ids = getSelectedRowIds();
if (!ids.length) { alert('Veuillez sélectionner au moins un serveur.'); return; }
// 1) preview
try {
const r = await fetch('/patching/import/pct-prevenance/preview', {
method: 'POST', headers: {'Content-Type':'application/json'},
credentials: 'same-origin',
body: JSON.stringify({row_ids: ids}),
});
const j = await r.json();
if (!j.ok) { alert('Erreur preview : ' + (j.msg || '')); return; }
showPctPreview(j, ids);
} catch (e) { alert('Erreur réseau : ' + e); }
});
}
function showPctPreview(data, ids) {
const modal = document.getElementById('pct-modal');
document.getElementById('pct-subject').textContent = data.subject;
document.getElementById('pct-to').textContent = data.to;
const ccList = (data.cc || []);
const ccHtml = ccList.length
? ccList.map(c => '<span class="badge badge-blue">' + escapeHTML(c.name) + ' &lt;' + escapeHTML(c.email) + '&gt;</span>').join(' ')
: '<span class="text-gray-500">(aucun contact en CC — vérifie les responsables/référents en BDD)</span>';
document.getElementById('pct-cc').innerHTML = ccHtml;
document.getElementById('pct-iframe').srcdoc = data.html;
document.getElementById('pct-smtp-warn').style.display = data.smtp_configured ? 'none' : '';
document.getElementById('pct-rowcount').textContent = data.row_count;
modal.classList.add('active');
const btnSend = document.getElementById('pct-btn-send');
btnSend.disabled = !data.smtp_configured;
btnSend.onclick = async () => {
if (!confirm('Envoyer ce mail à ' + data.to + ' (+ ' + ccList.length + ' en CC) ?')) return;
btnSend.disabled = true; btnSend.textContent = '⏳ Envoi…';
try {
const r = await fetch('/patching/import/pct-prevenance/send', {
method: 'POST', headers: {'Content-Type':'application/json'},
credentials: 'same-origin',
body: JSON.stringify({row_ids: ids}),
});
const j = await r.json();
if (j.ok) {
alert('✅ Mail envoyé.\n' + (j.msg || ''));
modal.classList.remove('active');
} else {
alert('❌ Échec envoi : ' + (j.msg || 'inconnu'));
}
} catch (e) { alert('Erreur réseau : ' + e); }
finally { btnSend.disabled = false; btnSend.textContent = '📤 Envoyer'; }
};
}
function closePctModal() {
document.getElementById('pct-modal').classList.remove('active');
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closePctModal();
});
// Délégation document-level pour les boutons modaux (bind robuste même si
// la modal HTML est parsée APRÈS ce script).
document.addEventListener('click', async (ev) => {
const t = ev.target.closest('button');
if (!t) return;
if (t.id === 'pct-btn-cancel' || t.id === 'pct-modal-bg') {
closePctModal();
return;
}
if (t.id === 'pct-btn-eml') {
const checkedAny = Array.from(tbody.querySelectorAll('input.row-cb:checked'));
const ids = checkedAny.map(cb => cb.dataset.id);
if (!ids.length) { alert('Sélection vide'); return; }
t.disabled = true; const orig = t.textContent; t.textContent = '⏳ Génération .eml…';
try {
const r = await fetch('/patching/import/pct-prevenance/download-eml', {
method: 'POST', headers: {'Content-Type':'application/json'},
credentials: 'same-origin',
body: JSON.stringify({row_ids: ids}),
});
if (!r.ok) {
const j = await r.json().catch(()=>({}));
alert('Erreur génération .eml : ' + (j.msg || r.status));
return;
}
let filename = 'prevenance_pct.eml';
const cd = r.headers.get('Content-Disposition') || '';
const m = cd.match(/filename="?([^"]+)"?/i);
if (m) filename = m[1];
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (e) {
alert('Erreur réseau : ' + e);
} finally {
t.disabled = false; t.textContent = orig;
}
}
});
// Click hors modal sur le BG ferme la modal (delegation aussi)
document.addEventListener('click', (ev) => {
if (ev.target && ev.target.id === 'pct-modal-bg') closePctModal();
});
})();
</script>
{% endif %}
{# Modal preview Prévenance PCT #}
<style>
#pct-modal { position:fixed; inset:0; z-index:9999; display:none; align-items:center; justify-content:center; }
#pct-modal.active { display:flex; }
#pct-modal-bg { position:absolute; inset:0; background:rgba(10,14,23,0.85); backdrop-filter:blur(2px); }
.pct-dialog {
position:relative; max-width:920px; width:95%; max-height:90vh;
background:#0f172a; border:1px solid #334155; border-radius:8px;
box-shadow: 0 0 40px rgba(167,139,250,0.4);
display:flex; flex-direction:column;
}
.pct-header { padding:14px 20px; border-bottom:1px solid #334155; display:flex; justify-content:space-between; align-items:center; }
.pct-body { padding:14px 20px; overflow-y:auto; flex:1; }
.pct-footer { padding:12px 20px; border-top:1px solid #334155; display:flex; gap:10px; justify-content:flex-end; }
.pct-meta-row { display:flex; gap:10px; padding:6px 0; font-size:0.85rem; }
.pct-meta-row .lbl { color:#9ca3af; min-width:90px; }
.pct-meta-row .val { color:#e5e7eb; flex:1; word-break:break-all; }
.pct-meta-row .val .badge { display:inline-block; margin:2px 4px 2px 0; padding:2px 8px; border-radius:4px; font-size:11px; }
.pct-meta-row .val .badge-blue { background:rgba(59,130,246,0.15); color:#60a5fa; border:1px solid #3b82f6; }
#pct-iframe { width:100%; height:60vh; border:1px solid #334155; border-radius:4px; background:#fff; }
.pct-btn {
padding:8px 16px; font-size:0.85rem; font-weight:700; border-radius:6px;
cursor:pointer; border:1px solid; text-transform:uppercase; letter-spacing:0.03em;
}
.pct-btn-cancel { color:#9ca3af; border-color:#9ca3af; background:rgba(156,163,175,0.1); }
.pct-btn-cancel:hover { background:rgba(156,163,175,0.25); }
.pct-btn-send { color:#22c55e; border-color:#22c55e; background:rgba(34,197,94,0.15); }
.pct-btn-send:hover { background:rgba(34,197,94,0.30); }
.pct-btn-send:disabled { opacity:0.4; cursor:not-allowed; }
</style>
<div id="pct-modal">
<div id="pct-modal-bg"></div>
<div class="pct-dialog">
<div class="pct-header">
<div>
<div style="font-size:11px;letter-spacing:0.1em;text-transform:uppercase;color:#a78bfa;">Prévenance PCT</div>
<h3 style="margin:4px 0 0;color:#e5e7eb;font-size:1.1rem;">Aperçu du mail avant envoi</h3>
</div>
<button onclick="document.getElementById('pct-modal').classList.remove('active')"
style="background:transparent;border:none;color:#9ca3af;cursor:pointer;font-size:1.4rem;"></button>
</div>
<div class="pct-body">
<div class="pct-meta-row"><span class="lbl">Destinataire :</span><span class="val" id="pct-to"></span></div>
<div class="pct-meta-row"><span class="lbl">CC :</span><span class="val" id="pct-cc"></span></div>
<div class="pct-meta-row"><span class="lbl">Objet :</span><span class="val" id="pct-subject" style="font-weight:600;"></span></div>
<div class="pct-meta-row"><span class="lbl">Serveurs :</span><span class="val"><span id="pct-rowcount"></span> ligne(s) sélectionnée(s)</span></div>
<div id="pct-smtp-warn" style="margin:8px 0;padding:8px 12px;background:rgba(239,68,68,0.15);border-left:3px solid #ef4444;border-radius:4px;color:#fca5a5;font-size:0.85rem;display:none;">
<strong>SMTP non configuré</strong> dans Settings > SMTP. L'envoi est désactivé. Tu peux relire la preview ; configure SMTP plus tard pour activer l'envoi.
</div>
<h4 style="margin:14px 0 8px;color:#a78bfa;font-size:0.85rem;letter-spacing:0.1em;text-transform:uppercase;">Aperçu HTML du mail</h4>
<iframe id="pct-iframe" sandbox=""></iframe>
</div>
<div class="pct-footer">
<button id="pct-btn-cancel" class="pct-btn pct-btn-cancel">Annuler</button>
<button id="pct-btn-eml" class="pct-btn" style="color:#a78bfa; border-color:#a78bfa; background:rgba(167,139,250,0.15);" title="Télécharge un .eml à ouvrir dans Outlook (Web/New) — relecture + envoi manuel">⬇ Télécharger .eml</button>
<button id="pct-btn-send" class="pct-btn pct-btn-send">📤 Envoyer (SMTP)</button>
</div>
</div>
</div>
{% endblock %}