Ajout d'un dashboard lecture seule par-dessus la DB SQLite existante. Fichiers créés : - tickettracker/web/queries.py : 7 fonctions SQL (stats, compare, historique...) - tickettracker/web/api.py : router /api/* JSON (FastAPI) - tickettracker/web/app.py : routes HTML + Jinja2 + point d'entrée uvicorn - tickettracker/web/templates/ : base.html, index.html, compare.html, product.html, receipt.html - tickettracker/web/static/style.css : personnalisations Pico CSS - tests/test_web.py : 19 tests (96 passent, 1 xfail OCR) Fichiers modifiés : - requirements.txt : +fastapi, uvicorn[standard], jinja2, python-multipart, httpx - config.py : +DB_PATH (lu depuis TICKETTRACKER_DB_PATH) Lancement : python -m tickettracker.web.app Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
119 lines
3.4 KiB
HTML
119 lines
3.4 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}
|
|
{% if data %}{{ data.name }}{% else %}Produit inconnu{% endif %} — TicketTracker
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<h1>Historique produit</h1>
|
|
|
|
<!-- Sélecteur de produit : navigation JS vers /product/<nom encodé> -->
|
|
<label for="product-select">Choisir un produit :</label>
|
|
<select id="product-select" onchange="window.location='/product/' + encodeURIComponent(this.value)">
|
|
<option value="">— sélectionner —</option>
|
|
{% for p in all_products %}
|
|
<option value="{{ p }}" {% if p == name %}selected{% endif %}>{{ p }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
|
|
{% if empty %}
|
|
<article>
|
|
<p>Produit <strong>{{ name }}</strong> introuvable dans la base.</p>
|
|
<p>Le nom doit correspondre exactement à un <code>name_normalized</code> existant.</p>
|
|
</article>
|
|
{% else %}
|
|
|
|
<!-- Cartes statistiques -->
|
|
<div class="stat-grid">
|
|
<article class="stat-card">
|
|
<h3>{{ "%.2f"|format(data.min_price) }} €</h3>
|
|
<p>Prix minimum</p>
|
|
</article>
|
|
<article class="stat-card">
|
|
<h3>{{ "%.2f"|format(data.avg_price) }} €</h3>
|
|
<p>Prix moyen</p>
|
|
</article>
|
|
<article class="stat-card">
|
|
<h3>{{ "%.2f"|format(data.max_price) }} €</h3>
|
|
<p>Prix maximum</p>
|
|
</article>
|
|
</div>
|
|
|
|
<!-- Graphique d'historique des prix -->
|
|
<article>
|
|
<h2>Évolution du prix unitaire</h2>
|
|
<div class="chart-container">
|
|
<canvas id="priceChart"></canvas>
|
|
</div>
|
|
</article>
|
|
|
|
<script>
|
|
(function () {
|
|
const history = {{ data.history | tojson }};
|
|
|
|
// Couleurs par enseigne
|
|
const colors = { picnic: "#4a9eff", leclerc: "#ff6b35" };
|
|
const stores = [...new Set(history.map(h => h.store))].sort();
|
|
|
|
const datasets = stores.map(store => {
|
|
const points = history.filter(h => h.store === store);
|
|
return {
|
|
label: store,
|
|
data: points.map(h => ({ x: h.date, y: h.unit_price })),
|
|
borderColor: colors[store] || "#888",
|
|
backgroundColor: (colors[store] || "#888") + "33",
|
|
tension: 0.2,
|
|
fill: false,
|
|
};
|
|
});
|
|
|
|
new Chart(document.getElementById("priceChart").getContext("2d"), {
|
|
type: "line",
|
|
data: { datasets },
|
|
options: {
|
|
responsive: true,
|
|
scales: {
|
|
x: { type: "category", title: { display: true, text: "Date" } },
|
|
y: {
|
|
title: { display: true, text: "Prix unitaire (€)" },
|
|
ticks: { callback: v => v + " €" }
|
|
}
|
|
},
|
|
plugins: { legend: { position: "top" } }
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
|
|
<!-- Tableau des occurrences -->
|
|
<article>
|
|
<h2>Toutes les occurrences</h2>
|
|
<div class="overflow-auto">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Date</th>
|
|
<th>Enseigne</th>
|
|
<th>Prix unitaire</th>
|
|
<th>Quantité</th>
|
|
<th>Unité</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for h in data.history %}
|
|
<tr>
|
|
<td>{{ h.date }}</td>
|
|
<td>{{ h.store }}</td>
|
|
<td>{{ "%.2f"|format(h.unit_price) }} €</td>
|
|
<td>{{ h.quantity }}</td>
|
|
<td>{{ h.unit }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</article>
|
|
|
|
{% endif %}
|
|
{% endblock %}
|