retapage accueil, gestion de Demandes

This commit is contained in:
Tykayn 2025-07-16 17:00:09 +02:00 committed by tykayn
parent d777221d0d
commit f4c5e048ff
26 changed files with 2498 additions and 292 deletions

View file

@ -148,6 +148,8 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/extra-bundle" /> <excludeFolder url="file://$MODULE_DIR$/vendor/twig/extra-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/twig" /> <excludeFolder url="file://$MODULE_DIR$/vendor/twig/twig" />
<excludeFolder url="file://$MODULE_DIR$/vendor/webmozart/assert" /> <excludeFolder url="file://$MODULE_DIR$/vendor/webmozart/assert" />
<excludeFolder url="file://$MODULE_DIR$/public/build" />
<excludeFolder url="file://$MODULE_DIR$/var" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />

6
.idea/symfony2.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Symfony2PluginSettings">
<option name="pluginEnabled" value="true" />
</component>
</project>

60
clean_duplicate_stats.sql Normal file
View file

@ -0,0 +1,60 @@
-- Script SQL pour désactiver temporairement la contrainte d'unicité sur l'INSEE de ville,
-- supprimer les doublons, et réactiver la contrainte
-- ATTENTION: Ce script supprime des enregistrements de la table stats.
-- Les entités liées (CityFollowUp, Place) seront également supprimées si des contraintes
-- de clé étrangère avec ON DELETE CASCADE sont définies dans la base de données.
-- Assurez-vous de faire une sauvegarde de la base de données avant d'exécuter ce script.
-- 1. Désactiver temporairement la contrainte d'unicité
ALTER TABLE stats DROP CONSTRAINT uniq_stats_zone;
-- 2. Identifier et supprimer les doublons, en gardant l'entrée la plus ancienne
-- Créer une table temporaire pour stocker les IDs à supprimer
CREATE TEMP TABLE stats_to_delete AS
WITH duplicates AS (
SELECT
id,
zone,
date_created,
ROW_NUMBER() OVER (
PARTITION BY zone
ORDER BY
-- Garder l'entrée la plus ancienne si date_created existe
CASE WHEN date_created IS NOT NULL THEN 0 ELSE 1 END,
date_created,
-- Si date_created est NULL, utiliser l'ID le plus petit (probablement le plus ancien)
id
) AS row_num
FROM stats
WHERE zone IS NOT NULL
)
SELECT id FROM duplicates WHERE row_num > 1;
-- Afficher le nombre de doublons qui seront supprimés
SELECT COUNT(*) AS "Nombre de doublons à supprimer" FROM stats_to_delete;
-- Afficher les détails des doublons qui seront supprimés (pour vérification)
SELECT s.id, s.zone, s.name, s.date_created
FROM stats s
JOIN stats_to_delete std ON s.id = std.id
ORDER BY s.zone, s.id;
-- 3. Supprimer les doublons
-- Note: Nous utilisons DELETE ... USING pour éviter les problèmes de contraintes de clé étrangère
DELETE FROM stats
USING stats_to_delete
WHERE stats.id = stats_to_delete.id;
-- 4. Nettoyer la table temporaire
DROP TABLE stats_to_delete;
-- 5. Réactiver la contrainte d'unicité
ALTER TABLE stats ADD CONSTRAINT uniq_stats_zone UNIQUE (zone);
-- 6. Vérifier qu'il n'y a plus de doublons
SELECT zone, COUNT(*)
FROM stats
WHERE zone IS NOT NULL
GROUP BY zone
HAVING COUNT(*) > 1;

View file

@ -0,0 +1,62 @@
# Script de nettoyage des doublons de villes
Ce dossier contient un script SQL pour résoudre le problème de contrainte d'unicité sur le code INSEE des villes dans la table `stats`.
## Problème
Lorsque vous rencontrez l'erreur suivante :
```
SQLSTATE[23505]: Unique violation: 7 ERREUR: n'a pas pu créer l'index unique « uniq_stats_zone »
DETAIL: La clé (zone)=(91111) est dupliquée.
```
Cela signifie qu'il existe des doublons dans la table `stats` avec le même code INSEE (colonne `zone`), ce qui empêche la création de la contrainte d'unicité.
## Solution
Le script `clean_duplicate_stats.sql` permet de :
1. Désactiver temporairement la contrainte d'unicité
2. Identifier et supprimer les doublons de villes, en gardant l'entrée la plus ancienne
3. Réactiver la contrainte d'unicité
## Comment utiliser le script
### Précautions importantes
⚠️ **ATTENTION** : Ce script supprime des données de la base. Assurez-vous de faire une sauvegarde complète de votre base de données avant de l'exécuter.
Les entités liées aux enregistrements `Stats` supprimés (comme `CityFollowUp` et `Place`) seront également supprimées si des contraintes de clé étrangère avec `ON DELETE CASCADE` sont définies dans la base de données.
### Exécution du script
Pour exécuter le script sur une base PostgreSQL :
```bash
psql -U username -d database_name -f clean_duplicate_stats.sql
```
Remplacez `username` par votre nom d'utilisateur PostgreSQL et `database_name` par le nom de votre base de données.
### Vérification
Le script inclut des requêtes pour :
- Afficher le nombre de doublons qui seront supprimés
- Afficher les détails des doublons avant suppression
- Vérifier qu'il n'y a plus de doublons après l'opération
## Fonctionnement technique
Le script :
1. Désactive la contrainte d'unicité `uniq_stats_zone`
2. Crée une table temporaire pour identifier les doublons
3. Utilise `ROW_NUMBER()` pour conserver l'entrée la plus ancienne de chaque groupe de doublons
4. Supprime les doublons identifiés
5. Réactive la contrainte d'unicité
6. Vérifie qu'il n'y a plus de doublons
## Après l'exécution
Une fois le script exécuté avec succès, vous ne devriez plus rencontrer l'erreur de violation de contrainte d'unicité lors de l'exécution des commandes Symfony.

View file

@ -0,0 +1,101 @@
<?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 Version20250716124008 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'
CREATE TABLE demande (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, query VARCHAR(255) NOT NULL, email VARCHAR(255) DEFAULT NULL, insee INT DEFAULT NULL, PRIMARY KEY(id))
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER email TYPE VARCHAR(255)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER note TYPE TEXT
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER name TYPE VARCHAR(255)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER note_content TYPE VARCHAR(255)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER street TYPE VARCHAR(255)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER housenumber TYPE VARCHAR(255)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER siret TYPE VARCHAR(255)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER osm_user TYPE VARCHAR(255)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER email_content TYPE TEXT
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats ALTER name TYPE VARCHAR(255)
SQL);
$this->addSql(<<<'SQL'
CREATE UNIQUE INDEX uniq_stats_zone ON stats (zone)
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
DROP TABLE demande
SQL);
$this->addSql(<<<'SQL'
DROP INDEX uniq_stats_zone
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats ALTER name TYPE VARCHAR(255)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER email TYPE VARCHAR(255)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER note TYPE TEXT
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER name TYPE VARCHAR(255)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER note_content TYPE VARCHAR(255)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER street TYPE VARCHAR(255)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER housenumber TYPE VARCHAR(255)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER siret TYPE VARCHAR(255)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER osm_user TYPE VARCHAR(255)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ALTER email_content TYPE TEXT
SQL);
}
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Add osmObjectType and osmId fields to demande table
*/
final class Version20250716160000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add osmObjectType and osmId fields to demande table';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE demande ADD osm_object_type VARCHAR(10) DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE demande ADD osm_id 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 demande DROP osm_object_type
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE demande DROP osm_id
SQL);
}
}

View file

