add history on stats

This commit is contained in:
Tykayn 2025-06-17 19:38:44 +02:00 committed by tykayn
parent 7fb0c9c8c2
commit b61fa6a287
11 changed files with 502 additions and 37 deletions

View file

@ -148,4 +148,9 @@ img {
.mb-3 {
margin-bottom: 1rem !important;
}
}
table tbody {
max-height: 700px;
overflow: auto;
}

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250617165738 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE stats_history ADD names_count INT DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats_history ADD opening_hours_count INT DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats_history ADD website_count INT DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats_history ADD address_count INT DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats_history ADD siret_count INT DEFAULT NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE stats_history DROP names_count
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats_history DROP opening_hours_count
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats_history DROP website_count
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats_history DROP address_count
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats_history DROP siret_count
SQL);
}
}

View file

@ -5,7 +5,7 @@
* @param {string} suggestionListId - ID de la liste des suggestions
* @param {Function} onSelect - Callback appelé lors de la sélection d'une ville
*/
export function setupCitySearch(inputId, suggestionListId, onSelect) {
function setupCitySearch(inputId, suggestionListId, onSelect) {
const searchInput = document.getElementById(inputId);
const suggestionList = document.getElementById(suggestionListId);
@ -75,12 +75,12 @@ export function setupCitySearch(inputId, suggestionListId, onSelect) {
* @param {string} zipCode - Le code postal
* @returns {string} L'URL de labourage
*/
export function getLabourerUrl(zipCode) {
function getLabourerUrl(zipCode) {
return `/admin/labourer/${zipCode}`;
}
// Fonction pour gérer la soumission du formulaire d'ajout de ville
export function handleAddCityFormSubmit(event) {
function handleAddCityFormSubmit(event) {
event.preventDefault();
const form = event.target;
const submitButton = form.querySelector('button[type="submit"]');
@ -101,7 +101,7 @@ export function handleAddCityFormSubmit(event) {
* @param {string} selector - Le sélecteur CSS pour cibler les cellules à colorer
* @param {string} color - La couleur de base en format RGB (ex: '154, 205, 50')
*/
export function colorizePercentageCells(selector, color = '154, 205, 50') {
function colorizePercentageCells(selector, color = '154, 205, 50') {
document.querySelectorAll(selector).forEach(cell => {
const percentage = parseInt(cell.textContent);
if (!isNaN(percentage)) {
@ -116,7 +116,7 @@ export function colorizePercentageCells(selector, color = '154, 205, 50') {
* @param {string} selector - Le sélecteur CSS pour cibler les cellules à colorer
* @param {string} color - La couleur de base en format RGB (ex: '154, 205, 50')
*/
export function colorizePercentageCellsRelative(selector, color = '154, 205, 50') {
function colorizePercentageCellsRelative(selector, color = '154, 205, 50') {
// Récupérer toutes les cellules
const cells = document.querySelectorAll(selector);
@ -145,7 +145,7 @@ export function colorizePercentageCellsRelative(selector, color = '154, 205, 50'
* @param {number} [minFont=0.8] - Taille de police minimale en rem
* @param {number} [maxFont=1.2] - Taille de police maximale en rem
*/
export function adjustListGroupFontSize(selector, minFont = 0.8, maxFont = 1.2) {
function adjustListGroupFontSize(selector, minFont = 0.8, maxFont = 1.2) {
const items = document.querySelectorAll(selector);
const count = items.length;
let fontSize = maxFont;

View file

@ -8,6 +8,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use App\Entity\Place;
use App\Entity\Stats;
use App\Entity\StatsHistory;
use App\Service\Motocultrice;
use Doctrine\ORM\EntityManagerInterface;
use function uuid_create;
@ -146,6 +147,8 @@ final class AdminController extends AbstractController
$city = $this->motocultrice->get_city_osm_from_zip_code($insee_code);
if (!$stats) {
$stats = new Stats();
$stats->setDateCreated(new \DateTime());
$stats->setDateModified(new \DateTime());
$stats->setZone($insee_code)
->setPlacesCount(0)
->setAvecHoraires(0)
@ -234,15 +237,30 @@ final class AdminController extends AbstractController
// Mettre à jour les statistiques finales
$stats->computeCompletionPercent();
if($stats->getDateCreated() == null) {
$stats->setDateCreated(new \DateTime());
}
$stats->setDateModified(new \DateTime());
// Créer un historique des statistiques
$statsHistory = new StatsHistory();
$statsHistory->setDate(new \DateTime())
->setStats($stats);
$statsHistory->setPlacesCount($stats->getPlaces()->count())
->setOpeningHoursCount($stats->getAvecHoraires())
->setAddressCount($stats->getAvecAdresse())
->setWebsiteCount($stats->getAvecSite())
->setSiretCount($stats->getAvecSiret())
// ->setAccessibiliteCount($stats->getAvecAccessibilite())
// ->setNoteCount($stats->getAvecNote())
->setCompletionPercent($stats->getCompletionPercent())
->setStats($stats);
$this->entityManager->persist($statsHistory);
$this->entityManager->persist($stats);
$this->entityManager->flush();
@ -253,10 +271,11 @@ final class AdminController extends AbstractController
$this->addFlash('success', $message);
} catch (\Exception $e) {
$this->addFlash('error', 'Erreur lors du labourage : ' . $e->getMessage());
die(var_dump($e));
}
return $this->redirectToRoute('app_public_dashboard');
// return $this->redirectToRoute('app_admin_stats', ['insee_code' => $insee_code]);
// return $this->redirectToRoute('app_public_dashboard');
return $this->redirectToRoute('app_admin_stats', ['insee_code' => $insee_code]);
}
#[Route('/admin/delete/{id}', name: 'app_admin_delete')]

View file

@ -16,10 +16,10 @@ class Stats
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[ORM\Column(length: 255, type: Types::STRING, nullable: true)]
private ?string $zone = null; // code insee de la zone
#[ORM\Column(type: Types::SMALLINT)]
#[ORM\Column(type: Types::INTEGER, nullable: true)]
private ?int $completion_percent = null;
/**
@ -29,27 +29,27 @@ class Stats
private Collection $places;
// nombre de commerces dans la zone
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
#[ORM\Column(type: Types::INTEGER, nullable: true)]
private ?int $places_count = null;
// nombre de commerces avec horaires
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
#[ORM\Column(type: Types::INTEGER, nullable: true)]
private ?int $avec_horaires = null;
// nombre de commerces avec adresse
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
#[ORM\Column(type: Types::INTEGER, nullable: true)]
private ?int $avec_adresse = null;
// nombre de commerces avec site
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
#[ORM\Column(type: Types::INTEGER, nullable: true)]
private ?int $avec_site = null;
// nombre de commerces avec accessibilité
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
#[ORM\Column(type: Types::INTEGER, nullable: true)]
private ?int $avec_accessibilite = null;
// nombre de commerces avec note
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
#[ORM\Column(type: Types::INTEGER, nullable: true)]
private ?int $avec_note = null;
#[ORM\Column(length: 255, nullable: true)]
@ -59,10 +59,10 @@ class Stats
#[ORM\Column(type: Types::INTEGER, nullable: true)]
private ?int $population = null;
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
#[ORM\Column(type: Types::INTEGER, nullable: true)]
private ?int $siren = null;
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
#[ORM\Column(type: Types::INTEGER, nullable: true)]
private ?int $codeEpci = null;
#[ORM\Column(length: 255, nullable: true)]
@ -74,6 +74,18 @@ class Stats
#[ORM\OneToMany(targetEntity: StatsHistory::class, mappedBy: 'stats')]
private Collection $statsHistories;
#[ORM\Column(nullable: true)]
private ?\DateTime $date_created = null;
#[ORM\Column(nullable: true)]
private ?\DateTime $date_modified = null;
#[ORM\Column(nullable: true)]
private ?int $avec_siret = null;
#[ORM\Column(nullable: true)]
private ?int $avec_name = null;
// calcule le pourcentage de complétion de la zone
public function computeCompletionPercent(): ?int
{
@ -352,6 +364,54 @@ class Stats
return $this;
}
public function getDateCreated(): ?\DateTime
{
return $this->date_created;
}
public function setDateCreated(?\DateTime $date_created): static
{
$this->date_created = $date_created;
return $this;
}
public function getDateModified(): ?\DateTime
{
return $this->date_modified;
}
public function setDateModified(?\DateTime $date_modified): static
{
$this->date_modified = $date_modified;
return $this;
}
public function getAvecSiret(): ?int
{
return $this->avec_siret;
}
public function setAvecSiret(?int $avec_siret): static
{
$this->avec_siret = $avec_siret;
return $this;
}
public function getAvecName(): ?int
{
return $this->avec_name;
}
public function setAvecName(?int $avec_name): static
{
$this->avec_name = $avec_name;
return $this;
}
}

View file

@ -29,6 +29,24 @@ class StatsHistory
#[ORM\ManyToOne(inversedBy: 'statsHistories')]
private ?Stats $stats = null;
#[ORM\Column]
private ?\DateTime $date = null;
#[ORM\Column(nullable: true)]
private ?int $names_count = null;
#[ORM\Column(nullable: true)]
private ?int $opening_hours_count = null;
#[ORM\Column(nullable: true)]
private ?int $website_count = null;
#[ORM\Column(nullable: true)]
private ?int $address_count = null;
#[ORM\Column(nullable: true)]
private ?int $siret_count = null;
public function getId(): ?int
{
@ -95,4 +113,76 @@ class StatsHistory
return $this;
}
public function getDate(): ?\DateTime
{
return $this->date;
}
public function setDate(\DateTime $date): static
{
$this->date = $date;
return $this;
}
public function getNamesCount(): ?int
{
return $this->names_count;
}
public function setNamesCount(?int $names_count): static
{
$this->names_count = $names_count;
return $this;
}
public function getOpeningHoursCount(): ?int
{
return $this->opening_hours_count;
}
public function setOpeningHoursCount(?int $opening_hours_count): static
{
$this->opening_hours_count = $opening_hours_count;
return $this;
}
public function getWebsiteCount(): ?int
{
return $this->website_count;
}
public function setWebsiteCount(?int $website_count): static
{
$this->website_count = $website_count;
return $this;
}
public function getAddressCount(): ?int
{
return $this->address_count;
}
public function setAddressCount(?int $address_count): static
{
$this->address_count = $address_count;
return $this;
}
public function getSiretCount(): ?int
{
return $this->siret_count;
}
public function setSiretCount(?int $siret_count): static
{
$this->siret_count = $siret_count;
return $this;
}
}

View file

@ -24,9 +24,160 @@
{% block javascripts %}
{{ parent() }}
<script src="{{ asset('js/maplibre/maplibre-gl.js') }}"></script>
<script src="{{ asset('js/turf/turf.min.js') }}"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src='{{ asset('js/maplibre/maplibre-gl.js') }}'></script>
<script src="https://unpkg.com/@turf/turf@6/turf.min.js"></script>
<script src="{{ asset('js/map-utils.js') }}"></script>
<script>
// Attendre que le DOM et tous les scripts soient chargés
document.addEventListener('DOMContentLoaded', function() {
// Vérifier que Chart.js est disponible
if (typeof Chart === 'undefined') {
console.error('Chart.js n\'est pas chargé');
return;
}
// Vérifier que les fonctions sont disponibles
if (typeof calculateCompletion === 'undefined') {
console.error('La fonction calculateCompletion n\'est pas définie');
return;
}
let map;
let dropMarkers = [];
let currentMarkerType = 'drop';
let completionChart;
let contextMenu;
let selectedFeature = null;
// Fonction pour calculer la distribution des taux de complétion
function calculateCompletionDistribution(features) {
const buckets = Array(11).fill(0); // 0-10%, 11-20%, ..., 91-100%
features.forEach(feature => {
const completion = calculateCompletion(feature.properties);
const bucketIndex = Math.min(Math.floor(completion.percentage / 10), 10);
buckets[bucketIndex]++;
});
return buckets;
}
// Fonction pour créer le graphique de complétion
function createCompletionChart(features) {
const ctx = document.getElementById('completionChart').getContext('2d');
const distribution = calculateCompletionDistribution(features);
if (completionChart) {
completionChart.destroy();
}
completionChart = new Chart(ctx, {
type: 'bar',
data: {
labels: ['0-10%', '11-20%', '21-30%', '31-40%', '41-50%', '51-60%', '61-70%', '71-80%', '81-90%', '91-100%'],
datasets: [{
label: 'Nombre de lieux',
data: distribution,
backgroundColor: 'rgba(75, 192, 192, 0.2)',
borderColor: 'rgba(75, 192, 192, 1)',
borderWidth: 1
}]
},
options: {
responsive: true,
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1
}
}
}
}
});
}
// Fonction pour charger les lieux depuis l'API Overpass
async function loadPlaces() {
try {
const response = await fetch('https://overpass-api.de/api/interpreter?data={{query_places|raw}}');
const data = await response.json();
if (data.features && data.features.length > 0) {
// Mettre à jour les statistiques
const totallieux = data.features.length;
document.getElementById('totallieux').textContent = totallieux;
// Calculer et afficher la distribution des taux de complétion
createCompletionChart(data.features);
// Mettre à jour les marqueurs sur la carte
dropMarkers = updateMarkers(data.features, map, currentMarkerType, dropMarkers, data);
}
} catch (error) {
console.error('Erreur lors du chargement des lieux:', error);
}
}
// Initialisation de la carte
map = new maplibregl.Map({
container: 'map',
style: 'https://api.maptiler.com/maps/basic-v2/style.json?key={{ maptiler_token }}',
center: [2.3522, 48.8566], // Paris
zoom: 12
});
// Ajouter les contrôles de navigation
map.addControl(new maplibregl.NavigationControl());
// Gestionnaire d'événements pour le menu contextuel
map.on('contextmenu', function(e) {
e.preventDefault();
const features = map.queryRenderedFeatures(e.point, {
layers: ['markers', 'circles']
});
if (features.length > 0) {
selectedFeature = features[0];
const popup = new maplibregl.Popup()
.setLngLat(e.lngLat)
.setHTML(`
<div class="context-menu">
<button onclick="window.location.href='{{ path('app_admin_labourer', {'insee_code': 'ZONE_CODE'}) }}'.replace('ZONE_CODE', '${selectedFeature.properties.insee_code}')">
Labourer cette zone
</button>
</div>
`)
.addTo(map);
}
});
// Gestionnaire d'événements pour le clic sur la carte
map.on('click', function(e) {
const features = map.queryRenderedFeatures(e.point, {
layers: ['markers', 'circles']
});
if (features.length > 0) {
const popup = new maplibregl.Popup()
.setLngLat(e.lngLat)
.setHTML(createPopupContent(features[0].properties))
.addTo(map);
}
});
// Gestionnaire d'événements pour le bouton de changement de type de marqueur
document.getElementById('toggleMarkerType').addEventListener('click', function() {
currentMarkerType = currentMarkerType === 'drop' ? 'circle' : 'drop';
this.textContent = currentMarkerType === 'drop' ? 'Afficher en cercles' : 'Afficher en points';
loadPlaces();
});
// Charger les lieux au démarrage
loadPlaces();
});
</script>
{% endblock %}
{% block body %}
@ -60,7 +211,7 @@
</div>
<div class="col-md-4 col-12">
<span class="badge bg-success">
<i class="bi bi-pencil-square"></i> {{ stats.getAvecNote() }} / {{ stats.places|length }} commerces avec note
<i class="bi bi-pencil-square"></i> {{ stats.getAvecNote() }} / {{ stats.places|length }} lieux avec note
</span>
</div>
</div>
@ -75,38 +226,38 @@
<div class="col-md-3 col-12">
<span class="badge bg-primary">
<i class="bi bi-building"></i> {{ stats.places | length}}
</span>commerces dans la zone.
</span>lieux dans la zone.
</div>
<div class="col-md-3 col-12">
<span class="badge bg-primary">
<i class="bi bi-clock"></i> {{ stats.getAvecHoraires() }}
</span>
commerces avec horaires.
lieux avec horaires.
</div>
<div class="col-md-3 col-12">
<span class="badge bg-primary">
<i class="bi bi-map"></i> {{ stats.getAvecAdresse() }}
</span>
commerces avec adresse.
lieux avec adresse.
</div>
<div class="col-md-3 col-12">
<span class="badge bg-primary">
<i class="bi bi-globe"></i> {{ stats.getAvecSite() }}
</span>
commerces avec site web renseigné.
lieux avec site web renseigné.
</div>
<div class="col-md-3 col-12">
<span class="badge bg-primary">
<i class="bi bi-arrow-up-right"></i>
{{ stats.getAvecAccessibilite() }}
</span>
commerces avec accessibilité PMR renseignée.
lieux avec accessibilité PMR renseignée.
</div>
<div class="col-md-3 col-12">
<span class="badge bg-primary">
<i class="bi bi-chat-dots"></i> {{ stats.getAvecNote() }}
</span>
commerces avec note renseignée.
lieux avec note renseignée.
</div>
</div>
@ -134,9 +285,10 @@
</div>
</div>
<div class="card mt-4">
{% include 'admin/stats_history.html.twig' %}
<div id="distribution_completion" class="mt-4 mb-4"></div>
<div class="row">
<div class="col-md-6 col-12">
<h1 class="card-title p-4">Tableau des {{ stats.places |length }} lieux</h1>
@ -179,6 +331,14 @@
<th>Date</th>
<th>Places</th>
<th>Complétion</th>
<th>Emails count</th>
<th>Emails sent</th>
<th>Opening hours</th>
<th>Address</th>
<th>Website</th>
<th>Siret</th>
{# <th>Accessibilite</th> #}
{# <th>Note</th> #}
</tr>
</thead>
<tbody>
@ -187,6 +347,14 @@
<td>{{ stat.date|date('d/m/Y') }}</td>
<td>{{ stat.placesCount }}</td>
<td>{{ stat.completionPercent }}%</td>
<td>{{ stat.emailsCount }}</td>
<td>{{ stat.emailsSent }}</td>
<td>{{ stat.openingHoursCount }}</td>
<td>{{ stat.addressCount }}</td>
<td>{{ stat.websiteCount }}</td>
<td>{{ stat.siretCount }}</td>
{# <td>{{ stat.accessibiliteCount }}</td> #}
{# <td>{{ stat.noteCount }}</td> #}
</tr>
{% endfor %}
</tbody>
@ -255,7 +423,7 @@
data: {
labels: ['0-10%', '10-20%', '20-30%', '30-40%', '40-50%', '50-60%', '60-70%', '70-80%', '80-90%', '90-100%'],
datasets: [{
label: 'Nombre de commerces',
label: 'Nombre de lieux',
data: distribution,
backgroundColor: 'rgba(0, 128, 0, 0.1)',
borderColor: 'rgba(0, 128, 0, 1)',
@ -277,7 +445,7 @@
beginAtZero: true,
title: {
display: true,
text: 'Nombre de commerces'
text: 'Nombre de lieux'
}
},
x: {
@ -290,7 +458,7 @@
plugins: {
title: {
display: true,
text: 'Distribution du taux de complétion des commerces'
text: 'Distribution du taux de complétion des lieux'
}
}
}

View file

@ -0,0 +1,62 @@
<div class="card mt-4">
<div class="card-header">
<h2>Évolution du taux de complétion</h2>
</div>
<div class="card-body">
<canvas id="completionHistoryChart"></canvas>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const ctx = document.getElementById('completionHistoryChart').getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: [
{% for stat in statsHistory %}
'{{ stat.date|date('d/m/Y') }}'{% if not loop.last %},{% endif %}
{% endfor %}
],
datasets: [{
label: 'Taux de complétion (%)',
data: [
{% for stat in statsHistory %}
{{ stat.completionPercent }}{% if not loop.last %},{% endif %}
{% endfor %}
],
borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.2)',
tension: 0.3,
fill: true
}]
},
options: {
responsive: true,
plugins: {
title: {
display: true,
text: 'Évolution du taux de complétion au fil du temps'
}
},
scales: {
y: {
beginAtZero: true,
max: 100,
title: {
display: true,
text: 'Complétion (%)'
}
},
x: {
title: {
display: true,
text: 'Date'
}
}
}
}
});
});
</script>

View file

@ -53,9 +53,8 @@
{% block javascripts %}
{{ parent() }}
<script src='{{ asset('js/maplibre/maplibre-gl.js') }}'></script>
<script type="module">
import { colorizePercentageCells, setupCitySearch, getLabourerUrl, handleAddCityFormSubmit, colorizePercentageCellsRelative } from '{{ asset('js/utils.js') }}';
<script src='{{ asset('js/utils.js') }}'></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialiser les tooltips Bootstrap
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))

View file

@ -113,7 +113,10 @@
<span class="zone">{{ stat.zone }}</span>
<span class="name">{{ stat.name }}</span>
</div>
<span class="badge bg-primary rounded-pill">{{ stat.placesCount }} lieux</span>
<div class="d-flex">
<span class="badge bg-primary rounded-pill">{{ stat.placesCount }} lieux</span>
<span class="badge rounded-pill completion {% if stat.completionPercent > 80 %}bg-success{% else %}bg-info{% endif %}" >{{ stat.completionPercent }}%</span>
</div>
</a>
{% endfor %}

View file

@ -86,4 +86,4 @@ display:
note: "Champ libre, pour des informations complémentaires. Ne pas mettre de publicité"
disclaimer:
title: "Attention"
text: "Ce site est un travail en cours, ceci est une démonstration sur un objet fictif qui utilise les données de l'API OpenStreetMap."
text: "Ce site est un travail en cours, qui utilise les données de l'API OpenStreetMap."