feat: export Indy — selection commandes, CSV produits/services, acronyme client
Deploy via Portainer / deploy (push) Successful in 0s

This commit is contained in:
Jo
2026-07-17 00:15:26 +02:00
parent 38fc4ed565
commit a82dcc0c92
4 changed files with 339 additions and 9 deletions
+27 -5
View File
@@ -309,6 +309,7 @@ def init_db():
'ALTER TABLE jobs ADD COLUMN meshy_credits_used INTEGER DEFAULT 0',
'ALTER TABLE jobs ADD COLUMN meshy_cost REAL DEFAULT 0',
'ALTER TABLE jobs ADD COLUMN meshy_margin_pct REAL DEFAULT 0',
'ALTER TABLE clients ADD COLUMN acronyme TEXT DEFAULT ""',
]
for sql in migrations:
try:
@@ -845,10 +846,11 @@ def new_client():
if request.method == 'POST':
def _int(k): return int(request.form[k]) if request.form.get(k) else None
conn.execute('''INSERT INTO clients
(name,email,notes,default_machine_profile_id,default_handling_profile_id,
(name,acronyme,email,notes,default_machine_profile_id,default_handling_profile_id,
default_material_profile_id,default_pricing_profile_id)
VALUES(?,?,?,?,?,?,?)''',
(request.form['name'], request.form.get('email',''), request.form.get('notes',''),
VALUES(?,?,?,?,?,?,?,?)''',
(request.form['name'], request.form.get('acronyme','').upper().strip(),
request.form.get('email',''), request.form.get('notes',''),
_int('default_machine_profile_id'), _int('default_handling_profile_id'),
_int('default_material_profile_id'), _int('default_pricing_profile_id')))
conn.commit(); conn.close()
@@ -863,10 +865,11 @@ def edit_client(id):
client = conn.execute('SELECT * FROM clients WHERE id=?',(id,)).fetchone()
if request.method == 'POST':
def _int(k): return int(request.form[k]) if request.form.get(k) else None
conn.execute('''UPDATE clients SET name=?,email=?,notes=?,
conn.execute('''UPDATE clients SET name=?,acronyme=?,email=?,notes=?,
default_machine_profile_id=?,default_handling_profile_id=?,
default_material_profile_id=?,default_pricing_profile_id=? WHERE id=?''',
(request.form['name'], request.form.get('email',''), request.form.get('notes',''),
(request.form['name'], request.form.get('acronyme','').upper().strip(),
request.form.get('email',''), request.form.get('notes',''),
_int('default_machine_profile_id'), _int('default_handling_profile_id'),
_int('default_material_profile_id'), _int('default_pricing_profile_id'), id))
conn.commit(); conn.close()
@@ -897,6 +900,25 @@ def delete_client(id):
conn.commit(); conn.close()
return redirect(url_for('clients'))
@app.route('/export/indy')
def export_indy():
"""Page d'export des commandes au format Indy (produits/services)."""
conn = get_db()
jobs = conn.execute('''
SELECT j.id, j.name, j.created_at, j.final_price, j.discount_pct,
j.tva_amount, j.order_qty, j.pieces_per_plate, j.plate_name,
j.description,
c.name as client_name, c.acronyme as client_acronyme
FROM jobs j
LEFT JOIN clients c ON j.client_id = c.id
ORDER BY j.created_at DESC
''').fetchall()
s = get_settings()
tva_pct = float(s.get('tva_rate_pct', 20.0))
conn.close()
return render_template('export_indy.html', jobs=jobs, tva_pct=tva_pct)
# ─── Projets ──────────────────────────────────────────────────────────────────
@app.route('/projects')
+3
View File
@@ -213,6 +213,9 @@
<a href="{{ url_for('stats') }}" class="{% if request.endpoint=='stats' %}active{% endif %}" title="Statistiques">
<i class="bi bi-bar-chart-line"></i><span class="nav-label"> Statistiques</span>
</a>
<a href="{{ url_for('export_indy') }}" class="{% if request.endpoint == 'export_indy' %}active{% endif %}" title="Export Indy">
<i class="bi bi-file-earmark-arrow-down"></i><span class="nav-label"> Export Indy</span>
</a>
<a href="{{ url_for('export_csv') }}" title="Export CSV">
<i class="bi bi-download"></i><span class="nav-label"> Export CSV</span>
</a>
+13 -4
View File
@@ -15,10 +15,19 @@
<div class="card-body">
<form method="POST">
<div class="mb-3">
<label class="form-label fw-semibold">Nom *</label>
<input type="text" name="name" class="form-control" required
value="{{ client.name if client else '' }}" placeholder="ex: Blockcorp">
<div class="row g-3 mb-3">
<div class="col-md-8">
<label class="form-label fw-semibold">Nom *</label>
<input type="text" name="name" class="form-control" required
value="{{ client.name if client else '' }}" placeholder="ex: Blockcorp">
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Acronyme <span class="text-muted fw-normal">(export Indy)</span></label>
<input type="text" name="acronyme" class="form-control text-uppercase"
maxlength="10" value="{{ client.acronyme if client else '' }}"
placeholder="ex: BLK"
oninput="this.value=this.value.toUpperCase()">
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Email</label>
+296
View File
@@ -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 &amp; 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 %}