@ -60,6 +60,48 @@
padding: 0; padding: 0;
} }
/* Home page specific styles */
.hero-image {
max-width: 100%;
height: auto;
}
.feature-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 4rem;
height: 4rem;
margin-bottom: 1rem;
font-size: 2rem;
color: #fff;
border-radius: 0.75rem;
background-color: #0d6efd;
}
.step-circle {
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border-radius: 50%;
background-color: #0d6efd;
color: white;
font-weight: bold;
font-size: 1.25rem;
margin-right: 1rem;
}
.card {
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1) !important;
}
/* Media queries */ /* Media queries */
@media (max-width: 768px) { @media (max-width: 768px) {
.main-header h1 { .main-header h1 {
@ -69,4 +111,13 @@
.main-footer { .main-footer {
padding: 1.5rem 0; padding: 1.5rem 0;
} }
.display-4 {
font-size: 2.5rem;
}
.hero-image {
max-height: 200px;
margin-top: 2rem;
}
} }

View file

@ -0,0 +1,225 @@
<?php
namespace App\Command;
use App\Entity\Stats;
use App\Entity\CityFollowUp;
use App\Service\Motocultrice;
use App\Service\FollowUpService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:update-most-recent-city-followup',
description: 'Met à jour les followups de la ville qui a une mesure récente qui date le plus, en priorisant les villes sans followup de type "rnb"'
)]
class UpdateMostRecentCityFollowupCommand extends Command
{
public function __construct(
private EntityManagerInterface $entityManager,
private Motocultrice $motocultrice,
private FollowUpService $followUpService
) {
parent::__construct();
}
protected function configure(): void
{
$this->setHelp('Cette commande trouve la ville avec la mesure la plus récente, en priorisant les villes sans followup de type "rnb", et met à jour ses followups. Elle nettoie d\'abord les doublons de Stats pour éviter les violations de contrainte unique.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Mise à jour des followups pour la ville avec la mesure la plus récente');
// 1. Nettoyer les doublons de Stats pour éviter les violations de contrainte unique
$io->section('Nettoyage des doublons de Stats');
$statsByZone = [];
$statsRepo = $this->entityManager->getRepository(Stats::class);
$allStats = $statsRepo->findAll();
// Regrouper par code INSEE
foreach ($allStats as $stat) {
$zone = $stat->getZone();
if (!$zone) continue;
$statsByZone[$zone][] = $stat;
}
$toDelete = [];
$toKeep = [];
foreach ($statsByZone as $zone => $statsList) {
if (count($statsList) > 1) {
// Trier par date_created (le plus ancien d'abord), puis par id si date absente
usort($statsList, function($a, $b) {
$da = $a->getDateCreated();
$db = $b->getDateCreated();
if ($da && $db) {
return $da <=> $db;
} elseif ($da) {
return -1;
} elseif ($db) {
return 1;
} else {
return $a->getId() <=> $b->getId();
}
});
// Garder le premier, supprimer les autres
$toKeep[$zone] = $statsList[0];
$toDelete[$zone] = array_slice($statsList, 1);
}
}
$totalToDelete = array_sum(array_map('count', $toDelete));
if ($totalToDelete === 0) {
$io->success('Aucun doublon trouvé.');
} else {
$io->info("$totalToDelete doublons de Stats trouvés à supprimer.");
foreach ($toDelete as $statsList) {
foreach ($statsList as $stat) {
$this->entityManager->remove($stat);
}
}
$this->entityManager->flush();
$io->success("$totalToDelete doublons supprimés avec succès.");
}
// 2. Récupérer toutes les villes (Stats) sans utiliser les colonnes problématiques
try {
$allStats = $statsRepo->findAllCitiesWithoutLabourage();
} catch (\Exception $e) {
$io->error('Erreur lors de la récupération des villes: ' . $e->getMessage());
return Command::FAILURE;
}
if (empty($allStats)) {
$io->error('Aucune ville trouvée dans la base de données.');
return Command::FAILURE;
}
$io->info('Nombre de villes trouvées: ' . count($allStats));
// 3. Identifier les villes sans followup de type "rnb"
$citiesWithoutRnb = [];
$citiesWithRnb = [];
foreach ($allStats as $stats) {
$hasRnb = false;
foreach ($stats->getCityFollowUps() as $followup) {
if ($followup->getName() === 'rnb_count' || $followup->getName() === 'rnb_completion') {
$hasRnb = true;
break;
}
}
if (!$hasRnb) {
$citiesWithoutRnb[] = $stats;
} else {
$citiesWithRnb[] = $stats;
}
}
$io->info('Villes sans followup "rnb": ' . count($citiesWithoutRnb));
$io->info('Villes avec followup "rnb": ' . count($citiesWithRnb));
// 4. Trouver la ville avec la mesure la plus récente
$selectedCity = null;
$latestDate = null;
// D'abord, chercher parmi les villes sans followup "rnb"
if (!empty($citiesWithoutRnb)) {
foreach ($citiesWithoutRnb as $stats) {
$latestFollowup = $this->findLatestFollowup($stats);
if ($latestFollowup && ($latestDate === null || $latestFollowup->getDate() > $latestDate)) {
$selectedCity = $stats;
$latestDate = $latestFollowup->getDate();
}
}
}
// Si aucune ville sans followup "rnb" n'a été trouvée, chercher parmi toutes les villes
if ($selectedCity === null) {
foreach ($allStats as $stats) {
$latestFollowup = $this->findLatestFollowup($stats);
if ($latestFollowup && ($latestDate === null || $latestFollowup->getDate() > $latestDate)) {
$selectedCity = $stats;
$latestDate = $latestFollowup->getDate();
}
}
}
if ($selectedCity === null) {
$io->error('Aucune ville avec des followups n\'a été trouvée.');
return Command::FAILURE;
}
$io->section('Ville sélectionnée: ' . $selectedCity->getName() . ' (' . $selectedCity->getZone() . ')');
if ($latestDate) {
$io->info('Date de la dernière mesure: ' . $latestDate->format('Y-m-d H:i:s'));
}
// 5. Mettre à jour les followups pour la ville sélectionnée
$io->section('Mise à jour des followups');
try {
$this->followUpService->generateCityFollowUps(
$selectedCity,
$this->motocultrice,
$this->entityManager
);
$io->success('Followups mis à jour avec succès pour ' . $selectedCity->getName());
// Afficher les résultats
$newFollowups = $selectedCity->getCityFollowUps();
$io->section('Résultats');
$table = [];
foreach ($newFollowups as $followup) {
if ($followup->getDate() > (new \DateTime())->modify('-1 hour')) {
$table[] = [
$followup->getName(),
$followup->getMeasure(),
$followup->getDate()->format('Y-m-d H:i:s')
];
}
}
if (!empty($table)) {
$io->table(['Nom', 'Valeur', 'Date'], $table);
} else {
$io->info('Aucun nouveau followup généré dans la dernière heure.');
}
} catch (\Exception $e) {
$io->error('Erreur lors de la mise à jour des followups: ' . $e->getMessage());
return Command::FAILURE;
}
return Command::SUCCESS;
}
/**
* Trouve le followup le plus récent pour une ville donnée
*/
private function findLatestFollowup(Stats $stats): ?CityFollowUp
{
$latestFollowup = null;
foreach ($stats->getCityFollowUps() as $followup) {
if ($latestFollowup === null || $followup->getDate() > $latestFollowup->getDate()) {
$latestFollowup = $followup;
}
}
return $latestFollowup;
}
}

View file

@ -0,0 +1,174 @@
<?php
namespace App\Command;
use App\Entity\Stats;
use App\Entity\CityFollowUp;
use App\Service\Motocultrice;
use App\Service\FollowUpService;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\DBAL\Exception\TableNotFoundException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:update-recent-city-followup',
description: 'Met à jour les followups de la ville qui a une mesure récente qui date le plus, en priorisant les villes sans followup de type "rnb"'
)]
class UpdateRecentCityFollowupCommand extends Command
{
public function __construct(
private EntityManagerInterface $entityManager,
private Motocultrice $motocultrice,
private FollowUpService $followUpService
) {
parent::__construct();
}
protected function configure(): void
{
$this->setHelp('Cette commande trouve la ville avec la mesure la plus récente, en priorisant les villes sans followup de type "rnb", et met à jour ses followups.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Mise à jour des followups pour la ville avec la mesure la plus récente');
// 1. Récupérer toutes les villes (Stats) sans utiliser les colonnes problématiques
$statsRepo = $this->entityManager->getRepository(Stats::class);
try {
$allStats = $statsRepo->findAllCitiesWithoutLabourage();
} catch (\Exception $e) {
$io->error('Erreur lors de la récupération des villes: ' . $e->getMessage());
return Command::FAILURE;
}
if (empty($allStats)) {
$io->error('Aucune ville trouvée dans la base de données.');
return Command::FAILURE;
}
$io->info('Nombre de villes trouvées: ' . count($allStats));
// 2. Identifier les villes sans followup de type "rnb"
$citiesWithoutRnb = [];
$citiesWithRnb = [];
foreach ($allStats as $stats) {
$hasRnb = false;
foreach ($stats->getCityFollowUps() as $followup) {
if ($followup->getName() === 'rnb_count' || $followup->getName() === 'rnb_completion') {
$hasRnb = true;
break;
}
}
if (!$hasRnb) {
$citiesWithoutRnb[] = $stats;
} else {
$citiesWithRnb[] = $stats;
}
}
$io->info('Villes sans followup "rnb": ' . count($citiesWithoutRnb));
$io->info('Villes avec followup "rnb": ' . count($citiesWithRnb));
// 3. Trouver la ville avec la mesure la plus récente
$selectedCity = null;
$latestDate = null;
// D'abord, chercher parmi les villes sans followup "rnb"
if (!empty($citiesWithoutRnb)) {
foreach ($citiesWithoutRnb as $stats) {
$latestFollowup = $this->findLatestFollowup($stats);
if ($latestFollowup && ($latestDate === null || $latestFollowup->getDate() > $latestDate)) {
$selectedCity = $stats;
$latestDate = $latestFollowup->getDate();
}
}
}
// Si aucune ville sans followup "rnb" n'a été trouvée, chercher parmi toutes les villes
if ($selectedCity === null) {
foreach ($allStats as $stats) {
$latestFollowup = $this->findLatestFollowup($stats);
if ($latestFollowup && ($latestDate === null || $latestFollowup->getDate() > $latestDate)) {
$selectedCity = $stats;
$latestDate = $latestFollowup->getDate();
}
}
}
if ($selectedCity === null) {
$io->error('Aucune ville avec des followups n\'a été trouvée.');
return Command::FAILURE;
}
$io->section('Ville sélectionnée: ' . $selectedCity->getName() . ' (' . $selectedCity->getZone() . ')');
if ($latestDate) {
$io->info('Date de la dernière mesure: ' . $latestDate->format('Y-m-d H:i:s'));
}
// 4. Mettre à jour les followups pour la ville sélectionnée
$io->section('Mise à jour des followups');
try {
$this->followUpService->generateCityFollowUps(
$selectedCity,
$this->motocultrice,
$this->entityManager
);
$io->success('Followups mis à jour avec succès pour ' . $selectedCity->getName());
// Afficher les résultats
$newFollowups = $selectedCity->getCityFollowUps();
$io->section('Résultats');
$table = [];
foreach ($newFollowups as $followup) {
if ($followup->getDate() > (new \DateTime())->modify('-1 hour')) {
$table[] = [
$followup->getName(),
$followup->getMeasure(),
$followup->getDate()->format('Y-m-d H:i:s')
];
}
}
if (!empty($table)) {
$io->table(['Nom', 'Valeur', 'Date'], $table);
} else {
$io->info('Aucun nouveau followup généré dans la dernière heure.');
}
} catch (\Exception $e) {
$io->error('Erreur lors de la mise à jour des followups: ' . $e->getMessage());
return Command::FAILURE;
}
return Command::SUCCESS;
}
/**
* Trouve le followup le plus récent pour une ville donnée
*/
private function findLatestFollowup(Stats $stats): ?CityFollowUp
{
$latestFollowup = null;
foreach ($stats->getCityFollowUps() as $followup) {
if ($latestFollowup === null || $followup->getDate() > $latestFollowup->getDate()) {
$latestFollowup = $followup;
}
}
return $latestFollowup;
}
}

View file

