feat: export Indy — selection commandes, CSV produits/services, acronyme client
Deploy via Portainer / deploy (push) Successful in 0s
Deploy via Portainer / deploy (push) Successful in 0s
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Export Indy{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h1><i class="bi bi-file-earmark-arrow-down me-2 text-primary"></i>Export Indy</h1>
|
||||
<a href="{{ url_for('jobs') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Retour commandes
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
|
||||
<!-- COLONNE GAUCHE : selection des commandes -->
|
||||
<div class="col-lg-5">
|
||||
<div class="card h-100">
|
||||
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||||
<span class="fw-semibold"><i class="bi bi-check2-square me-1"></i>Selection des commandes</span>
|
||||
<div class="d-flex gap-2">
|
||||
<button id="selAll" class="btn btn-sm btn-outline-secondary py-0">Tout</button>
|
||||
<button id="selNone" class="btn btn-sm btn-outline-secondary py-0">Aucun</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Filtres -->
|
||||
<div class="card-body border-bottom py-2 px-3">
|
||||
<div class="row g-2">
|
||||
<div class="col">
|
||||
<input type="text" id="fSearch" class="form-control form-control-sm" placeholder="Rechercher...">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<select id="fClientSel" class="form-select form-select-sm">
|
||||
<option value="">Tous clients</option>
|
||||
{% set seen = [] %}
|
||||
{% for j in jobs %}
|
||||
{% if j.client_name and j.client_name not in seen %}
|
||||
<option value="{{ j.client_name }}">{{ j.client_name }}</option>
|
||||
{% if seen.append(j.client_name) %}{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Liste jobs -->
|
||||
<div class="card-body p-0" style="max-height:520px;overflow-y:auto">
|
||||
<table class="table table-sm table-hover mb-0" id="jobSelTable">
|
||||
<thead class="table-light sticky-top">
|
||||
<tr>
|
||||
<th style="width:32px"><input type="checkbox" id="chkAll" class="form-check-input"></th>
|
||||
<th>Commande</th>
|
||||
<th class="text-end">Prix HT</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="jobSelBody">
|
||||
{% for j in jobs %}
|
||||
{% set prix_ttc = (j.final_price / (1 - j.discount_pct / 100)) if (j.discount_pct and j.discount_pct > 0 and j.discount_pct < 100) else (j.final_price or 0) %}
|
||||
{% set prix_ht_net = prix_ttc - (j.tva_amount or 0) %}
|
||||
{% set qty = j.order_qty or 1 %}
|
||||
{% set acro = j.client_acronyme or '' %}
|
||||
{% set default_nom = (acro ~ ' - ' ~ j.name) if acro else j.name %}
|
||||
<tr class="sel-row"
|
||||
data-id="{{ j.id }}"
|
||||
data-client="{{ j.client_name or '' }}"
|
||||
data-nom="{{ default_nom }}"
|
||||
data-prix-ht="{{ '%.2f'|format(prix_ht_net) }}"
|
||||
data-tva="{{ tva_pct }}"
|
||||
data-qty="{{ qty }}"
|
||||
data-desc="{{ j.description or '' }}"
|
||||
data-date="{{ j.created_at[:10] }}"
|
||||
>
|
||||
<td><input type="checkbox" class="form-check-input job-chk"></td>
|
||||
<td>
|
||||
<div class="fw-semibold" style="font-size:.85rem">{{ j.name }}</div>
|
||||
<div class="text-muted" style="font-size:.72rem">
|
||||
{{ j.created_at[:10] }}{% if j.client_name %} · {{ j.client_name }}{% if acro %} <span class="badge bg-light text-dark border">{{ acro }}</span>{% endif %}{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-end small fw-semibold">{{ '%.2f'|format(prix_ht_net) }} €</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- COLONNE DROITE : tableau d'export éditable -->
|
||||
<div class="col-lg-7">
|
||||
<div class="card">
|
||||
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||||
<span class="fw-semibold"><i class="bi bi-table me-1"></i>Produits Indy <span id="exportCount" class="badge bg-secondary ms-1">0</span></span>
|
||||
<button id="btnDownload" class="btn btn-sm btn-success" disabled>
|
||||
<i class="bi bi-download me-1"></i>Télécharger CSV Indy
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0" style="max-height:600px;overflow-y:auto">
|
||||
<table class="table table-sm mb-0" id="exportTable">
|
||||
<thead class="table-light sticky-top">
|
||||
<tr>
|
||||
<th>Nom produit <small class="text-muted fw-normal">(éditable)</small></th>
|
||||
<th style="width:110px">Nature</th>
|
||||
<th style="width:90px">Prix HT</th>
|
||||
<th style="width:70px">TVA %</th>
|
||||
<th style="width:60px">Unité</th>
|
||||
<th style="width:28px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="exportBody">
|
||||
<tr id="exportEmpty">
|
||||
<td colspan="6" class="text-center text-muted py-4">
|
||||
<i class="bi bi-arrow-left me-1"></i>Sélectionnez des commandes
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text mt-2 ps-1">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Les colonnes <strong>Nom</strong>, <strong>Nature</strong>, <strong>Prix HT</strong>, <strong>TVA</strong> et <strong>Unité</strong> sont éditables directement dans le tableau.
|
||||
Le CSV généré est compatible avec l'import <em>Produits & Services</em> d'Indy.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var rows = Array.from(document.querySelectorAll('.sel-row'));
|
||||
var expBody = document.getElementById('exportBody');
|
||||
var expEmpty = document.getElementById('exportEmpty');
|
||||
var expCount = document.getElementById('exportCount');
|
||||
var btnDl = document.getElementById('btnDownload');
|
||||
var fSearch = document.getElementById('fSearch');
|
||||
var fClient = document.getElementById('fClientSel');
|
||||
var chkAll = document.getElementById('chkAll');
|
||||
|
||||
// Map jobId -> export row element
|
||||
var exportRows = {};
|
||||
|
||||
function filterRows() {
|
||||
var q = fSearch.value.toLowerCase();
|
||||
var cli = fClient.value;
|
||||
rows.forEach(function(r) {
|
||||
var name = (r.dataset.nom + ' ' + r.dataset.client + ' ' + r.dataset.date).toLowerCase();
|
||||
var show = (!q || name.includes(q)) && (!cli || r.dataset.client === cli);
|
||||
r.style.display = show ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function makeInput(val, cls) {
|
||||
var inp = document.createElement('input');
|
||||
inp.type = 'text';
|
||||
inp.className = 'form-control form-control-sm border-0 px-1 ' + (cls||'');
|
||||
inp.value = val;
|
||||
inp.style.minWidth = '0';
|
||||
return inp;
|
||||
}
|
||||
|
||||
function makeSelect(opts, selected) {
|
||||
var sel = document.createElement('select');
|
||||
sel.className = 'form-select form-select-sm border-0 px-1';
|
||||
opts.forEach(function(o) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = o; opt.textContent = o;
|
||||
if (o === selected) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
return sel;
|
||||
}
|
||||
|
||||
function addExportRow(r) {
|
||||
if (exportRows[r.dataset.id]) return;
|
||||
|
||||
var tr = document.createElement('tr');
|
||||
tr.dataset.id = r.dataset.id;
|
||||
|
||||
// Nom
|
||||
var tdNom = document.createElement('td');
|
||||
tdNom.appendChild(makeInput(r.dataset.nom, 'nom-inp'));
|
||||
tr.appendChild(tdNom);
|
||||
|
||||
// Nature
|
||||
var tdNat = document.createElement('td');
|
||||
tdNat.appendChild(makeSelect(['Prestation de services', 'Vente de produits'], 'Prestation de services'));
|
||||
tr.appendChild(tdNat);
|
||||
|
||||
// Prix HT
|
||||
var tdPrix = document.createElement('td');
|
||||
var prixInp = makeInput(r.dataset.prixHt, 'text-end');
|
||||
tdPrix.appendChild(prixInp);
|
||||
tr.appendChild(tdPrix);
|
||||
|
||||
// TVA
|
||||
var tdTva = document.createElement('td');
|
||||
tdTva.appendChild(makeInput(r.dataset.tva, 'text-end'));
|
||||
tr.appendChild(tdTva);
|
||||
|
||||
// Unite
|
||||
var tdUnit = document.createElement('td');
|
||||
tdUnit.appendChild(makeSelect(['unite', 'piece', 'lot', 'heure'], 'unite'));
|
||||
tr.appendChild(tdUnit);
|
||||
|
||||
// Remove
|
||||
var tdDel = document.createElement('td');
|
||||
var btn = document.createElement('button');
|
||||
btn.className = 'btn btn-sm btn-link text-danger p-0';
|
||||
btn.innerHTML = '<i class="bi bi-x-lg"></i>';
|
||||
btn.onclick = function() {
|
||||
// uncheck the source row
|
||||
var srcRow = rows.find(function(rr) { return rr.dataset.id === r.dataset.id; });
|
||||
if (srcRow) srcRow.querySelector('.job-chk').checked = false;
|
||||
removeExportRow(r.dataset.id);
|
||||
};
|
||||
tdDel.appendChild(btn);
|
||||
tr.appendChild(tdDel);
|
||||
|
||||
exportRows[r.dataset.id] = tr;
|
||||
expBody.appendChild(tr);
|
||||
updateEmpty();
|
||||
}
|
||||
|
||||
function removeExportRow(id) {
|
||||
var tr = exportRows[id];
|
||||
if (tr) { tr.remove(); delete exportRows[id]; }
|
||||
updateEmpty();
|
||||
}
|
||||
|
||||
function updateEmpty() {
|
||||
var count = Object.keys(exportRows).length;
|
||||
expCount.textContent = count;
|
||||
btnDl.disabled = count === 0;
|
||||
if (count === 0) {
|
||||
if (!expEmpty.parentNode) expBody.appendChild(expEmpty);
|
||||
} else {
|
||||
if (expEmpty.parentNode) expEmpty.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Checkbox on job rows
|
||||
rows.forEach(function(r) {
|
||||
var chk = r.querySelector('.job-chk');
|
||||
chk.addEventListener('change', function() {
|
||||
if (chk.checked) addExportRow(r);
|
||||
else removeExportRow(r.dataset.id);
|
||||
});
|
||||
});
|
||||
|
||||
// Select all / none visible
|
||||
document.getElementById('selAll').addEventListener('click', function() {
|
||||
rows.filter(function(r) { return r.style.display !== 'none'; }).forEach(function(r) {
|
||||
var chk = r.querySelector('.job-chk');
|
||||
if (!chk.checked) { chk.checked = true; addExportRow(r); }
|
||||
});
|
||||
});
|
||||
document.getElementById('selNone').addEventListener('click', function() {
|
||||
rows.forEach(function(r) {
|
||||
var chk = r.querySelector('.job-chk');
|
||||
if (chk.checked) { chk.checked = false; removeExportRow(r.dataset.id); }
|
||||
});
|
||||
});
|
||||
chkAll.addEventListener('change', function() {
|
||||
if (chkAll.checked) document.getElementById('selAll').click();
|
||||
else document.getElementById('selNone').click();
|
||||
});
|
||||
|
||||
// Filters
|
||||
fSearch.addEventListener('input', filterRows);
|
||||
fClient.addEventListener('change', filterRows);
|
||||
|
||||
// CSV download
|
||||
btnDl.addEventListener('click', function() {
|
||||
var lines = ['"Nom*","Nature*","Prix unitaire HT*","Taux de TVA*","Description","Unite","Reference"'];
|
||||
Object.values(exportRows).forEach(function(tr) {
|
||||
var inputs = tr.querySelectorAll('input,select');
|
||||
var nom = inputs[0].value.replace(/"/g,'""');
|
||||
var nature = inputs[1].value.replace(/"/g,'""');
|
||||
var prix = parseFloat(inputs[2].value).toFixed(2).replace('.',',');
|
||||
var tva = inputs[3].value.replace(/"/g,'""');
|
||||
var unite = inputs[4].value.replace(/"/g,'""');
|
||||
lines.push('"'+nom+'","'+nature+'","'+prix+'","'+tva+'%","","'+unite+'",""');
|
||||
});
|
||||
var csv = '' + lines.join('\r\n');
|
||||
var blob = new Blob([csv], {type:'text/csv;charset=utf-8;'});
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'indy_produits_' + new Date().toISOString().slice(0,10) + '.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
updateEmpty();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user