feat: jobs page filters/sort/summary + poids piece in detail
Deploy via Portainer / deploy (push) Successful in 1s
Deploy via Portainer / deploy (push) Successful in 1s
This commit is contained in:
@@ -1252,14 +1252,13 @@ def jobs():
|
||||
conn = get_db()
|
||||
jobs = conn.execute('''
|
||||
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
|
||||
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()
|
||||
clients = conn.execute('SELECT id, name FROM clients ORDER BY name').fetchall()
|
||||
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'])
|
||||
def new_job():
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
{% endif %}
|
||||
<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 / 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">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>
|
||||
|
||||
+209
-26
@@ -14,46 +14,153 @@
|
||||
</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-body p-0">
|
||||
{% if jobs %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<table class="table table-hover mb-0" id="jobsTable">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Date</th><th>Nom</th><th>Client</th><th>Réf. plateau</th><th>Poids</th>
|
||||
<th>Durée</th><th>Design</th><th>Marge brute</th><th>Total marge</th><th>Remise</th>
|
||||
<th class="text-end">Prix final</th><th></th>
|
||||
<th style="width:90px">Date</th>
|
||||
<th>Nom</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>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody id="jobsBody">
|
||||
{% for j in jobs %}
|
||||
<tr>
|
||||
<td class="text-muted small">{{ j.created_at[:10] }}</td>
|
||||
<td><a href="{{ url_for('job_detail', id=j.id) }}" class="text-decoration-none fw-semibold">{{ j.name }}</a></td>
|
||||
<td>{{ j.client_name or '—' }}</td>
|
||||
<td class="text-muted small">{{ j.plate_name or '—' }}</td>
|
||||
<td>{{ j.weight_g }} g</td>
|
||||
<td>{{ j.print_time_s | fmt_time }}</td>
|
||||
<td><span class="badge bg-light text-dark">×{{ j.design_multiplier }}</span></td>
|
||||
<td><span class="badge bg-light text-dark">{{ j.gross_margin_pct }}%</span></td>
|
||||
<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>
|
||||
<td>
|
||||
{% if j.discount_pct > 0 %}
|
||||
<span class="badge bg-danger">-{{ j.discount_pct }}%</span>
|
||||
{% else %}—{% endif %}
|
||||
{% set pieces = j.pieces_per_plate or 1 %}
|
||||
{% set qty = j.order_qty or 1 %}
|
||||
{% set prix_ht = j.prix_ht_piece or 0 %}
|
||||
{% set poids_piece = j.weight_g / pieces %}
|
||||
<tr
|
||||
data-date="{{ j.created_at[:10] }}"
|
||||
data-client="{{ j.client_id or '' }}"
|
||||
data-prix="{{ '%.4f' | format(prix_ht * qty) }}"
|
||||
data-poids="{{ j.weight_g }}"
|
||||
data-duree="{{ j.print_time_s }}"
|
||||
data-final="{{ '%.2f' | format((j.final_price or 0) * qty) }}"
|
||||
>
|
||||
<td class="text-muted small align-middle">{{ j.created_at[:10] }}</td>
|
||||
<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 class="text-end fw-bold" style="color:#ff6b35">{{ j.final_price }} €</td>
|
||||
<td>
|
||||
<td class="align-middle small">{{ j.client_name or '--' }}</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) }} €</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) }} € 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) }} €</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) }} € tot.</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<div class="d-flex gap-1 align-items-center">
|
||||
{% if j.client_token %}
|
||||
<a href="/portal/{{ j.client_token }}" target="_blank"
|
||||
class="btn btn-sm btn-outline-success py-0"
|
||||
title="Portail client actif — ouvrir">
|
||||
class="btn btn-sm btn-outline-success py-0" title="Portail client">
|
||||
<i class="bi bi-share-fill"></i>
|
||||
</a>
|
||||
{% 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">→</a>
|
||||
<form method="POST" action="{{ url_for('delete_job', id=j.id) }}"
|
||||
onsubmit="return confirm('Supprimer ?')">
|
||||
<button class="btn btn-sm btn-outline-danger py-0"><i class="bi bi-trash"></i></button>
|
||||
@@ -65,13 +172,89 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</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 %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-inbox fs-1"></i>
|
||||
<p class="mt-2">Aucune commande enregistrée.</p>
|
||||
<a href="{{ url_for('new_job') }}" class="btn btn-sm" style="background:#ff6b35;color:#fff">Créer le premier calcul</a>
|
||||
<p class="mt-2">Aucune commande enregistree.</p>
|
||||
<a href="{{ url_for('new_job') }}" class="btn btn-sm" style="background:#ff6b35;color:#fff">Creer le premier calcul</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</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 %}
|
||||
|
||||
Reference in New Issue
Block a user