@ -1908,4 +1908,164 @@ final class AdminController extends AbstractController
'maptiler_token' => $_ENV['MAPTILER_TOKEN'] ?? null, 'maptiler_token' => $_ENV['MAPTILER_TOKEN'] ?? null,
]); ]);
} }
#[Route('/admin/demandes', name: 'app_admin_demandes')]
public function listDemandes(Request $request): Response
{
$status = $request->query->get('status');
$repository = $this->entityManager->getRepository(\App\Entity\Demande::class);
if ($status) {
$demandes = $repository->findByStatus($status);
} else {
$demandes = $repository->findAllOrderedByCreatedAt();
}
// Get all possible statuses for the filter
$allStatuses = ['new', 'email_provided', 'ready', 'email_sent', 'email_failed', 'email_opened', 'edit_form_opened', 'place_modified', 'linked_to_place'];
// Count demandes for each status
$statusCounts = [];
foreach ($allStatuses as $statusValue) {
$statusCounts[$statusValue] = $repository->findByStatus($statusValue);
}
// Get total count
$totalCount = $repository->findAllOrderedByCreatedAt();
return $this->render('admin/demandes/list.html.twig', [
'demandes' => $demandes,
'current_status' => $status,
'all_statuses' => $allStatuses,
'status_counts' => $statusCounts,
'total_count' => count($totalCount)
]);
}
#[Route('/admin/demandes/{id}/edit', name: 'app_admin_demande_edit')]
public function editDemande(int $id, Request $request): Response
{
$demande = $this->entityManager->getRepository(\App\Entity\Demande::class)->find($id);
if (!$demande) {
$this->addFlash('error', 'Demande non trouvée');
return $this->redirectToRoute('app_admin_demandes');
}
if ($request->isMethod('POST')) {
$placeUuid = $request->request->get('placeUuid');
if ($placeUuid) {
// Check if the Place exists
$place = $this->entityManager->getRepository(Place::class)->findOneBy(['uuid_for_url' => $placeUuid]);
if ($place) {
$demande->setPlaceUuid($placeUuid);
$demande->setPlace($place);
$demande->setStatus('linked_to_place');
// Set OSM object type and OSM ID from the Place
$demande->setOsmObjectType($place->getOsmKind());
$demande->setOsmId((int)$place->getOsmId());
$this->entityManager->persist($demande);
$this->entityManager->flush();
$this->addFlash('success', 'Demande mise à jour avec succès');
} else {
$this->addFlash('error', 'Place non trouvée avec cet UUID');
}
}
}
return $this->render('admin/demandes/edit.html.twig', [
'demande' => $demande
]);
}
#[Route('/admin/contacted-places', name: 'app_admin_contacted_places')]
public function listContactedPlaces(): Response
{
$demandes = $this->entityManager->getRepository(\App\Entity\Demande::class)->findPlacesWithContactAttempt();
return $this->render('admin/demandes/contacted_places.html.twig', [
'demandes' => $demandes
]);
}
#[Route('/admin/demandes/{id}/send-email', name: 'app_admin_demande_send_email')]
public function sendEmailToDemande(int $id, \Symfony\Component\Mailer\MailerInterface $mailer): Response
{
$demande = $this->entityManager->getRepository(\App\Entity\Demande::class)->find($id);
if (!$demande) {
$this->addFlash('error', 'Demande non trouvée');
return $this->redirectToRoute('app_admin_demandes');
}
$place = $demande->getPlace();
if (!$place) {
$this->addFlash('error', 'Aucune place associée à cette demande');
return $this->redirectToRoute('app_admin_demande_edit', ['id' => $id]);
}
// Check if the place has an email
if (!$place->getEmail() && !$demande->getEmail()) {
$this->addFlash('error', 'Aucun email associé à cette place ou à cette demande');
return $this->redirectToRoute('app_admin_demande_edit', ['id' => $id]);
}
// Use the email from the place if available, otherwise use the email from the demande
$email = $place->getEmail() ?: $demande->getEmail();
// Generate the email content
$emailContent = $this->renderView('admin/email_content.html.twig', [
'place' => $place
]);
// Only send the email in production environment
if ($this->getParameter('kernel.environment') === 'prod') {
$message = (new \Symfony\Component\Mime\Email())
->from('contact@osm-commerce.fr')
->to($email)
->subject('Votre lien de modification OpenStreetMap')
->html($emailContent);
try {
$mailer->send($message);
} catch (\Throwable $e) {
$this->actionLogger->log('ERROR_envoi_email', [
'demande_id' => $demande->getId(),
'place_id' => $place->getId(),
'message' => $e->getMessage(),
]);
$this->addFlash('error', 'Erreur lors de l\'envoi de l\'email : ' . $e->getMessage());
return $this->redirectToRoute('app_admin_demande_edit', ['id' => $id]);
}
} else {
// In non-production environments, just log the attempt
$this->actionLogger->log('email_would_be_sent', [
'demande_id' => $demande->getId(),
'place_id' => $place->getId(),
'email' => $email,
'content' => $emailContent
]);
$this->addFlash('info', 'En environnement de production, un email serait envoyé à ' . $email);
}
// Update the last contact attempt date and set status to email_sent
$now = new \DateTime();
$demande->setLastContactAttempt($now);
$demande->setStatus('email_sent');
$place->setLastContactAttemptDate($now);
$this->entityManager->persist($demande);
$this->entityManager->persist($place);
$this->entityManager->flush();
$this->addFlash('success', 'Email envoyé avec succès');
return $this->redirectToRoute('app_admin_contacted_places');
}
} }

View file

@ -21,6 +21,69 @@ class FollowUpController extends AbstractController
$this->followUpService = $followUpService; $this->followUpService = $followUpService;
} }
#[Route('/api/city-followup', name: 'api_city_followup', methods: ['POST'])]
public function recordCityFollowUp(
EntityManagerInterface $em,
\Symfony\Component\HttpFoundation\Request $request
): Response {
$insee_code = $request->request->get('insee_code');
$measure_label = $request->request->get('measure_label');
$measure_value = $request->request->getFloat('measure_value');
if (!$insee_code || !$measure_label || $measure_value === null) {
return $this->json([
'success' => false,
'message' => 'Missing required parameters: insee_code, measure_label, measure_value'
], Response::HTTP_BAD_REQUEST);
}
$stats = $em->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
if (!$stats) {
return $this->json([
'success' => false,
'message' => 'No stats found for this INSEE code'
], Response::HTTP_NOT_FOUND);
}
// Check if the same measure was recorded less than an hour ago
$oneHourAgo = new \DateTime('-1 hour');
$recentFollowUp = $em->getRepository(CityFollowUp::class)
->findRecentByStatsAndName($stats, $measure_label, $oneHourAgo);
if ($recentFollowUp) {
return $this->json([
'success' => false,
'message' => 'A measure with the same label was recorded less than an hour ago',
'existing_measure' => [
'id' => $recentFollowUp->getId(),
'date' => $recentFollowUp->getDate()->format('Y-m-d H:i:s'),
'value' => $recentFollowUp->getMeasure()
]
], Response::HTTP_TOO_MANY_REQUESTS);
}
// Create and save the new follow-up
$followUp = new CityFollowUp();
$followUp->setName($measure_label);
$followUp->setMeasure($measure_value);
$followUp->setDate(new \DateTime());
$followUp->setStats($stats);
$em->persist($followUp);
$em->flush();
return $this->json([
'success' => true,
'message' => 'City follow-up recorded successfully',
'follow_up' => [
'id' => $followUp->getId(),
'name' => $followUp->getName(),
'measure' => $followUp->getMeasure(),
'date' => $followUp->getDate()->format('Y-m-d H:i:s')
]
]);
}
#[Route('/admin/followup/{insee_code}/delete', name: 'admin_followup_delete', requirements: ['insee_code' => '\\d+'])] #[Route('/admin/followup/{insee_code}/delete', name: 'admin_followup_delete', requirements: ['insee_code' => '\\d+'])]
public function deleteFollowups(string $insee_code, EntityManagerInterface $em): Response { public function deleteFollowups(string $insee_code, EntityManagerInterface $em): Response {

View file

@ -4,16 +4,21 @@ namespace App\Controller;
use App\Entity\Stats; use App\Entity\Stats;
use App\Entity\Place; use App\Entity\Place;
use App\Entity\CityFollowUp;
use App\Entity\Demande;
use App\Service\Motocultrice; use App\Service\Motocultrice;
use App\Service\FollowUpService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Annotation\Route;
use GuzzleHttp\Client; use GuzzleHttp\Client;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Mime\Email; use Symfony\Component\Mime\Email;
use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mailer\MailerInterface;
use App\Service\ActionLogger; use App\Service\ActionLogger;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
class PublicController extends AbstractController class PublicController extends AbstractController
{ {
@ -24,7 +29,8 @@ class PublicController extends AbstractController
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
private Motocultrice $motocultrice, private Motocultrice $motocultrice,
private MailerInterface $mailer, private MailerInterface $mailer,
private ActionLogger $actionLogger private ActionLogger $actionLogger,
private FollowUpService $followUpService
) {} ) {}
#[Route('/propose-email/{email}/{type}/{id}', name: 'app_public_propose_email')] #[Route('/propose-email/{email}/{type}/{id}', name: 'app_public_propose_email')]
@ -115,6 +121,73 @@ class PublicController extends AbstractController
return $this->redirectToRoute('app_public_index'); return $this->redirectToRoute('app_public_index');
} }
#[Route('/api/demande/create', name: 'app_public_create_demande', methods: ['POST'])]
public function createDemande(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
if (!isset($data['businessName']) || empty($data['businessName'])) {
return new JsonResponse(['success' => false, 'message' => 'Le nom du commerce est requis'], 400);
}
// Create a new Demande
$demande = new Demande();
$demande->setQuery($data['businessName']);
$demande->setStatus('new');
$demande->setCreatedAt(new \DateTime());
// Save the INSEE code if provided
if (isset($data['insee']) && !empty($data['insee'])) {
$demande->setInsee((int)$data['insee']);
}
// Save the OSM object type if provided
if (isset($data['osmObjectType']) && !empty($data['osmObjectType'])) {
$demande->setOsmObjectType($data['osmObjectType']);
}
// Save the OSM ID if provided
if (isset($data['osmId']) && !empty($data['osmId'])) {
$demande->setOsmId((int)$data['osmId']);
}
$this->entityManager->persist($demande);
$this->entityManager->flush();
return new JsonResponse([
'success' => true,
'message' => 'Demande créée avec succès',
'id' => $demande->getId()
]);
}
#[Route('/api/demande/{id}/email', name: 'app_public_update_demande_email', methods: ['POST'])]
public function updateDemandeEmail(int $id, Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
if (!isset($data['email']) || empty($data['email'])) {
return new JsonResponse(['success' => false, 'message' => 'L\'email est requis'], 400);
}
$demande = $this->entityManager->getRepository(Demande::class)->find($id);
if (!$demande) {
return new JsonResponse(['success' => false, 'message' => 'Demande non trouvée'], 404);
}
$demande->setEmail($data['email']);
$demande->setStatus('email_provided');
$this->entityManager->persist($demande);
$this->entityManager->flush();
return new JsonResponse([
'success' => true,
'message' => 'Email ajouté avec succès'
]);
}
#[Route('/', name: 'app_public_index')] #[Route('/', name: 'app_public_index')]
public function index(): Response public function index(): Response
{ {
@ -291,6 +364,74 @@ class PublicController extends AbstractController
]); ]);
} }
#[Route('/api/dashboard/regression', name: 'api_dashboard_regression', methods: ['POST'])]
public function saveRegressionData(Request $request): JsonResponse
{
$this->actionLogger->log('save_regression_data', []);
// Récupérer les données de la requête
$data = json_decode($request->getContent(), true);
if (!isset($data['angle']) || !isset($data['slope']) || !isset($data['intercept'])) {
return new JsonResponse([
'success' => false,
'message' => 'Données de régression incomplètes'
], Response::HTTP_BAD_REQUEST);
}
// Récupérer les stats globales (zone 00000)
$statsGlobal = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => '00000']);
if (!$statsGlobal) {
// Créer les stats globales si elles n'existent pas
$statsGlobal = new Stats();
$statsGlobal->setZone('00000');
$statsGlobal->setName('toutes les villes');
$this->entityManager->persist($statsGlobal);
$this->entityManager->flush();
}
// Créer un nouveau followup pour la régression linéaire
$followup = new CityFollowUp();
$followup->setName('regression_angle');
$followup->setMeasure($data['angle']);
$followup->setDate(new \DateTime());
$followup->setStats($statsGlobal);
$this->entityManager->persist($followup);
// Créer un followup pour la pente
$followupSlope = new CityFollowUp();
$followupSlope->setName('regression_slope');
$followupSlope->setMeasure($data['slope']);
$followupSlope->setDate(new \DateTime());
$followupSlope->setStats($statsGlobal);
$this->entityManager->persist($followupSlope);
// Créer un followup pour l'ordonnée à l'origine
$followupIntercept = new CityFollowUp();
$followupIntercept->setName('regression_intercept');
$followupIntercept->setMeasure($data['intercept']);
$followupIntercept->setDate(new \DateTime());
$followupIntercept->setStats($statsGlobal);
$this->entityManager->persist($followupIntercept);
$this->entityManager->flush();
return new JsonResponse([
'success' => true,
'message' => 'Données de régression enregistrées avec succès',
'followup' => [
'id' => $followup->getId(),
'name' => $followup->getName(),
'measure' => $followup->getMeasure(),
'date' => $followup->getDate()->format('Y-m-d H:i:s')
]
]);
}
#[Route('/modify/{osm_object_id}/{version}/{changesetID}', name: 'app_public_submit')] #[Route('/modify/{osm_object_id}/{version}/{changesetID}', name: 'app_public_submit')]
public function submit($osm_object_id, $version, $changesetID): Response public function submit($osm_object_id, $version, $changesetID): Response
{ {
@ -849,4 +990,47 @@ class PublicController extends AbstractController
'places_6mois' => $places_6mois, 'places_6mois' => $places_6mois,
]); ]);
} }
#[Route('/rss/demandes', name: 'app_public_rss_demandes')]
public function rssDemandes(): Response
{
$demandes = $this->entityManager->getRepository(Demande::class)->findAllOrderedByCreatedAt();
$content = $this->renderView('public/rss/demandes.xml.twig', [
'demandes' => $demandes,
'base_url' => $this->getParameter('router.request_context.host'),
]);
$response = new Response($content);
$response->headers->set('Content-Type', 'application/rss+xml');
return $response;
}
#[Route('/cities', name: 'app_public_cities')]
public function cities(): Response
{
$stats = $this->entityManager->getRepository(Stats::class)->findAll();
// Prepare data for the map
$citiesForMap = [];
foreach ($stats as $stat) {
if ($stat->getZone() !== 'undefined' && preg_match('/^\d+$/', $stat->getZone())) {
$citiesForMap[] = [
'name' => $stat->getName(),
'zone' => $stat->getZone(),
'lat' => $stat->getLat(),
'lon' => $stat->getLon(),
'placesCount' => $stat->getPlacesCount(),
'completionPercent' => $stat->getCompletionPercent(),
];
}
}
return $this->render('public/cities.html.twig', [
'stats' => $stats,
'citiesForMap' => $citiesForMap,
'maptiler_token' => $_ENV['MAPTILER_TOKEN'] ?? null,
]);
}
} }

