feat: jobs page filters/sort/summary + poids piece in detail
Deploy via Portainer / deploy (push) Successful in 1s

This commit is contained in:
Jo
2026-07-16 19:25:25 +02:00
parent 0d146e241d
commit c00704a2aa
3 changed files with 213 additions and 30 deletions
+3 -4
View File
@@ -1252,14 +1252,13 @@ def jobs():
conn = get_db() conn = get_db()
jobs = conn.execute(''' jobs = conn.execute('''
SELECT j.*, c.name as client_name, m.name as material_name, SELECT j.*, c.name as client_name, m.name as material_name,
ps.status as slot_status, ps.planned_start COALESCE(j.cout_fixe, 0) + COALESCE(j.total_marge, 0) as prix_ht_piece
FROM jobs j LEFT JOIN clients c ON j.client_id=c.id FROM jobs j LEFT JOIN clients c ON j.client_id=c.id
LEFT JOIN materials m ON j.material_id=m.id LEFT JOIN materials m ON j.material_id=m.id
LEFT JOIN print_slots ps ON ps.job_id=j.id AND ps.id=(
SELECT id FROM print_slots WHERE job_id=j.id ORDER BY planned_start LIMIT 1)
ORDER BY j.created_at DESC''').fetchall() ORDER BY j.created_at DESC''').fetchall()
clients = conn.execute('SELECT id, name FROM clients ORDER BY name').fetchall()
conn.close() conn.close()
return render_template('jobs.html', jobs=jobs) return render_template('jobs.html', jobs=jobs, clients=clients)
@app.route('/jobs/new', methods=['GET','POST']) @app.route('/jobs/new', methods=['GET','POST'])
def new_job(): def new_job():
+1
View File
@@ -82,6 +82,7 @@
{% endif %} {% endif %}
<tr><th class="text-muted fw-normal">Date</th><td>{{ job.created_at[:10] }}</td></tr> <tr><th class="text-muted fw-normal">Date</th><td>{{ job.created_at[:10] }}</td></tr>
<tr><th class="text-muted fw-normal">Poids plateau</th><td>{{ job.weight_g }} g</td></tr> <tr><th class="text-muted fw-normal">Poids plateau</th><td>{{ job.weight_g }} g</td></tr>
<tr><th class="text-muted fw-normal">Poids / pièce</th><td><strong>{{ '%.2f'|format(job.weight_g / (job.pieces_per_plate or 1)) }} g</strong></td></tr>
<tr><th class="text-muted fw-normal">Pieces / plateau</th><td>{{ job.pieces_per_plate or 1 }}</td></tr> <tr><th class="text-muted fw-normal">Pieces / plateau</th><td>{{ job.pieces_per_plate or 1 }}</td></tr>
<tr><th class="text-muted fw-normal">Quantite commandee</th><td>{{ qty }} pièce{{ 's' if qty > 1 else '' }}</td></tr> <tr><th class="text-muted fw-normal">Quantite commandee</th><td>{{ qty }} pièce{{ 's' if qty > 1 else '' }}</td></tr>
<tr><th class="text-muted fw-normal">Duree plateau</th><td>{{ job.print_time_s | fmt_time }}</td></tr> <tr><th class="text-muted fw-normal">Duree plateau</th><td>{{ job.print_time_s | fmt_time }}</td></tr>
+209 -26
View File
@@ -14,46 +14,153 @@
</div> </div>
</div> </div>
<!-- Barre de filtres -->
<div class="card mb-3">
<div class="card-body py-2 px-3">
<div class="row g-2 align-items-center">
<div class="col-auto d-flex align-items-center gap-1">
<label class="text-muted small mb-0">Du</label>
<input type="date" id="fDateFrom" class="form-control form-control-sm" style="width:140px">
</div>
<div class="col-auto d-flex align-items-center gap-1">
<label class="text-muted small mb-0">au</label>
<input type="date" id="fDateTo" class="form-control form-control-sm" style="width:140px">
</div>
<div class="col-auto">
<select id="fClient" class="form-select form-select-sm" style="min-width:160px">
<option value="">Tous les clients</option>
{% for c in clients %}
<option value="{{ c.id }}">{{ c.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-auto d-flex align-items-center gap-1">
<label class="text-muted small mb-0">Tri</label>
<select id="fSort" class="form-select form-select-sm" style="min-width:165px">
<option value="date-desc">Date (recent)</option>
<option value="date-asc">Date (ancien)</option>
<option value="prix-desc">Prix HT desc</option>
<option value="prix-asc">Prix HT asc</option>
<option value="poids-desc">Poids desc</option>
<option value="poids-asc">Poids asc</option>
<option value="duree-desc">Duree desc</option>
<option value="duree-asc">Duree asc</option>
</select>
</div>
<div class="col-auto ms-auto">
<button id="fReset" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-x-circle me-1"></i>Reinitialiser
</button>
</div>
</div>
</div>
</div>
<!-- Resume -->
<div class="row g-2 mb-3">
<div class="col-auto">
<div class="card px-3 py-2">
<div class="text-muted" style="font-size:.68rem;text-transform:uppercase;letter-spacing:.05em">Commandes</div>
<div class="fw-bold fs-5" id="sumCount">{{ jobs|length }}</div>
</div>
</div>
<div class="col-auto">
<div class="card px-3 py-2">
<div class="text-muted" style="font-size:.68rem;text-transform:uppercase;letter-spacing:.05em">Total HT commandes</div>
<div class="fw-bold fs-5" id="sumHT">--</div>
</div>
</div>
<div class="col-auto">
<div class="card px-3 py-2">
<div class="text-muted" style="font-size:.68rem;text-transform:uppercase;letter-spacing:.05em">Total prix final</div>
<div class="fw-bold fs-5" id="sumFinal" style="color:#ff6b35">--</div>
</div>
</div>
<div class="col-auto">
<div class="card px-3 py-2">
<div class="text-muted" style="font-size:.68rem;text-transform:uppercase;letter-spacing:.05em">Poids total</div>
<div class="fw-bold fs-5" id="sumPoids">--</div>
</div>
</div>
</div>
<!-- Tableau -->
<div class="card"> <div class="card">
<div class="card-body p-0"> <div class="card-body p-0">
{% if jobs %} {% if jobs %}
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0" id="jobsTable">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th>Date</th><th>Nom</th><th>Client</th><th>Réf. plateau</th><th>Poids</th> <th style="width:90px">Date</th>
<th>Durée</th><th>Design</th><th>Marge brute</th><th>Total marge</th><th>Remise</th> <th>Nom</th>
<th class="text-end">Prix final</th><th></th> <th>Client</th>
<th>Matiere</th>
<th>Poids</th>
<th>Duree</th>
<th class="text-end">Prix HT</th>
<th class="text-end">Prix final</th>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="jobsBody">
{% for j in jobs %} {% for j in jobs %}
<tr> {% set pieces = j.pieces_per_plate or 1 %}
<td class="text-muted small">{{ j.created_at[:10] }}</td> {% set qty = j.order_qty or 1 %}
<td><a href="{{ url_for('job_detail', id=j.id) }}" class="text-decoration-none fw-semibold">{{ j.name }}</a></td> {% set prix_ht = j.prix_ht_piece or 0 %}
<td>{{ j.client_name or '—' }}</td> {% set poids_piece = j.weight_g / pieces %}
<td class="text-muted small">{{ j.plate_name or '—' }}</td> <tr
<td>{{ j.weight_g }} g</td> data-date="{{ j.created_at[:10] }}"
<td>{{ j.print_time_s | fmt_time }}</td> data-client="{{ j.client_id or '' }}"
<td><span class="badge bg-light text-dark">×{{ j.design_multiplier }}</span></td> data-prix="{{ '%.4f' | format(prix_ht * qty) }}"
<td><span class="badge bg-light text-dark">{{ j.gross_margin_pct }}%</span></td> data-poids="{{ j.weight_g }}"
<td class="text-nowrap small">{% if j.total_marge %}{{ '%.2f'|format(j.total_marge) }} €<br><span class="text-muted">{% if j.marge_pct_on_ht %}{{ j.marge_pct_on_ht }}% du HT{% endif %}</span>{% else %}—{% endif %}</td> data-duree="{{ j.print_time_s }}"
<td> data-final="{{ '%.2f' | format((j.final_price or 0) * qty) }}"
{% if j.discount_pct > 0 %} >
<span class="badge bg-danger">-{{ j.discount_pct }}%</span> <td class="text-muted small align-middle">{{ j.created_at[:10] }}</td>
{% else %}—{% endif %} <td class="align-middle">
<a href="{{ url_for('job_detail', id=j.id) }}" class="text-decoration-none fw-semibold">{{ j.name }}</a>
{% if j.plate_name %}<br><span class="text-muted" style="font-size:.72rem">{{ j.plate_name }}</span>{% endif %}
</td> </td>
<td class="text-end fw-bold" style="color:#ff6b35">{{ j.final_price }}</td> <td class="align-middle small">{{ j.client_name or '--' }}</td>
<td> <td class="align-middle small">
{% if j.material_name %}<span class="badge bg-light text-dark border">{{ j.material_name }}</span>{% else %}<span class="text-muted">--</span>{% endif %}
</td>
<td class="align-middle">
<span class="fw-semibold">{{ j.weight_g }} g</span>
{% if pieces > 1 %}
<br><span class="text-muted" style="font-size:.72rem">{{ '%.1f'|format(poids_piece) }} g/pce x{{ pieces }}</span>
{% else %}
<br><span class="text-muted" style="font-size:.72rem">{{ '%.1f'|format(poids_piece) }} g/pce</span>
{% endif %}
</td>
<td class="align-middle small">{{ j.print_time_s | fmt_time }}</td>
<td class="align-middle text-end">
{% if prix_ht > 0 %}
<span class="fw-semibold">{{ '%.2f'|format(prix_ht) }} &euro;</span><span class="text-muted" style="font-size:.72rem"> /pce</span>
{% if qty > 1 %}
<br><span class="text-muted" style="font-size:.78rem">{{ '%.2f'|format(prix_ht * qty) }} &euro; x{{ qty }}</span>
{% endif %}
{% else %}--{% endif %}
</td>
<td class="align-middle text-end">
<span class="fw-bold" style="color:#ff6b35">{{ '%.2f'|format(j.final_price or 0) }} &euro;</span>
{% if j.discount_pct and j.discount_pct > 0 %}
<span class="badge bg-danger ms-1" style="font-size:.65rem">-{{ j.discount_pct }}%</span>
{% endif %}
{% if qty > 1 %}
<br><span class="text-muted" style="font-size:.72rem">{{ '%.2f'|format((j.final_price or 0) * qty) }} &euro; tot.</span>
{% endif %}
</td>
<td class="align-middle">
<div class="d-flex gap-1 align-items-center"> <div class="d-flex gap-1 align-items-center">
{% if j.client_token %} {% if j.client_token %}
<a href="/portal/{{ j.client_token }}" target="_blank" <a href="/portal/{{ j.client_token }}" target="_blank"
class="btn btn-sm btn-outline-success py-0" class="btn btn-sm btn-outline-success py-0" title="Portail client">
title="Portail client actif — ouvrir">
<i class="bi bi-share-fill"></i> <i class="bi bi-share-fill"></i>
</a> </a>
{% endif %} {% endif %}
<a href="{{ url_for('job_detail', id=j.id) }}" class="btn btn-sm btn-outline-secondary py-0"></a> <a href="{{ url_for('job_detail', id=j.id) }}" class="btn btn-sm btn-outline-secondary py-0">&rarr;</a>
<form method="POST" action="{{ url_for('delete_job', id=j.id) }}" <form method="POST" action="{{ url_for('delete_job', id=j.id) }}"
onsubmit="return confirm('Supprimer ?')"> onsubmit="return confirm('Supprimer ?')">
<button class="btn btn-sm btn-outline-danger py-0"><i class="bi bi-trash"></i></button> <button class="btn btn-sm btn-outline-danger py-0"><i class="bi bi-trash"></i></button>
@@ -65,13 +172,89 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div id="noResults" class="text-center text-muted py-4 d-none">
<i class="bi bi-search fs-2"></i>
<p class="mt-2 mb-0">Aucune commande ne correspond aux filtres.</p>
</div>
{% else %} {% else %}
<div class="text-center text-muted py-5"> <div class="text-center text-muted py-5">
<i class="bi bi-inbox fs-1"></i> <i class="bi bi-inbox fs-1"></i>
<p class="mt-2">Aucune commande enregistrée.</p> <p class="mt-2">Aucune commande enregistree.</p>
<a href="{{ url_for('new_job') }}" class="btn btn-sm" style="background:#ff6b35;color:#fff">Créer le premier calcul</a> <a href="{{ url_for('new_job') }}" class="btn btn-sm" style="background:#ff6b35;color:#fff">Creer le premier calcul</a>
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<script>
(function() {
var body = document.getElementById('jobsBody');
if (!body) return;
var rows = Array.from(body.querySelectorAll('tr'));
var fFrom = document.getElementById('fDateFrom');
var fTo = document.getElementById('fDateTo');
var fCli = document.getElementById('fClient');
var fSort = document.getElementById('fSort');
var noRes = document.getElementById('noResults');
function fmt(v) {
return new Intl.NumberFormat('fr-FR', {minimumFractionDigits:2, maximumFractionDigits:2}).format(v) + ' €';
}
function fmtG(v) {
return new Intl.NumberFormat('fr-FR', {maximumFractionDigits:0}).format(v) + ' g';
}
function apply() {
var from = fFrom.value;
var to = fTo.value;
var client = fCli.value;
var parts = fSort.value.split('-');
var key = parts[0];
var dir = parts[1];
var visible = rows.filter(function(r) {
if (from && r.dataset.date < from) return false;
if (to && r.dataset.date > to) return false;
if (client && r.dataset.client !== client) return false;
return true;
});
visible.sort(function(a, b) {
var va, vb;
if (key === 'date') { va = a.dataset.date; vb = b.dataset.date; }
if (key === 'prix') { va = parseFloat(a.dataset.prix); vb = parseFloat(b.dataset.prix); }
if (key === 'poids') { va = parseFloat(a.dataset.poids); vb = parseFloat(b.dataset.poids); }
if (key === 'duree') { va = parseFloat(a.dataset.duree); vb = parseFloat(b.dataset.duree); }
if (dir === 'asc') return va > vb ? 1 : va < vb ? -1 : 0;
return va < vb ? 1 : va > vb ? -1 : 0;
});
rows.forEach(function(r) { r.style.display = 'none'; });
visible.forEach(function(r) { r.style.display = ''; body.appendChild(r); });
noRes.classList.toggle('d-none', visible.length > 0);
var totalHT = 0, totalFinal = 0, totalPoids = 0;
visible.forEach(function(r) {
totalHT += parseFloat(r.dataset.prix) || 0;
totalFinal += parseFloat(r.dataset.final) || 0;
totalPoids += parseFloat(r.dataset.poids) || 0;
});
document.getElementById('sumCount').textContent = visible.length;
document.getElementById('sumHT').textContent = fmt(totalHT);
document.getElementById('sumFinal').textContent = fmt(totalFinal);
document.getElementById('sumPoids').textContent = fmtG(totalPoids);
}
fFrom.addEventListener('change', apply);
fTo.addEventListener('change', apply);
fCli.addEventListener('change', apply);
fSort.addEventListener('change', apply);
document.getElementById('fReset').addEventListener('click', function() {
fFrom.value = ''; fTo.value = ''; fCli.value = ''; fSort.value = 'date-desc';
apply();
});
apply();
})();
</script>
{% endblock %} {% endblock %}