177
src/Entity/Demande.php Normal file
View file

@ -0,0 +1,177 @@
<?php
namespace App\Entity;
use App\Repository\DemandeRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: DemandeRepository::class)]
class Demande
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $query = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $email = null;
#[ORM\Column(nullable: true)]
private ?int $insee = null;
#[ORM\Column(type: Types::DATETIME_MUTABLE)]
private ?\DateTimeInterface $createdAt = null;
#[ORM\Column(length: 50)]
private ?string $status = 'new';
#[ORM\Column(length: 255, nullable: true)]
private ?string $placeUuid = null;
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTimeInterface $lastContactAttempt = null;
#[ORM\ManyToOne]
private ?Place $place = null;
#[ORM\Column(length: 10, nullable: true)]
private ?string $osmObjectType = null;
#[ORM\Column(nullable: true)]
private ?int $osmId = null;
public function __construct()
{
$this->createdAt = new \DateTime();
$this->status = 'new';
}
public function getId(): ?int
{
return $this->id;
}
public function getQuery(): ?string
{
return $this->query;
}
public function setQuery(string $query): static
{
$this->query = $query;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(?string $email): static
{
$this->email = $email;
return $this;
}
public function getInsee(): ?int
{
return $this->insee;
}
public function setInsee(?int $insee): static
{
$this->insee = $insee;
return $this;
}
public function getCreatedAt(): ?\DateTimeInterface
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeInterface $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
public function getStatus(): ?string
{
return $this->status;
}
public function setStatus(string $status): static
{
$this->status = $status;
return $this;
}
public function getPlaceUuid(): ?string
{
return $this->placeUuid;
}
public function setPlaceUuid(?string $placeUuid): static
{
$this->placeUuid = $placeUuid;
return $this;
}
public function getLastContactAttempt(): ?\DateTimeInterface
{
return $this->lastContactAttempt;
}
public function setLastContactAttempt(?\DateTimeInterface $lastContactAttempt): static
{
$this->lastContactAttempt = $lastContactAttempt;
return $this;
}
public function getPlace(): ?Place
{
return $this->place;
}
public function setPlace(?Place $place): static
{
$this->place = $place;
return $this;
}
public function getOsmObjectType(): ?string
{
return $this->osmObjectType;
}
public function setOsmObjectType(?string $osmObjectType): static
{
$this->osmObjectType = $osmObjectType;
return $this;
}
public function getOsmId(): ?int
{
return $this->osmId;
}
public function setOsmId(?int $osmId): static
{
$this->osmId = $osmId;
return $this;
}
}

View file

@ -16,28 +16,20 @@ class CityFollowUpRepository extends ServiceEntityRepository
parent::__construct($registry, CityFollowUp::class); parent::__construct($registry, CityFollowUp::class);
} }
// /**
// * @return CityFollowUp[] Returns an array of CityFollowUp objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('c')
// ->andWhere('c.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('c.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?CityFollowUp public function findRecentByStatsAndName(Stats $stats, string $name, \DateTime $since): ?CityFollowUp
// { {
// return $this->createQueryBuilder('c') return $this->createQueryBuilder('c')
// ->andWhere('c.exampleField = :val') ->andWhere('c.stats = :stats')
// ->setParameter('val', $value) ->andWhere('c.name = :name')
// ->getQuery() ->andWhere('c.date >= :since')
// ->getOneOrNullResult() ->setParameter('stats', $stats)
// ; ->setParameter('name', $name)
// } ->setParameter('since', $since)
->orderBy('c.date', 'DESC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult()
;
}
} }

View file

@ -0,0 +1,99 @@
<?php
namespace App\Repository;
use App\Entity\Demande;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Demande>
*/
class DemandeRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Demande::class);
}
/**
* Find the most recent Demande by query (business name)
*/
public function findMostRecentByQuery(string $query): ?Demande
{
return $this->createQueryBuilder('d')
->andWhere('d.query = :query')
->setParameter('query', $query)
->orderBy('d.createdAt', 'DESC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult()
;
}
/**
* Find Demandes without an email
*/
public function findWithoutEmail(): array
{
return $this->createQueryBuilder('d')
->andWhere('d.email IS NULL')
->orderBy('d.createdAt', 'DESC')
->getQuery()
->getResult()
;
}
/**
* Find Demandes by status
*/
public function findByStatus(string $status): array
{
return $this->createQueryBuilder('d')
->andWhere('d.status = :status')
->setParameter('status', $status)
->orderBy('d.createdAt', 'DESC')
->getQuery()
->getResult()
;
}
/**
* Find Demandes with a Place UUID
*/
public function findWithPlaceUuid(): array
{
return $this->createQueryBuilder('d')
->andWhere('d.placeUuid IS NOT NULL')
->orderBy('d.createdAt', 'DESC')
->getQuery()
->getResult()
;
}
/**
* Find all Demandes in reverse chronological order
*/
public function findAllOrderedByCreatedAt(): array
{
return $this->createQueryBuilder('d')
->orderBy('d.createdAt', 'DESC')
->getQuery()
->getResult()
;
}
/**
* Find Places that have been contacted, ordered by last contact attempt
*/
public function findPlacesWithContactAttempt(): array
{
return $this->createQueryBuilder('d')
->andWhere('d.lastContactAttempt IS NOT NULL')
->andWhere('d.place IS NOT NULL')
->orderBy('d.lastContactAttempt', 'DESC')
->getQuery()
->getResult()
;
}
}

View file

@ -16,6 +16,42 @@ class StatsRepository extends ServiceEntityRepository
parent::__construct($registry, Stats::class); parent::__construct($registry, Stats::class);
} }
/**
* Find all cities without using problematic columns
*
* @return Stats[] Returns an array of Stats objects
*/
public function findAllCitiesWithoutLabourage(): array
{
// Use native SQL to avoid ORM mapping issues with missing columns
$conn = $this->getEntityManager()->getConnection();
$sql = '
SELECT id, zone, completion_percent, places_count, avec_horaires,
avec_adresse, avec_site, avec_accessibilite, avec_note,
name, population, siren, code_epci, codes_postaux,
date_created, date_modified, avec_siret, avec_name,
osm_data_date_min, osm_data_date_avg, osm_data_date_max,
budget_annuel, lat, lon
FROM stats
WHERE zone != :global_zone
';
$stmt = $conn->prepare($sql);
$resultSet = $stmt->executeQuery(['global_zone' => '00000']);
$results = $resultSet->fetchAllAssociative();
// Get existing Stats entities by ID
$statsEntities = [];
foreach ($results as $row) {
$stats = $this->find($row['id']);
if ($stats) {
$statsEntities[] = $stats;
}
}
return $statsEntities;
}
// /** // /**
// * @return Stats[] Returns an array of Stats objects // * @return Stats[] Returns an array of Stats objects
// */ // */

View file

@ -0,0 +1,68 @@
{% extends 'base.html.twig' %}
{% block title %}Places contactées{% endblock %}
{% block body %}
<div class="container mt-4">
<h1>Places contactées</h1>
<div class="mb-4">
<a href="{{ path('app_admin') }}" class="btn btn-secondary">Retour à l'administration</a>
<a href="{{ path('app_admin_demandes') }}" class="btn btn-primary">Liste des demandes</a>
</div>
<div class="card">
<div class="card-header">
<h2>Places contactées ({{ demandes|length }})</h2>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>ID</th>
<th>Nom du commerce</th>
<th>Email</th>
<th>Place</th>
<th>Dernière tentative de contact</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for demande in demandes %}
<tr>
<td>{{ demande.id }}</td>
<td>{{ demande.query }}</td>
<td>{{ demande.email }}</td>
<td>
{% if demande.place %}
{{ demande.place.name }}
<small class="text-muted">({{ demande.place.osmKind }}/{{ demande.place.osmId }})</small>
{% else %}
<span class="text-muted">Non liée</span>
{% endif %}
</td>
<td>{{ demande.lastContactAttempt ? demande.lastContactAttempt|date('Y-m-d H:i:s') : '' }}</td>
<td>
<div class="btn-group" role="group">
<a href="{{ path('app_admin_demande_edit', {'id': demande.id}) }}" class="btn btn-sm btn-primary">
<i class="bi bi-pencil"></i> Éditer
</a>
<a href="{{ path('app_admin_demande_send_email', {'id': demande.id}) }}" class="btn btn-sm btn-success">
<i class="bi bi-envelope"></i> Renvoyer un email
</a>
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center">Aucune place contactée trouvée</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,120 @@
{% extends 'base.html.twig' %}
{% block title %}Éditer une demande{% endblock %}
{% block body %}
<div class="container mt-4">
<h1>Éditer une demande</h1>
<div class="mb-4">
<a href="{{ path('app_admin_demandes') }}" class="btn btn-secondary">Retour à la liste</a>
</div>
<div class="row">
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">
<h2>Informations de la demande</h2>
</div>
<div class="card-body">
<table class="table">
<tbody>
<tr>
<th>ID</th>
<td>{{ demande.id }}</td>
</tr>
<tr>
<th>Nom du commerce</th>
<td>{{ demande.query }}</td>
</tr>
<tr>
<th>Email</th>
<td>{{ demande.email }}</td>
</tr>
<tr>
<th>Date de création</th>
<td>{{ demande.createdAt ? demande.createdAt|date('Y-m-d H:i:s') : '' }}</td>
</tr>
<tr>
<th>Statut</th>
<td>
<span class="badge {% if demande.status == 'new' %}bg-primary{% elseif demande.status == 'email_provided' %}bg-info{% elseif demande.status == 'linked_to_place' %}bg-success{% else %}bg-secondary{% endif %}">
{{ demande.status }}
</span>
</td>
</tr>
<tr>
<th>Place UUID</th>
<td>{{ demande.placeUuid }}</td>
</tr>
<tr>
<th>Dernière tentative de contact</th>
<td>{{ demande.lastContactAttempt ? demande.lastContactAttempt|date('Y-m-d H:i:s') : '' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
{% if demande.place %}
<div class="card mb-4">
<div class="card-header">
<h2>Place associée</h2>
</div>
<div class="card-body">
<table class="table">
<tbody>
<tr>
<th>ID</th>
<td>{{ demande.place.id }}</td>
</tr>
<tr>
<th>Nom</th>
<td>{{ demande.place.name }}</td>
</tr>
<tr>
<th>Email</th>
<td>{{ demande.place.email }}</td>
</tr>
<tr>
<th>UUID</th>
<td>{{ demande.place.uuidForUrl }}</td>
</tr>
<tr>
<th>OSM ID</th>
<td>{{ demande.place.osmKind }}/{{ demande.place.osmId }}</td>
</tr>
</tbody>
</table>
<div class="mt-3">
<a href="{{ path('app_admin_demande_send_email', {'id': demande.id}) }}" class="btn btn-success">
<i class="bi bi-envelope"></i> Envoyer un email
</a>
</div>
</div>
</div>
{% endif %}
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h2>Lier à une Place</h2>
</div>
<div class="card-body">
<form method="post">
<div class="mb-3">
<label for="placeUuid" class="form-label">UUID de la Place</label>
<input type="text" id="placeUuid" name="placeUuid" class="form-control" value="{{ demande.placeUuid }}" placeholder="Entrez l'UUID de la Place">
<div class="form-text">Entrez l'UUID d'une Place existante pour la lier à cette demande.</div>
</div>
<button type="submit" class="btn btn-primary">Enregistrer</button>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,152 @@
{% extends 'base.html.twig' %}
{% block title %}Liste des demandes{% endblock %}
{% block body %}
<div class="container mt-4">
<h1>Liste des demandes</h1>
<div class="mb-4">
<a href="{{ path('app_admin') }}" class="btn btn-secondary">Retour à l'administration</a>
</div>
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h2 class="mb-0">Filtrer par statut</h2>
{% if current_status %}
<a href="{{ path('app_admin_demandes') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-x-circle"></i> Effacer le filtre
</a>
{% endif %}
</div>
<div class="card-body">
<div class="d-flex flex-wrap gap-2" style="row-gap: 0.5rem;">
<a href="{{ path('app_admin_demandes') }}" class="btn btn-sm {% if current_status is null %}btn-primary{% else %}btn-outline-primary{% endif %}">
<i class="bi bi-funnel"></i> Tous <span class="badge bg-secondary">{{ total_count }}</span>
</a>
{% for status in all_statuses %}
<a href="{{ path('app_admin_demandes', {'status': status}) }}" class="btn btn-sm {% if current_status == status %}btn-primary{% else %}btn-outline-primary{% endif %}">
{% if status == 'new' %}
<i class="bi bi-star"></i> Nouveau
{% elseif status == 'email_provided' %}
<i class="bi bi-envelope"></i> Email fourni
{% elseif status == 'ready' %}
<i class="bi bi-check-circle"></i> Prêt
{% elseif status == 'email_sent' %}
<i class="bi bi-send"></i> Email envoyé
{% elseif status == 'email_failed' %}
<i class="bi bi-exclamation-triangle"></i> Échec d'envoi
{% elseif status == 'email_opened' %}
<i class="bi bi-envelope-open"></i> Email ouvert
{% elseif status == 'edit_form_opened' %}
<i class="bi bi-pencil-square"></i> Formulaire ouvert
{% elseif status == 'place_modified' %}
<i class="bi bi-check2-all"></i> Place modifiée
{% elseif status == 'linked_to_place' %}
<i class="bi bi-link"></i> Lié à une place
{% else %}
{{ status }}
{% endif %}
<span class="badge bg-secondary">{{ status_counts[status]|length }}</span>
</a>
{% endfor %}
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h2>
Demandes ({{ demandes|length }})
{% if current_status %}
<small class="text-muted">
- Filtre actif :
{% if current_status == 'new' %}
<span class="badge bg-primary">Nouveau</span>
{% elseif current_status == 'email_provided' %}
<span class="badge bg-info">Email fourni</span>
{% elseif current_status == 'ready' %}
<span class="badge bg-success">Prêt</span>
{% elseif current_status == 'email_sent' %}
<span class="badge bg-success">Email envoyé</span>
{% elseif current_status == 'email_failed' %}
<span class="badge bg-danger">Échec d'envoi</span>
{% elseif current_status == 'email_opened' %}
<span class="badge bg-warning">Email ouvert</span>
{% elseif current_status == 'edit_form_opened' %}
<span class="badge bg-warning">Formulaire ouvert</span>
{% elseif current_status == 'place_modified' %}
<span class="badge bg-success">Place modifiée</span>
{% elseif current_status == 'linked_to_place' %}
<span class="badge bg-success">Lié à une place</span>
{% else %}
<span class="badge bg-secondary">{{ current_status }}</span>
{% endif %}
</small>
{% endif %}
</h2>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>ID</th>
<th>Nom du commerce</th>
<th>Email</th>
<th>Date de création</th>
<th>Statut</th>
<th>Place UUID</th>
<th>Dernière tentative de contact</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for demande in demandes %}
<tr>
<td>{{ demande.id }}</td>
<td>{{ demande.query }}</td>
<td>{{ demande.email }}</td>
<td>{{ demande.createdAt ? demande.createdAt|date('Y-m-d H:i:s') : '' }}</td>
<td>
<span class="badge
{% if demande.status == 'new' %}bg-primary
{% elseif demande.status == 'email_provided' %}bg-info
{% elseif demande.status == 'ready' %}bg-success
{% elseif demande.status == 'email_sent' %}bg-success
{% elseif demande.status == 'email_failed' %}bg-danger
{% elseif demande.status == 'email_opened' %}bg-warning
{% elseif demande.status == 'edit_form_opened' %}bg-warning
{% elseif demande.status == 'place_modified' %}bg-success
{% elseif demande.status == 'linked_to_place' %}bg-success
{% else %}bg-secondary{% endif %}">
{{ demande.status }}
</span>
</td>
<td>{{ demande.placeUuid }}</td>
<td>{{ demande.lastContactAttempt ? demande.lastContactAttempt|date('Y-m-d H:i:s') : '' }}</td>
<td>
<div class="btn-group" role="group">
<a href="{{ path('app_admin_demande_edit', {'id': demande.id}) }}" class="btn btn-sm btn-primary">
<i class="bi bi-pencil"></i> Éditer
</a>
{% if demande.place %}
<a href="{{ path('app_admin_demande_send_email', {'id': demande.id}) }}" class="btn btn-sm btn-success">
<i class="bi bi-envelope"></i> Envoyer un email
</a>
{% endif %}
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="8" class="text-center">Aucune demande trouvée</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -1,15 +1,44 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% block title %}Hello AdminController!{% endblock %} {% block title %}Administration{% endblock %}
{% block body %} {% block body %}
<style> <div class="container mt-4">
.example-wrapper { margin: 1em auto; max-width: 800px; width: 95%; font: 18px/1.5 sans-serif; } <h1>Administration</h1>
.example-wrapper code { background: #F5F5F5; padding: 2px 6px; }
</style>
<div class="example-wrapper"> <div class="row mt-4">
<h1>Hello {{ controller_name }}! ✅</h1> <div class="col-md-6">
<div class="card mb-4">
<div class="card-header">
<h2>Gestion des demandes</h2>
</div>
<div class="card-body">
<div class="list-group">
<a href="{{ path('app_admin_demandes') }}" class="list-group-item list-group-item-action">
<i class="bi bi-list-ul"></i> Liste des demandes
</a>
<a href="{{ path('app_admin_contacted_places') }}" class="list-group-item list-group-item-action">
<i class="bi bi-envelope"></i> Places contactées
</a>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">
<h2>Autres actions</h2>
</div>
<div class="card-body">
<div class="list-group">
<a href="{{ path('app_public_index') }}" class="list-group-item list-group-item-action">
<i class="bi bi-house"></i> Retour à l'accueil
</a>
</div>
</div>
</div>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,138 @@
{% extends 'base.html.twig' %}
{% block title %}{{ 'display.title'|trans }}{% endblock %}
{% block stylesheets %}
{{ parent() }}
<link href='{{ asset('js/maplibre/maplibre-gl.css') }}' rel='stylesheet'/>
<style>
#citiesMap {
height: 400px;
width: 100%;
margin-bottom: 1rem;
}
</style>
{% endblock %}
{% block body %}
{% if citiesForMap is not empty %}
<div id="cities" class="container">
<div class="alert-info alert">
Les contributrices et contributeurs aguerris d'OSM peuvent ajouter ici de quoi suivre les évolutions thématiques de complétion dans une ville donnée.
</div>
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<h3><i class="bi bi-geo-alt"></i> Carte des villes disponibles</h3>
<p class="mb-0">Cliquez sur un marqueur pour voir les statistiques de la ville</p>
</div>
<a href="{{ path('app_public_add_city') }}" class="btn btn-success">
<i class="bi bi-plus-circle"></i> Ajouter ma ville
</a>
</div>
<div class="card-body p-0">
<div class="map-container">
<div id="citiesMap"></div>
<div class="map-legend">
<div class="legend-item">
<div class="legend-color" style="background-color: #28a745;"></div>
<span>Complétion > 80%</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #17a2b8;"></div>
<span>Complétion 50-80%</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #ffc107;"></div>
<span>Complétion < 50%</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row city-list ">
<div id="stats_bubble"></div>
<div class="mt-5">
<h2><i class="bi bi-geo-alt"></i> Villes disponibles</h2>
<p>Visualisez un tableau de bord de la complétion des commerces et autres lieux d'intérêt pour votre
ville grâce à OpenStreetMap</p>
</div>
{% set sorted_stats = stats|sort((a, b) => a.zone <=> b.zone) %}
{% for stat in sorted_stats %}
{% if stat.zone != 'undefined' and stat.zone matches '/^\\d+$/' %}
<a href="{{ path('app_admin_stats', {'insee_code': stat.zone}) }}"
class="list-group-item list-group-item-action d-flex p-4 rounded-3 justify-content-between align-items-center">
<div class="d-flex flex-column">
<span class="zone">{{ stat.zone }}</span>
<span class="name">{{ stat.name }}</span>
</div>
<div class="d-flex flex-column">
<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>
{% endif %}
{% endfor %}
{% include 'public/labourage-form.html.twig' %}
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script src='{{ asset('js/maplibre/maplibre-gl.js') }}'></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize the map
const map = new maplibregl.Map({
container: 'citiesMap',
style: 'https://api.maptiler.com/maps/streets/style.json?key={{ maptiler_token }}',
center: [2.213749, 46.227638], // Center of France
zoom: 5
});
// Add navigation controls
map.addControl(new maplibregl.NavigationControl());
let color = '#ffc107'; // Yellow by default
// Add markers for each city
{% if citiesForMap is not empty %}
{% for city in citiesForMap %}
{% if city.lat and city.lon %}
// Determine marker color based on completion percentage
{% if city.completionPercent > 80 %}
color = '#28a745'; // Green for high completion
{% elseif city.completionPercent > 50 %}
color = '#17a2b8'; // Blue for medium completion
{% endif %}
// Create marker and popup
new maplibregl.Marker({color: color})
.setLngLat([{{ city.lon }}, {{ city.lat }}])
.setPopup(new maplibregl.Popup().setHTML(`
<strong>{{ city.name }}</strong><br>
Code INSEE: {{ city.zone }}<br>
Nombre de lieux: {{ city.placesCount }}<br>
Complétion: {{ city.completionPercent }}%<br>
<a href="{{ path('app_admin_stats', {'insee_code': city.zone}) }}" class="btn btn-sm btn-primary mt-2">
Voir les statistiques
</a>
`))
.addTo(map);
{% endif %}
{% endfor %}
{% endif %}
});
</script>
{% endblock %}

View file

@ -88,7 +88,7 @@
<div class="row mb-2"> <div class="row mb-2">
<div class="col-12 text-end"> <div class="col-12 text-end">
<div class="form-check form-switch d-inline-block"> <div class="form-check form-switch d-inline-block">
<input class="form-check-input" type="checkbox" id="toggleBubbleSize" checked> <input class="form-check-input" type="checkbox" id="toggleBubbleSize">
<label class="form-check-label" for="toggleBubbleSize"> <label class="form-check-label" for="toggleBubbleSize">
Taille des bulles proportionnelle au nombre de lieux Taille des bulles proportionnelle au nombre de lieux
</label> </label>
@ -314,6 +314,26 @@
const angleElem = document.getElementById('dashboard-regression-angle'); const angleElem = document.getElementById('dashboard-regression-angle');
if (angleElem) angleElem.innerText = angleStr; if (angleElem) angleElem.innerText = angleStr;
console.log('Angle de la régression linéaire :', angleStr); console.log('Angle de la régression linéaire :', angleStr);
// Envoyer les données de régression au serveur pour créer un followup
fetch('/api/dashboard/regression', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
angle: angle,
slope: slope,
intercept: intercept
}),
})
.then(response => response.json())
.then(data => {
console.log('Données de régression enregistrées:', data);
})
.catch((error) => {
console.error('Erreur lors de l\'enregistrement des données de régression:', error);
});
} }
Chart.register(window.ChartDataLabels); Chart.register(window.ChartDataLabels);
const bubbleChart = new Chart(chartCanvas.getContext('2d'), { const bubbleChart = new Chart(chartCanvas.getContext('2d'), {

View file

@ -26,7 +26,7 @@
background: white; background: white;
padding: 10px; padding: 10px;
border-radius: 4px; border-radius: 4px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
font-size: 12px; font-size: 12px;
z-index: 1000; z-index: 1000;
} }
@ -116,240 +116,225 @@
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<div class="container mt-4"> <!-- Hero Section -->
<div class="row"> <div class="container-fluid bg-primary text-black py-5 mb-5">
<div class="col-12"> <div class="container">
<h1> <div class="row align-items-center">
<div class="col-lg-8 d-none d-lg-block">
<h1 class="display-4 fw-bold mb-4">
<img src="{{ asset('logo-osm.png') }}" alt="OpenStreetMap Logo" class="img-fluid"
style="max-height: 250px;">
<i class="bi bi-shop"></i> Mon Commerce OSM <i class="bi bi-shop"></i> Mon Commerce OSM
</h1> </h1>
<p class="mt-4 p-4"> <p class="lead mb-4">
Bonjour, ce site permet de modifier les informations de votre commerce sur OpenStreetMap afin de Améliorez la visibilité de votre commerce sur des milliers de sites web et applications en
gagner en visibilité sur des milliers de sites web à la fois en une minute, c'est gratuit et sans quelques minutes.
engagement.
<br>Nous sommes bénévoles dans une association à but non lucratif.
<br>Nous vous enverrons un lien unique pour cela par email, et si vous en avez besoin, nous pouvons
vous aider.
</p> </p>
<div class="row"> </div>
<div class="col-12"> <div class="col-lg-4">
<label class="label" for="researchShop">
<i class="bi bi-search bi-2x"></i> Rechercher un commerce, écrivez son nom et la ville <div class="d-flex gap-3">
</label> <a href="#search-section" class="btn btn-light btn-lg">
<input class="form-control" type="text" id="researchShop" placeholder="Mon commerce, Paris"> <i class="bi bi-search"></i> Trouver mon commerce
</a>
<a href="{{ path('faq') }}" class="btn btn-outline-light btn-lg">
<i class="bi bi-info-circle"></i> En savoir plus
</a>
</div>
</div>
</div>
</div> </div>
</div> </div>
<div id="resultsList"></div> <div class="container mt-5">
<!-- Benefits Section -->
<div class="row mb-5">
<div class="col-12 text-center mb-4">
<h2 class="fw-bold">Pourquoi mettre à jour vos informations sur OpenStreetMap ?</h2>
<p class="lead text-muted">Un seul ajout, une visibilité sur des milliers de plateformes</p>
</div>
<div class="col-md-4 mb-4">
<div class="card h-100 shadow-sm border-0">
<div class="card-body text-center p-4">
<div class="mb-3">
<i class="bi bi-globe text-primary" style="font-size: 3rem;"></i>
</div>
<h3 class="card-title h5 fw-bold">Visibilité Maximale</h3>
<p class="card-text">Vos informations apparaîtront sur Google Maps, Apple Plans, Facebook et des
centaines d'autres applications.</p>
</div>
</div>
</div>
<div class="col-md-4 mb-4">
<div class="card h-100 shadow-sm border-0">
<div class="card-body text-center p-4">
<div class="mb-3">
<i class="bi bi-clock text-primary" style="font-size: 3rem;"></i>
</div>
<h3 class="card-title h5 fw-bold">Rapide et Simple</h3>
<p class="card-text">Mettez à jour vos horaires, contacts et services en quelques minutes
seulement.</p>
</div>
</div>
</div>
<div class="col-md-4 mb-4">
<div class="card h-100 shadow-sm border-0">
<div class="card-body text-center p-4">
<div class="mb-3">
<i class="bi bi-cash-coin text-primary" style="font-size: 3rem;"></i>
</div>
<h3 class="card-title h5 fw-bold">100% Gratuit</h3>
<p class="card-text">Service entièrement gratuit et sans engagement, fourni par une association
à but non lucratif.</p>
</div>
</div>
</div>
</div>
<!-- Search Section -->
<div id="search-section" class="row mb-5">
<div class="col-lg-8 mx-auto">
<div class="card shadow border-0">
<div class="card-header bg-white py-3">
<h3 class="mb-0 fw-bold">
<i class="bi bi-search"></i> Rechercher votre commerce
</h3>
</div>
<div class="card-body p-4">
<p class="mb-4">
Entrez le nom de votre commerce et sa ville pour commencer à mettre à jour vos informations.
</p>
<div class="mb-4">
<label class="form-label fw-bold" for="researchShop">
Nom du commerce et ville
</label>
<div class="input-group input-group-lg">
<span class="input-group-text bg-white">
<i class="bi bi-building"></i>
</span>
<input class="form-control" type="text" id="researchShop"
placeholder="Ex: Boulangerie Dupont, Paris">
</div>
<div class="form-text">
<i class="bi bi-info-circle"></i> Exemple: Café de la Place, Lyon
</div>
</div>
<div id="resultsList" class="mt-4"></div>
<div id="proposeLink" class="d-none"></div> <div id="proposeLink" class="d-none"></div>
<div id="proposeMail" class="d-none"> <div id="proposeMail" class="d-none">
<div class="mb-3">
<label for="emailInput" class="form-label fw-bold">Votre email professionnel</label>
<div class="input-group">
<span class="input-group-text bg-white">
<i class="bi bi-envelope"></i>
</span>
<input type="email" id="emailInput" class="form-control" <input type="email" id="emailInput" class="form-control"
placeholder="mon_email_de_commerce@exemple.com"> placeholder="contact@moncommerce.fr">
<button type="submit" class="btn btn-primary p-4 d-block"><i class="bi bi-envelope"></i> Envoyer </div>
</div>
<button type="submit" class="btn btn-primary btn-lg">
<i class="bi bi-send"></i> Recevoir mon lien de modification
</button> </button>
</div> </div>
<div id="emailForm"></div> <div id="emailForm"></div>
{% if citiesForMap is not empty %}
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<h3><i class="bi bi-geo-alt"></i> Carte des villes disponibles</h3>
<p class="mb-0">Cliquez sur un marqueur pour voir les statistiques de la ville</p>
</div> </div>
<a href="{{ path('app_public_add_city') }}" class="btn btn-success"> </div>
<i class="bi bi-plus-circle"></i> Ajouter ma ville </div>
</div>
<!-- How It Works Section -->
<div class="row mb-5">
<div class="col-12 text-center mb-4">
<h2 class="fw-bold">Comment ça marche ?</h2>
<p class="lead text-muted">Un processus simple en 3 étapes</p>
</div>
<div class="col-md-4 mb-4">
<div class="card h-100 border-0 bg-light">
<div class="card-body p-4">
<div class="d-flex align-items-center mb-3">
<div class="bg-primary text-white rounded-circle p-3 me-3"
style="width: 50px; height: 50px; display: flex; align-items: center; justify-content: center;">
<span class="fw-bold">1</span>
</div>
<h3 class="card-title h5 fw-bold mb-0">Recherchez votre commerce</h3>
</div>
<p class="card-text">Entrez le nom de votre établissement et sa localité pour le retrouver dans
notre base de données.</p>
</div>
</div>
</div>
<div class="col-md-4 mb-4">
<div class="card h-100 border-0 bg-light">
<div class="card-body p-4">
<div class="d-flex align-items-center mb-3">
<div class="bg-primary text-white rounded-circle p-3 me-3"
style="width: 50px; height: 50px; display: flex; align-items: center; justify-content: center;">
<span class="fw-bold">2</span>
</div>
<h3 class="card-title h5 fw-bold mb-0">Recevez votre lien unique</h3>
</div>
<p class="card-text">Nous vous envoyons par email un lien sécurisé pour accéder à votre fiche
commerce.</p>
</div>
</div>
</div>
<div class="col-md-4 mb-4">
<div class="card h-100 border-0 bg-light">
<div class="card-body p-4">
<div class="d-flex align-items-center mb-3">
<div class="bg-primary text-white rounded-circle p-3 me-3"
style="width: 50px; height: 50px; display: flex; align-items: center; justify-content: center;">
<span class="fw-bold">3</span>
</div>
<h3 class="card-title h5 fw-bold mb-0">Mettez à jour vos informations</h3>
</div>
<p class="card-text">Modifiez facilement vos horaires, contacts, services et autres informations
importantes.</p>
</div>
</div>
</div>
</div>
<!-- Help Section -->
<div class="row mb-5">
<div class="col-lg-10 mx-auto">
<div class="card bg-light border-0">
<div class="card-body p-4 text-center">
<h3 class="fw-bold mb-3">
<i class="bi bi-question-circle"></i> Besoin d'aide ?
</h3>
<p class="mb-4">
Notre équipe de bénévoles est là pour vous accompagner dans la mise à jour de vos
informations.
</p>
<a href="{{ path('app_public_ask_for_help') }}" class="btn btn-primary btn-lg">
<i class="bi bi-envelope"></i> Contactez-nous
</a> </a>
</div> </div>
<div class="card-body p-0">
<div class="map-container">
<div id="citiesMap"></div>
<div class="map-legend">
<div class="legend-item">
<div class="legend-color" style="background-color: #28a745;"></div>
<span>Complétion > 80%</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #17a2b8;"></div>
<span>Complétion 50-80%</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #ffc107;"></div>
<span>Complétion < 50%</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
<div class="row city-list ">
<div id="stats_bubble"></div>
<div class="mt-5">
<h2><i class="bi bi-geo-alt"></i> Villes disponibles</h2>
<p>Visualisez un tableau de bord de la complétion des commerces et autres lieux d'intérêt pour votre
ville grâce à OpenStreetMap</p>
</div>
{% set sorted_stats = stats|sort((a, b) => a.zone <=> b.zone) %}
{% for stat in sorted_stats %}
{% if stat.zone != 'undefined' and stat.zone matches '/^\\d+$/' %}
<a href="{{ path('app_admin_stats', {'insee_code': stat.zone}) }}"
class="list-group-item list-group-item-action d-flex p-4 rounded-3 justify-content-between align-items-center">
<div class="d-flex flex-column">
<span class="zone">{{ stat.zone }}</span>
<span class="name">{{ stat.name }}</span>
</div>
<div class="d-flex flex-column">
<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>
{% endif %}
{% endfor %}
{% include 'public/labourage-form.html.twig' %}
</div>
</div>
{% endblock %} {% endblock %}
{% block javascripts %} {% block javascripts %}
{{ parent() }} {{ parent() }}
<script src='{{ asset('js/maplibre/maplibre-gl.js') }}'></script>
{# <script src='{{ asset('js/utils.js') }}'></script> #}
<script type="module">
// import { adjustListGroupFontSize } from '{{ asset('js/utils.js') }}';
// document.addEventListener('DOMContentLoaded', function() {
// adjustListGroupFontSize('.list-group-item');
// });
</script>
<script> <script>
// Données des villes pour la carte // Données des villes pour la carte
const citiesData = {{ citiesForMap|json_encode|raw }}; const citiesData = {{ citiesForMap|json_encode|raw }};
const mapToken = '{{ maptiler_token }}';
// Initialiser la carte si des données sont disponibles
if (citiesData.length > 0 && mapToken) {
document.addEventListener('DOMContentLoaded', function() {
// Créer les features GeoJSON pour la carte
const features = citiesData.map(city => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [city.coordinates.lon, city.coordinates.lat]
},
properties: {
name: city.name,
zone: city.zone,
placesCount: city.placesCount,
completionPercent: city.completionPercent,
population: city.population,
url: city.url
}
}));
const geojson = { document.addEventListener('DOMContentLoaded', function () {
type: 'FeatureCollection',
features: features
};
// Calculer le centre de la carte (moyenne des coordonnées)
const bounds = new maplibregl.LngLatBounds();
features.forEach(feature => {
bounds.extend(feature.geometry.coordinates);
});
// Initialiser la carte
const map = new maplibregl.Map({
container: 'citiesMap',
style: `https://api.maptiler.com/maps/streets/style.json?key=${mapToken}`,
bounds: bounds,
fitBoundsOptions: {
padding: 50
}
});
// Ajouter les marqueurs
features.forEach(feature => {
const properties = feature.properties;
// Déterminer la couleur selon le pourcentage de complétion
let color = '#ffc107'; // Jaune par défaut
if (properties.completionPercent > 80) {
color = '#28a745'; // Vert
} else if (properties.completionPercent > 50) {
color = '#17a2b8'; // Bleu
}
// Créer le marqueur
const marker = new maplibregl.Marker({
color: color,
scale: 0.8
})
.setLngLat(feature.geometry.coordinates)
.setPopup(
new maplibregl.Popup({ offset: 25 })
.setHTML(`
<div style="min-width: 200px;">
<h6 style="margin: 0 0 10px 0; color: #333;">${properties.name}</h6>
<div style="font-size: 12px; color: #666;">
<div><strong>Code INSEE:</strong> ${properties.zone}</div>
<div><strong>Lieux:</strong> ${properties.placesCount}</div>
<div><strong>Complétion:</strong> ${properties.completionPercent}%</div>
${properties.population ? `<div><strong>Population:</strong> ${properties.population.toLocaleString()}</div>` : ''}
</div>
<div style="margin-top: 10px;">
<a href="${properties.url}" class="btn btn-sm btn-primary" style="text-decoration: none;">
Voir les statistiques
</a>
</div>
</div>
`)
)
.addTo(map);
// Ajouter le nom de la ville comme label
const label = new maplibregl.Marker({
element: (() => {
const el = document.createElement('div');
el.className = 'city-label';
el.style.cssText = `
background: rgba(255, 255, 255, 0.9);
border: 1px solid #ccc;
border-radius: 4px;
padding: 2px 6px;
font-size: 11px;
font-weight: bold;
color: #333;
white-space: nowrap;
pointer-events: none;
margin-top: -25px;
margin-left: 15px;
`;
el.textContent = properties.name;
return el;
})()
})
.setLngLat(feature.geometry.coordinates)
.addTo(map);
});
// Ajouter les contrôles de navigation
map.addControl(new maplibregl.NavigationControl());
});
}
console.log('content loaded')
// Créer le formulaire email // Créer le formulaire email
const emailFormHtml = ` const emailFormHtml = `
<form id="emailForm" class="mt-3"> <form id="emailForm" class="mt-3">
@ -376,10 +361,18 @@
`; `;
// Ajouter les éléments au DOM // Ajouter les éléments au DOM
document.querySelector('#proposeLink').innerHTML = proposeLinkHtml; // Initialize the elements only if they exist
document.querySelector('#proposeMail').innerHTML = proposeMailHtml; const proposeLinkElement = document.querySelector('#proposeLink');
const proposeMailElement = document.querySelector('#proposeMail');
if (proposeLinkElement) {
proposeLinkElement.innerHTML = proposeLinkHtml;
}
if (proposeMailElement) {
proposeMailElement.innerHTML = proposeMailHtml;
}
document.addEventListener('DOMContentLoaded', function () {
const searchInput = document.querySelector('#researchShop'); const searchInput = document.querySelector('#researchShop');
const resultsList = document.querySelector('#resultsList'); const resultsList = document.querySelector('#resultsList');
resultsList.classList.add('list-group', 'mt-2'); resultsList.classList.add('list-group', 'mt-2');
@ -389,17 +382,134 @@
clearTimeout(timeoutId); clearTimeout(timeoutId);
resultsList.innerHTML = ''; resultsList.innerHTML = '';
if (e.target.value.length < 3) return; if (e.target.value.length < 2) return;
const query_input_user = e.target.value;
timeoutId = setTimeout(() => { timeoutId = setTimeout(() => {
fetch(`https://demo.addok.xyz/search?q=${e.target.value}&limit=5`) fetch(`https://demo.addok.xyz/search?q=${query_input_user}&limit=5`)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
console.log(data);
resultsList.innerHTML = ''; resultsList.innerHTML = '';
const ul = document.createElement('ul'); const ul = document.createElement('ul');
ul.classList.add('list-group'); ul.classList.add('list-group');
resultsList.appendChild(ul); resultsList.appendChild(ul);
// Show a message if there are no results
if (data.features.length === 0) {
const noResultsMessage = document.createElement('div');
noResultsMessage.classList.add('alert', 'alert-info', 'mt-3');
noResultsMessage.innerHTML = `
<i class="bi bi-info-circle"></i> Aucun résultat trouvé pour votre recherche.
`;
resultsList.appendChild(noResultsMessage);
}
// Add "Not found" button
if (true) {
const notFoundButton = document.createElement('div');
notFoundButton.classList.add('mt-3', 'text-center');
notFoundButton.innerHTML = `
<button id="notFoundButton" class="btn btn-outline-secondary">
<i class="bi bi-search"></i> Je ne trouve pas mon commerce dans ces propositions
</button>
`;
resultsList.appendChild(notFoundButton);
// Add event listener to the "Not found" button
document.getElementById('notFoundButton').addEventListener('click', () => {
// Clear results list
resultsList.innerHTML = '';
// Show form to enter email
const notFoundForm = document.createElement('div');
notFoundForm.classList.add('card', 'mt-3');
notFoundForm.innerHTML = `
<div class="card-header">
<h5>Vous ne trouvez pas votre commerce ?</h5>
</div>
<div class="card-body">
<p>Laissez-nous votre email et nous vous aiderons à ajouter votre commerce.</p>
<form id="notFoundForm">
<div class="mb-3">
<label for="notFoundEmail" class="form-label">Email</label>
<input type="email" class="form-control" id="notFoundEmail" required>
</div>
<button type="submit" class="btn btn-primary">Envoyer</button>
</form>
</div>
`;
resultsList.appendChild(notFoundForm);
// Add event listener to the form
document.getElementById('notFoundForm').addEventListener('submit', (e) => {
e.preventDefault();
const email = document.getElementById('notFoundEmail').value;
const query = document.querySelector('#researchShop').value;
// Try to get the INSEE code from the first search result
let insee = null;
if (data.features && data.features.length > 0) {
insee = data.features[0].properties.citycode;
}
// Create a Demande with the business name and email
fetch('/api/demande/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
businessName: query,
insee: insee
}),
})
.then(response => response.json())
.then(data => {
if (data.success) {
const demandeId = data.id;
// Update the Demande with the email
fetch(`/api/demande/${demandeId}/email`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: email
}),
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Show success message
resultsList.innerHTML = `
<div class="alert alert-success mt-3">
<i class="bi bi-check-circle"></i> Votre demande a été enregistrée. Nous vous contacterons bientôt.
</div>
`;
} else {
alert('Erreur: ' + data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('Une erreur est survenue lors de la mise à jour de la demande.');
});
} else {
alert('Erreur: ' + data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('Une erreur est survenue lors de la création de la demande.');
});
});
});
}
data.features.forEach(feature => { data.features.forEach(feature => {
const li = document.createElement('li'); const li = document.createElement('li');
li.classList.add('list-group-item', 'cursor-pointer'); li.classList.add('list-group-item', 'cursor-pointer');
@ -415,17 +525,39 @@
); );
out body;`; out body;`;
console.log('li clicked', li)
fetch('https://overpass-api.de/api/interpreter', { fetch('https://overpass-api.de/api/interpreter', {
method: 'POST', method: 'POST',
body: query body: query
}) })
.then(response => response.json()) .then(response => response.json())
.then(osmData => { .then(osmData => {
console.log('osmData', osmData)
if (osmData.elements.length > 0) { if (osmData.elements.length > 0) {
const place = osmData.elements[0]; console.log('osmData.elements', osmData.elements)
const place = osmData.elements[1];
console.log('place', place)
console.log(`https://www.openstreetmap.org/${place.type}/${place.id}`, place.tags); console.log(`https://www.openstreetmap.org/${place.type}/${place.id}`, place.tags);
// Create a Demande with the business name
fetch('/api/demande/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
businessName: `${query_input_user} __ ${place.tags.name}` || `${feature.properties.name}, ${feature.properties.city}`,
insee: feature.properties.citycode
}),
})
.then(response => response.json())
.then(data => {
if (data.success) {
const demandeId = data.id;
if (place.tags && (place.tags['contact:email'] || place.tags['email'])) { if (place.tags && (place.tags['contact:email'] || place.tags['email'])) {
document.querySelector('#proposeLink').classList.remove('d-none'); document.querySelector('#proposeLink').classList.remove('d-none');
document.querySelector('#proposeMail').classList.add('d-none'); document.querySelector('#proposeMail').classList.add('d-none');
@ -437,9 +569,40 @@
emailForm.addEventListener('submit', (e) => { emailForm.addEventListener('submit', (e) => {
e.preventDefault(); e.preventDefault();
const email = emailForm.querySelector('#emailInput').value; const email = emailForm.querySelector('#emailInput').value;
// Update the Demande with the email
fetch(`/api/demande/${demandeId}/email`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: email
}),
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Redirect to the original route for backward compatibility
window.location.href = `/propose-email/${email}/${place.type}/${place.id}`; window.location.href = `/propose-email/${email}/${place.type}/${place.id}`;
} else {
alert('Erreur: ' + data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('Une erreur est survenue lors de la mise à jour de la demande.');
});
}); });
} }
} else {
alert('Erreur: ' + data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('Une erreur est survenue lors de la création de la demande.');
});
} }
}); });
}); });
@ -450,12 +613,7 @@
}, 500); }, 500);
}); });
function displayStatsBubble() {
const statsBubble = document.querySelector('#stats_bubble');
}
}); });
</script> </script>
{% endblock %} {% endblock %}

View file

@ -17,12 +17,14 @@
</a> </a>
<ul class="dropdown-menu" aria-labelledby="dataDropdown"> <ul class="dropdown-menu" aria-labelledby="dataDropdown">
<li><a class="dropdown-item" href="{{ path('app_public_dashboard') }}"><i class="bi bi-bar-chart-fill"></i> {{ 'display.stats'|trans }}</a></li> <li><a class="dropdown-item" href="{{ path('app_public_dashboard') }}"><i class="bi bi-bar-chart-fill"></i> {{ 'display.stats'|trans }}</a></li>
<li><a class="dropdown-item" href="{{ path('app_public_cities') }}"><i class="bi bi-geo-alt-fill"></i> Suivi des villes</a></li>
<li><a class="dropdown-item" href="{{ path('app_public_closed_commerces') }}"><i class="bi bi-x-circle-fill"></i> {{ 'display.closed_commerces'|trans }}</a></li> <li><a class="dropdown-item" href="{{ path('app_public_closed_commerces') }}"><i class="bi bi-x-circle-fill"></i> {{ 'display.closed_commerces'|trans }}</a></li>
<li><a class="dropdown-item" href="{{ path('app_public_places_with_note') }}"><i class="bi bi-file-earmark-text"></i> {{ 'display.places_with_note'|trans }}</a></li> <li><a class="dropdown-item" href="{{ path('app_public_places_with_note') }}"><i class="bi bi-file-earmark-text"></i> {{ 'display.places_with_note'|trans }}</a></li>
<li><a class="dropdown-item" href="{{ path('app_public_latest_changes') }}"><i class="bi bi-clock-fill"></i> {{ 'display.latest_changes'|trans }}</a></li> <li><a class="dropdown-item" href="{{ path('app_public_latest_changes') }}"><i class="bi bi-clock-fill"></i> {{ 'display.latest_changes'|trans }}</a></li>
<li><a class="dropdown-item" href="{{ path('admin_fraicheur_histogramme') }}"><i class="bi bi-clock-history"></i> Fraîcheur de la donnée</a></li> <li><a class="dropdown-item" href="{{ path('admin_fraicheur_histogramme') }}"><i class="bi bi-clock-history"></i> Fraîcheur de la donnée</a></li>
<li><a class="dropdown-item" href="/api/v1/stats/export?pretty=1"><i class="bi bi-download"></i> Export JSON des villes</a></li> <li><a class="dropdown-item" href="/api/v1/stats/export?pretty=1"><i class="bi bi-download"></i> Export JSON des villes</a></li>
<li><a class="dropdown-item" href="/admin/export_csv"><i class="bi bi-filetype-csv"></i> Exporter les villes (CSV)</a></li> <li><a class="dropdown-item" href="/admin/export_csv"><i class="bi bi-filetype-csv"></i> Exporter les villes (CSV)</a></li>
<li><a class="dropdown-item" href="{{ path('app_public_rss_demandes') }}"><i class="bi bi-rss-fill"></i> Flux RSS des demandes</a></li>
</ul> </ul>
</li> </li>
<li class="nav-item"> <li class="nav-item">
@ -39,6 +41,9 @@
<i class="bi bi-gear"></i> Admin <i class="bi bi-gear"></i> Admin
</a> </a>
<ul class="dropdown-menu" aria-labelledby="adminDropdown"> <ul class="dropdown-menu" aria-labelledby="adminDropdown">
<li><a class="dropdown-item" href="{{ path('app_admin_demandes') }}"><i class="bi bi-list-ul"></i> Liste des demandes</a></li>
<li><a class="dropdown-item" href="{{ path('app_admin_contacted_places') }}"><i class="bi bi-envelope"></i> Places contactées</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ path('app_admin_podium_contributeurs_osm') }}"><i class="bi bi-trophy-fill"></i> Podium des contributeurs OSM</a></li> <li><a class="dropdown-item" href="{{ path('app_admin_podium_contributeurs_osm') }}"><i class="bi bi-trophy-fill"></i> Podium des contributeurs OSM</a></li>
<li><a class="dropdown-item" href="{{ path('admin_followup_global_graph') }}"><i class="bi bi-globe"></i> Suivi global OSM</a></li> <li><a class="dropdown-item" href="{{ path('admin_followup_global_graph') }}"><i class="bi bi-globe"></i> Suivi global OSM</a></li>
<li><a class="dropdown-item" href="{{ path('app_admin_import_stats') }}"><i class="bi bi-upload"></i> Import Stats</a></li> <li><a class="dropdown-item" href="{{ path('app_admin_import_stats') }}"><i class="bi bi-upload"></i> Import Stats</a></li>

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Mon Commerce OSM - Demandes</title>
<link>https://{{ base_url }}</link>
<description>Flux RSS des demandes de modification de commerces sur OpenStreetMap</description>
<language>fr-fr</language>
<pubDate>{{ "now"|date("D, d M Y H:i:s O") }}</pubDate>
<atom:link href="https://{{ base_url }}{{ path('app_public_rss_demandes') }}" rel="self" type="application/rss+xml" />
{% for demande in demandes %}
<item>
<title>{{ demande.query }}</title>
<link>https://{{ base_url }}{{ path('app_admin_demande_edit', {'id': demande.id}) }}</link>
<guid>https://{{ base_url }}{{ path('app_admin_demande_edit', {'id': demande.id}) }}</guid>
<pubDate>{{ demande.createdAt|date("D, d M Y H:i:s O") }}</pubDate>
<description>
<![CDATA[
<p><strong>Nom du commerce:</strong> {{ demande.query }}</p>
{% if demande.email %}
<p><strong>Email:</strong> {{ demande.email }}</p>
{% endif %}
<p><strong>Statut:</strong> {{ demande.status }}</p>
{% if demande.insee %}
<p><strong>Code INSEE:</strong> {{ demande.insee }}</p>
{% endif %}
{% if demande.lastContactAttempt %}
<p><strong>Dernière tentative de contact:</strong> {{ demande.lastContactAttempt|date("d/m/Y H:i:s") }}</p>
{% endif %}
]]>
</description>
</item>
{% endfor %}
</channel>
</rss>

48
test_city_followup.php Normal file
View file

@ -0,0 +1,48 @@
<?php
// Test script for the new city-followup API endpoint
// Replace with your actual Symfony application URL
$url = 'http://localhost:8000/api/city-followup';
// Test data
$data = [
'insee_code' => '75056', // Example INSEE code for Paris
'measure_label' => 'test_measure',
'measure_value' => 42.5
];
// Function to make a POST request
function makePostRequest($url, $data) {
$options = [
'http' => [
'header' => "Content-type: application/x-www-form-urlencoded\r\n",
'method' => 'POST',
'content' => http_build_query($data)
]
];
$context = stream_context_create($options);
$result = file_get_contents($url, false, $context);
if ($result === FALSE) {
echo "Error making request\n";
return null;
}
return $result;
}
// First request should succeed
echo "Making first request...\n";
$response = makePostRequest($url, $data);
echo "Response: " . $response . "\n\n";
// Second request within an hour should fail
echo "Making second request (should fail due to 1-hour limit)...\n";
$response = makePostRequest($url, $data);
echo "Response: " . $response . "\n\n";
// Try with a different measure label (should succeed)
echo "Making request with different measure label...\n";
$data['measure_label'] = 'another_test_measure';
$response = makePostRequest($url, $data);
echo "Response: " . $response . "\n";