menu latéral ville
This commit is contained in:
parent
f4c5e048ff
commit
2e459122b5
11 changed files with 1008 additions and 236 deletions
84
public/css/city-sidebar.css
Normal file
84
public/css/city-sidebar.css
Normal file
|
@ -0,0 +1,84 @@
|
|||
/* Styles pour la sidebar */
|
||||
.city-sidebar {
|
||||
background-color: #f8f9fa;
|
||||
border-right: 1px solid #dee2e6;
|
||||
padding: 1rem;
|
||||
height: 100%;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* Desktop styles */
|
||||
@media (min-width: 768px) {
|
||||
.city-sidebar {
|
||||
width: 25%;
|
||||
max-width: 280px;
|
||||
}
|
||||
.main-content {
|
||||
margin-left: 25%;
|
||||
width: 75%;
|
||||
padding-top: 20px;
|
||||
}
|
||||
.main-header {
|
||||
margin-left: 25%;
|
||||
width: 75%;
|
||||
z-index: 1001;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile styles */
|
||||
@media (max-width: 767px) {
|
||||
.city-sidebar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-height: none;
|
||||
}
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.main-header {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.city-sidebar .nav-link {
|
||||
color: #495057;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.5rem 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.city-sidebar .nav-link:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.city-sidebar .nav-link.active {
|
||||
background-color: #0d6efd;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.city-sidebar .nav-link i {
|
||||
width: 1.5rem;
|
||||
text-align: center;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.city-sidebar .sidebar-heading {
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1rem;
|
||||
color: #6c757d;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.section-anchor {
|
||||
scroll-margin-top: 2rem;
|
||||
}
|
103
src/Command/CreateStatsFromDemandesCommand.php
Normal file
103
src/Command/CreateStatsFromDemandesCommand.php
Normal file
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Demande;
|
||||
use App\Entity\Stats;
|
||||
use App\Repository\DemandeRepository;
|
||||
use App\Repository\StatsRepository;
|
||||
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:create-stats-from-demandes',
|
||||
description: 'Create Stats objects for cities with Demandes but no Stats',
|
||||
)]
|
||||
class CreateStatsFromDemandesCommand extends Command
|
||||
{
|
||||
private EntityManagerInterface $entityManager;
|
||||
private DemandeRepository $demandeRepository;
|
||||
private StatsRepository $statsRepository;
|
||||
|
||||
public function __construct(
|
||||
EntityManagerInterface $entityManager,
|
||||
DemandeRepository $demandeRepository,
|
||||
StatsRepository $statsRepository
|
||||
) {
|
||||
parent::__construct();
|
||||
$this->entityManager = $entityManager;
|
||||
$this->demandeRepository = $demandeRepository;
|
||||
$this->statsRepository = $statsRepository;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$io->title('Creating Stats objects for cities with Demandes but no Stats');
|
||||
|
||||
// Find all Demandes with INSEE codes
|
||||
$demandesWithInsee = $this->demandeRepository->createQueryBuilder('d')
|
||||
->where('d.insee IS NOT NULL')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
if (empty($demandesWithInsee)) {
|
||||
$io->warning('No Demandes with INSEE codes found.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->info(sprintf('Found %d Demandes with INSEE codes.', count($demandesWithInsee)));
|
||||
|
||||
// Group Demandes by INSEE code
|
||||
$demandesByInsee = [];
|
||||
/** @var Demande $demande */
|
||||
foreach ($demandesWithInsee as $demande) {
|
||||
$insee = $demande->getInsee();
|
||||
if (!isset($demandesByInsee[$insee])) {
|
||||
$demandesByInsee[$insee] = [];
|
||||
}
|
||||
$demandesByInsee[$insee][] = $demande;
|
||||
}
|
||||
|
||||
$io->info(sprintf('Found %d unique INSEE codes.', count($demandesByInsee)));
|
||||
|
||||
// Check which INSEE codes don't have Stats objects
|
||||
$newStatsCount = 0;
|
||||
foreach ($demandesByInsee as $insee => $demandes) {
|
||||
$stats = $this->statsRepository->findOneBy(['zone' => $insee]);
|
||||
if ($stats === null) {
|
||||
// Create a new Stats object for this INSEE code
|
||||
$stats = new Stats();
|
||||
$stats->setZone((string) $insee);
|
||||
|
||||
// Try to set the city name from the first Demande
|
||||
$firstDemande = $demandes[0];
|
||||
if ($firstDemande->getQuery()) {
|
||||
// Use the query as a fallback name (will be updated during labourage)
|
||||
$stats->setName($firstDemande->getQuery());
|
||||
}
|
||||
|
||||
$stats->setDateCreated(new \DateTime());
|
||||
$stats->setDateLabourageRequested(new \DateTime());
|
||||
|
||||
$this->entityManager->persist($stats);
|
||||
$newStatsCount++;
|
||||
|
||||
$io->text(sprintf('Created Stats for INSEE code %s', $insee));
|
||||
}
|
||||
}
|
||||
|
||||
if ($newStatsCount > 0) {
|
||||
$this->entityManager->flush();
|
||||
$io->success(sprintf('Created %d new Stats objects.', $newStatsCount));
|
||||
} else {
|
||||
$io->info('No new Stats objects needed to be created.');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
202
src/Command/LinkDemandesPlacesCommand.php
Normal file
202
src/Command/LinkDemandesPlacesCommand.php
Normal file
|
@ -0,0 +1,202 @@
|
|||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Demande;
|
||||
use App\Entity\Place;
|
||||
use App\Repository\DemandeRepository;
|
||||
use App\Repository\PlaceRepository;
|
||||
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\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:link-demandes-places',
|
||||
description: 'Link Demandes to Places based on name similarity',
|
||||
)]
|
||||
class LinkDemandesPlacesCommand extends Command
|
||||
{
|
||||
private EntityManagerInterface $entityManager;
|
||||
private DemandeRepository $demandeRepository;
|
||||
private PlaceRepository $placeRepository;
|
||||
|
||||
public function __construct(
|
||||
EntityManagerInterface $entityManager,
|
||||
DemandeRepository $demandeRepository,
|
||||
PlaceRepository $placeRepository
|
||||
) {
|
||||
parent::__construct();
|
||||
$this->entityManager = $entityManager;
|
||||
$this->demandeRepository = $demandeRepository;
|
||||
$this->placeRepository = $placeRepository;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('threshold', null, InputOption::VALUE_REQUIRED, 'Similarity threshold (0-100)', 70)
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show matches without linking');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$io->title('Linking Demandes to Places based on name similarity');
|
||||
|
||||
$threshold = (int) $input->getOption('threshold');
|
||||
$dryRun = $input->getOption('dry-run');
|
||||
|
||||
if ($threshold < 0 || $threshold > 100) {
|
||||
$io->error('Threshold must be between 0 and 100');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Find all Demandes without linked Places
|
||||
$demandesWithoutPlace = $this->demandeRepository->createQueryBuilder('d')
|
||||
->where('d.place IS NULL')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
if (empty($demandesWithoutPlace)) {
|
||||
$io->warning('No Demandes without linked Places found.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->info(sprintf('Found %d Demandes without linked Places.', count($demandesWithoutPlace)));
|
||||
|
||||
// Process each Demande
|
||||
$linkedCount = 0;
|
||||
/** @var Demande $demande */
|
||||
foreach ($demandesWithoutPlace as $demande) {
|
||||
$query = $demande->getQuery();
|
||||
$insee = $demande->getInsee();
|
||||
|
||||
if (empty($query)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find Places with similar names
|
||||
$places = $this->findSimilarPlaces($query, $insee);
|
||||
if (empty($places)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the best match
|
||||
$bestMatch = null;
|
||||
$bestSimilarity = 0;
|
||||
foreach ($places as $place) {
|
||||
$similarity = $this->calculateSimilarity($query, $place->getName());
|
||||
if ($similarity > $bestSimilarity) {
|
||||
$bestSimilarity = $similarity;
|
||||
$bestMatch = $place;
|
||||
}
|
||||
}
|
||||
|
||||
// If similarity is above threshold, link the Demande to the Place
|
||||
if ($bestMatch && $bestSimilarity >= $threshold) {
|
||||
$io->text(sprintf(
|
||||
'Match found: "%s" (Demande) -> "%s" (Place) with similarity %d%%',
|
||||
$query,
|
||||
$bestMatch->getName(),
|
||||
$bestSimilarity
|
||||
));
|
||||
|
||||
if (!$dryRun) {
|
||||
$demande->setPlace($bestMatch);
|
||||
$demande->setStatus('linked_to_place');
|
||||
$this->entityManager->persist($demande);
|
||||
$linkedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$dryRun && $linkedCount > 0) {
|
||||
$this->entityManager->flush();
|
||||
$io->success(sprintf('Linked %d Demandes to Places.', $linkedCount));
|
||||
} elseif ($dryRun) {
|
||||
$io->info('Dry run completed. No changes were made.');
|
||||
} else {
|
||||
$io->info('No Demandes were linked to Places.');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find Places with names similar to the query
|
||||
*/
|
||||
private function findSimilarPlaces(string $query, ?int $insee): array
|
||||
{
|
||||
$queryBuilder = $this->placeRepository->createQueryBuilder('p')
|
||||
->where('p.name IS NOT NULL');
|
||||
|
||||
// If INSEE code is available, filter by Stats zone
|
||||
if ($insee !== null) {
|
||||
$queryBuilder
|
||||
->join('p.stats', 's')
|
||||
->andWhere('s.zone = :insee')
|
||||
->setParameter('insee', (string) $insee);
|
||||
}
|
||||
|
||||
// Use LIKE for initial filtering to reduce the number of results
|
||||
$queryBuilder
|
||||
->andWhere('p.name LIKE :query')
|
||||
->setParameter('query', '%' . $this->sanitizeForLike($query) . '%');
|
||||
|
||||
return $queryBuilder->getQuery()->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate similarity between two strings (0-100)
|
||||
*/
|
||||
private function calculateSimilarity(string $str1, string $str2): int
|
||||
{
|
||||
// Normalize strings for comparison
|
||||
$str1 = $this->normalizeString($str1);
|
||||
$str2 = $this->normalizeString($str2);
|
||||
|
||||
// If either string is empty after normalization, return 0
|
||||
if (empty($str1) || empty($str2)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Calculate Levenshtein distance
|
||||
$levenshtein = levenshtein($str1, $str2);
|
||||
$maxLength = max(strlen($str1), strlen($str2));
|
||||
|
||||
// Convert to similarity percentage (0-100)
|
||||
$similarity = (1 - $levenshtein / $maxLength) * 100;
|
||||
|
||||
return (int) $similarity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a string for comparison
|
||||
*/
|
||||
private function normalizeString(string $str): string
|
||||
{
|
||||
// Convert to lowercase
|
||||
$str = mb_strtolower($str);
|
||||
|
||||
// Remove accents
|
||||
$str = transliterator_transliterate('Any-Latin; Latin-ASCII', $str);
|
||||
|
||||
// Remove special characters and extra spaces
|
||||
$str = preg_replace('/[^a-z0-9\s]/', '', $str);
|
||||
$str = preg_replace('/\s+/', ' ', $str);
|
||||
|
||||
return trim($str);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a string for use in LIKE queries
|
||||
*/
|
||||
private function sanitizeForLike(string $str): string
|
||||
{
|
||||
return str_replace(['%', '_'], ['\%', '\_'], $str);
|
||||
}
|
||||
}
|
|
@ -1007,6 +1007,101 @@ class PublicController extends AbstractController
|
|||
return $response;
|
||||
}
|
||||
|
||||
#[Route('/rss/city/{insee_code}/demandes', name: 'app_public_rss_city_demandes')]
|
||||
public function rssCityDemandes(string $insee_code): Response
|
||||
{
|
||||
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
|
||||
if (!$stats) {
|
||||
throw $this->createNotFoundException('Ville non trouvée');
|
||||
}
|
||||
|
||||
// Récupérer les demandes pour cette ville
|
||||
$demandes = $this->entityManager->getRepository(Demande::class)
|
||||
->createQueryBuilder('d')
|
||||
->where('d.insee = :insee')
|
||||
->setParameter('insee', $insee_code)
|
||||
->orderBy('d.createdAt', 'DESC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
$content = $this->renderView('public/rss/city_demandes.xml.twig', [
|
||||
'demandes' => $demandes,
|
||||
'city' => $stats,
|
||||
'base_url' => $this->getParameter('router.request_context.host'),
|
||||
]);
|
||||
|
||||
$response = new Response($content);
|
||||
$response->headers->set('Content-Type', 'application/rss+xml');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
#[Route('/rss/city/{insee_code}/themes', name: 'app_public_rss_city_themes')]
|
||||
public function rssCityThemes(string $insee_code): Response
|
||||
{
|
||||
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
|
||||
if (!$stats) {
|
||||
throw $this->createNotFoundException('Ville non trouvée');
|
||||
}
|
||||
|
||||
// Récupérer les changements thématiques pour cette ville
|
||||
$followups = $stats->getCityFollowUps();
|
||||
$themeChanges = [];
|
||||
|
||||
foreach ($followups as $followup) {
|
||||
$name = $followup->getName();
|
||||
if (str_ends_with($name, '_count')) {
|
||||
$type = substr($name, 0, -6);
|
||||
if (!isset($themeChanges[$type])) {
|
||||
$themeChanges[$type] = [];
|
||||
}
|
||||
$themeChanges[$type][] = $followup;
|
||||
}
|
||||
}
|
||||
|
||||
// Trier les changements par date pour chaque thème
|
||||
foreach ($themeChanges as &$changes) {
|
||||
usort($changes, function($a, $b) {
|
||||
return $b->getDate() <=> $a->getDate();
|
||||
});
|
||||
}
|
||||
|
||||
$content = $this->renderView('public/rss/city_themes.xml.twig', [
|
||||
'themeChanges' => $themeChanges,
|
||||
'city' => $stats,
|
||||
'base_url' => $this->getParameter('router.request_context.host'),
|
||||
'followup_labels' => \App\Service\FollowUpService::getFollowUpThemes(),
|
||||
]);
|
||||
|
||||
$response = new Response($content);
|
||||
$response->headers->set('Content-Type', 'application/rss+xml');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
#[Route('/city/{insee_code}/demandes', name: 'app_public_city_demandes')]
|
||||
public function cityDemandes(string $insee_code): Response
|
||||
{
|
||||
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
|
||||
if (!$stats) {
|
||||
throw $this->createNotFoundException('Ville non trouvée');
|
||||
}
|
||||
|
||||
// Récupérer les demandes pour cette ville
|
||||
$demandes = $this->entityManager->getRepository(Demande::class)
|
||||
->createQueryBuilder('d')
|
||||
->where('d.insee = :insee')
|
||||
->setParameter('insee', $insee_code)
|
||||
->orderBy('d.createdAt', 'DESC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
return $this->render('public/city_demandes.html.twig', [
|
||||
'demandes' => $demandes,
|
||||
'city' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/cities', name: 'app_public_cities')]
|
||||
public function cities(): Response
|
||||
{
|
||||
|
|
73
templates/admin/_city_sidebar.html.twig
Normal file
73
templates/admin/_city_sidebar.html.twig
Normal file
|
@ -0,0 +1,73 @@
|
|||
{# City Sidebar Template #}
|
||||
<div class="city-sidebar">
|
||||
<h5 class="mb-3">{{ stats.name }}</h5>
|
||||
<p class="badge {% if stats.getCompletionPercent() > 85 %}bg-success{% else %}bg-warning{% endif %} mb-3">
|
||||
{{ stats.getCompletionPercent() }}% complété
|
||||
</p>
|
||||
|
||||
<!-- Sections de la page -->
|
||||
<div class="sidebar-heading">Sections</div>
|
||||
<nav class="nav flex-column">
|
||||
<a class="nav-link {% if active_menu == 'info-generales' %}active{% endif %}" href="{{ path('app_admin_stats', {'insee_code': stats.zone}) }}#info-generales">
|
||||
<i class="bi bi-info-circle"></i> Informations générales
|
||||
</a>
|
||||
<a class="nav-link {% if active_menu == 'themes' %}active{% endif %}" href="{{ path('app_admin_stats', {'insee_code': stats.zone}) }}#themes">
|
||||
<i class="bi bi-tags"></i> Thèmes
|
||||
</a>
|
||||
<a class="nav-link {% if active_menu == 'carte' %}active{% endif %}" href="{{ path('app_admin_stats', {'insee_code': stats.zone}) }}#carte">
|
||||
<i class="bi bi-map"></i> Carte
|
||||
</a>
|
||||
<a class="nav-link {% if active_menu == 'graphiques' %}active{% endif %}" href="{{ path('app_admin_stats', {'insee_code': stats.zone}) }}#graphiques">
|
||||
<i class="bi bi-graph-up"></i> Graphiques
|
||||
</a>
|
||||
<a class="nav-link {% if active_menu == 'lieux' %}active{% endif %}" href="{{ path('app_admin_stats', {'insee_code': stats.zone}) }}#lieux">
|
||||
<i class="bi bi-building"></i> Lieux
|
||||
</a>
|
||||
<a class="nav-link {% if active_menu == 'podium' %}active{% endif %}" href="{{ path('app_admin_stats', {'insee_code': stats.zone}) }}#podium">
|
||||
<i class="bi bi-trophy"></i> Podium
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- Pages liées -->
|
||||
<div class="sidebar-heading">Pages liées</div>
|
||||
<nav class="nav flex-column">
|
||||
<a class="nav-link {% if active_menu == 'labourer' %}active{% endif %}" href="{{ path('app_admin_labourer', {'insee_code': stats.zone, 'deleteMissing': 1}) }}">
|
||||
<i class="bi bi-tools"></i> Labourer les mises à jour
|
||||
</a>
|
||||
<a class="nav-link {% if active_menu == 'followup_graph' %}active{% endif %}" href="{{ path('admin_followup_graph', {'insee_code': stats.zone}) }}">
|
||||
<i class="bi bi-graph-up"></i> Suivi OSM (graphes)
|
||||
</a>
|
||||
<a class="nav-link {% if active_menu == 'stats_evolutions' %}active{% endif %}" href="{{ path('app_public_stats_evolutions', {'insee_code': stats.zone}) }}">
|
||||
<i class="bi bi-activity"></i> Évolutions des objets
|
||||
</a>
|
||||
<a class="nav-link {% if active_menu == 'street_completion' %}active{% endif %}" href="{{ path('admin_street_completion', {'insee_code': stats.zone}) }}">
|
||||
<i class="bi bi-signpost"></i> Complétion des rues
|
||||
</a>
|
||||
<a class="nav-link {% if active_menu == 'speed_limit' %}active{% endif %}" href="{{ path('admin_speed_limit', {'insee_code': stats.zone}) }}">
|
||||
<i class="bi bi-speedometer2"></i> Limites de vitesse
|
||||
</a>
|
||||
<a class="nav-link {% if active_menu == 'city_demandes' %}active{% endif %}" href="{{ path('app_public_city_demandes', {'insee_code': stats.zone}) }}">
|
||||
<i class="bi bi-list-check"></i> Demandes
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- Flux RSS -->
|
||||
<div class="sidebar-heading">Flux RSS</div>
|
||||
<nav class="nav flex-column">
|
||||
<a class="nav-link" href="{{ path('app_public_rss_city_demandes', {'insee_code': stats.zone}) }}" target="_blank">
|
||||
<i class="bi bi-rss"></i> Demandes
|
||||
</a>
|
||||
<a class="nav-link" href="{{ path('app_public_rss_city_themes', {'insee_code': stats.zone}) }}" target="_blank">
|
||||
<i class="bi bi-rss"></i> Changements thématiques
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="sidebar-heading">Actions</div>
|
||||
<button id="openInJOSM" class="btn btn-secondary btn-sm w-100 mb-2">
|
||||
<i class="bi bi-map"></i> Ouvrir dans JOSM
|
||||
</button>
|
||||
<a href="{{ path('app_admin_labourer', {'insee_code': stats.zone, 'deleteMissing': 1, 'disableFollowUpCleanup': 1}) }}" class="btn btn-warning btn-sm w-100" title="Labourer sans nettoyer les suivis OSM">
|
||||
<i class="bi bi-shield-check"></i> Labourer (sans nettoyage)
|
||||
</a>
|
||||
</div>
|
|
@ -6,6 +6,7 @@
|
|||
{% block stylesheets %}
|
||||
{{ parent() }}
|
||||
<link href='{{ asset('js/maplibre/maplibre-gl.css') }}' rel='stylesheet' />
|
||||
<link href='{{ asset('css/city-sidebar.css') }}' rel='stylesheet' />
|
||||
<style>
|
||||
.completion-circle {
|
||||
fill-opacity: 0.6;
|
||||
|
@ -62,26 +63,26 @@
|
|||
background: #388e3c;
|
||||
border-color: #1b5e20;
|
||||
}
|
||||
|
||||
|
||||
.compact-theme-card {
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
|
||||
.compact-theme-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1) !important;
|
||||
}
|
||||
|
||||
|
||||
.theme-title a {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
|
||||
.theme-stats {
|
||||
font-size: 0.75rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
|
||||
.theme-actions .btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
|
@ -115,35 +116,22 @@
|
|||
|
||||
|
||||
{% block body %}
|
||||
<div class="container">
|
||||
<div class="mt-4 p-4">
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-12">
|
||||
<h1 class="title">{{ 'display.stats'|trans }} - {{ stats.zone }}
|
||||
{{ stats.name }} - {{ stats.completionPercent }}% complété</h1>
|
||||
</div>
|
||||
<div class="col-md-6 col-12">
|
||||
<a href="{{ path('app_admin_labourer', {'insee_code': stats.zone, 'deleteMissing': 1}) }}" class="btn btn-primary" id="labourer">Labourer les mises à jour</a>
|
||||
<a href="{{ path('app_admin_labourer', {'insee_code': stats.zone, 'deleteMissing': 1, 'disableFollowUpCleanup': 1}) }}" class="btn btn-warning ms-2" id="labourer-no-cleanup" title="Labourer sans nettoyer les suivis OSM">
|
||||
<i class="bi bi-shield-check"></i> Labourer (sans nettoyage)
|
||||
</a>
|
||||
<a href="{{ path('admin_followup_graph', {'insee_code': stats.zone}) }}" class="btn btn-info ms-2" id="followup-graph-link">
|
||||
<i class="bi bi-graph-up"></i> Suivi OSM (graphes)
|
||||
</a>
|
||||
<button id="openInJOSM" class="btn btn-secondary ms-2">
|
||||
<i class="bi bi-map"></i> Ouvrir dans JOSM
|
||||
</button>
|
||||
<a href="{{ path('app_public_stats_evolutions', {'insee_code': stats.zone}) }}" class="btn btn-outline-info ms-2">
|
||||
<i class="bi bi-activity"></i> Évolutions des objets
|
||||
</a>
|
||||
<a href="{{ path('admin_street_completion', {'insee_code': stats.zone}) }}" class="btn btn-outline-success ms-2">
|
||||
<i class="bi bi-signpost"></i> Complétion des rues
|
||||
</a>
|
||||
<a href="{{ path('admin_speed_limit', {'insee_code': stats.zone}) }}" class="btn btn-outline-danger ms-2">
|
||||
<i class="bi bi-speedometer2"></i> Limites de vitesse
|
||||
</a>
|
||||
</div>
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<!-- Sidebar de navigation -->
|
||||
<div class="col-12">
|
||||
{% include 'admin/_city_sidebar.html.twig' with {'stats': stats, 'active_menu': 'info-generales'} %}
|
||||
</div>
|
||||
|
||||
<!-- Contenu principal -->
|
||||
<div class="col-md-9 col-lg-10 main-content">
|
||||
<div class="p-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h1 class="title" id="info-generales">{{ 'display.stats'|trans }} - {{ stats.zone }}
|
||||
{{ stats.name }} - {{ stats.completionPercent }}% complété</h1>
|
||||
</div>
|
||||
</div>
|
||||
{% if stats.population %}
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4 col-12">
|
||||
|
@ -184,17 +172,17 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if stats.dateLabourageDone %}
|
||||
<div class="alert alert-info">
|
||||
Dernier labourage : {{ include('admin/_labourage_time_ago.html.twig', { date: stats.dateLabourageDone }) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="row">
|
||||
<div id="followups">
|
||||
|
||||
|
||||
|
||||
{% set overpass_type_queries = {
|
||||
'fire_hydrant': 'nwr["emergency"="fire_hydrant"](area.searchArea);',
|
||||
|
@ -222,8 +210,9 @@
|
|||
'infrastructure': ['toilets', 'recycling', 'substation']
|
||||
} %}
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="row mb-4" id="themes">
|
||||
<div class="col-12">
|
||||
<h2 class="section-anchor">Thèmes</h2>
|
||||
<ul class="nav nav-tabs" id="themeTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="tab-table" data-bs-toggle="tab" data-bs-target="#tabTableContent" type="button" role="tab" aria-controls="tabTableContent" aria-selected="true">Tableau</button>
|
||||
|
@ -427,14 +416,16 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div id="maploader">
|
||||
<div class="spinner-border" role="status">
|
||||
<i class="bi bi-load bi-spin"></i>
|
||||
<span class="visually-hidden">Chargement de la carte...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="carte" class="section-anchor">
|
||||
<h2>Carte</h2>
|
||||
<div id="maploader">
|
||||
<div class="spinner-border" role="status">
|
||||
<i class="bi bi-load bi-spin"></i>
|
||||
<span class="visually-hidden">Chargement de la carte...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end mb-2">
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-outline-primary" id="circleMarkersBtn">
|
||||
|
@ -449,26 +440,27 @@
|
|||
</button>
|
||||
</div>
|
||||
<div id="map" style="height: 400px; width: 100%; margin-bottom: 1rem;"></div>
|
||||
|
||||
<div class="row ">
|
||||
|
||||
<div class="col-md-6 col-12 ">
|
||||
<canvas id="repartition_tags" width="600" height="400" style="max-width:100%; margin: 20px 0;"></canvas>
|
||||
|
||||
|
||||
<div id="graphiques" class="section-anchor">
|
||||
<h2>Graphiques</h2>
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-12">
|
||||
<canvas id="repartition_tags" width="600" height="400" style="max-width:100%; margin: 20px 0;"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-calendar-event"></i> Fréquence des mises à jour par trimestre pour {{stats.name}}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="modificationsByQuarterChart" style="min-height: 250px; width: 100%;"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-12 ">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-calendar-event"></i> Fréquence des mises à jour par trimestre pour {{stats.name}}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="modificationsByQuarterChart" style="min-height: 250px; width: 100%;"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="attribution">
|
||||
<a href="https://www.openstreetmap.org/copyright">Données OpenStreetMap</a>
|
||||
|
@ -479,9 +471,9 @@
|
|||
<canvas id="distribution_completion" class="mt-4 mb-4" height="400"></canvas>
|
||||
|
||||
|
||||
<div class="row">
|
||||
<div class="row" id="lieux">
|
||||
<div class="col-md-6 col-12">
|
||||
<h1 class="card-title p-4">Tableau des {{ stats.places |length }} lieux</h1>
|
||||
<h1 class="card-title p-4 section-anchor">Tableau des {{ stats.places |length }} lieux</h1>
|
||||
</div>
|
||||
<div class="col-md-6 col-12">
|
||||
<div class="btn-group mt-4" role="group">
|
||||
|
@ -507,8 +499,8 @@
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="card mt-4" id="podium">
|
||||
|
@ -574,7 +566,7 @@
|
|||
<div class="card-header">
|
||||
<h2>Requête Overpass</h2>
|
||||
<div id=overPassRequest >
|
||||
|
||||
|
||||
<pre>
|
||||
{{overpass}}
|
||||
</pre>
|
||||
|
@ -609,9 +601,9 @@
|
|||
|
||||
<div class="accordion mb-3" id="accordionStats">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="headingOne">
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="accordion-header" id="headingOne"></h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Espace de dump JSON -->
|
||||
<div id="ctc-json-dump-container" class="mt-4" style="display:none;">
|
||||
|
@ -653,8 +645,12 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="ctc-json-error" class="alert alert-danger mt-4" style="display:none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
@ -935,7 +931,7 @@
|
|||
x: { title: { display: true, text: 'Trimestre' } }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
} else if (modifCanvas) {
|
||||
modifCanvas.parentNode.innerHTML = '<div class="alert alert-info">Aucune donnée de modification disponible pour cette ville.</div>';
|
||||
|
@ -1026,20 +1022,17 @@
|
|||
const completionValues = completionLabels.map(label => completionDistribution[label]);
|
||||
const dc = document.getElementById('distribution_completion');
|
||||
|
||||
if(dc ){
|
||||
if(dc){
|
||||
const completionCtx = dc.getContext ? dc.getContext('2d') : null;
|
||||
if(!completionCtx){
|
||||
console.log('pas de completionCtx' )
|
||||
return ;
|
||||
}
|
||||
console.log('pas de completionCtx');
|
||||
} else {
|
||||
new Chart(completionCtx, {
|
||||
type: 'line',
|
||||
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: completionLabels,
|
||||
tension: 0.3,
|
||||
datasets: [{
|
||||
label: 'Distribution du Taux de Complétion',
|
||||
label: 'Évolution du taux de complétion',
|
||||
data: completionValues,
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.5)',
|
||||
borderColor: 'rgba(75, 192, 192, 1)',
|
||||
|
@ -1052,13 +1045,23 @@ if(dc ){
|
|||
beginAtZero: true
|
||||
}
|
||||
},
|
||||
responsive: true,
|
||||
responsive: true,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Évolution du taux de complétion',
|
||||
font: {
|
||||
size: 16
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}else{
|
||||
console.log('pas de distribution_completion')
|
||||
}
|
||||
} else {
|
||||
console.log('pas de distribution_completion');
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
|
|
118
templates/public/city_demandes.html.twig
Normal file
118
templates/public/city_demandes.html.twig
Normal file
|
@ -0,0 +1,118 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block title %}Demandes pour {{ city.name }}{% endblock %}
|
||||
|
||||
{% block stylesheets %}
|
||||
{{ parent() }}
|
||||
<link href='{{ asset('css/city-sidebar.css') }}' rel='stylesheet' />
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<!-- Sidebar de navigation -->
|
||||
<div class="col-12">
|
||||
{% include 'admin/_city_sidebar.html.twig' with {'stats': city, 'active_menu': 'city_demandes'} %}
|
||||
</div>
|
||||
|
||||
<!-- Contenu principal -->
|
||||
<div class="col-md-9 col-lg-10 main-content">
|
||||
<div class="p-4">
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8">
|
||||
<h1>Demandes pour {{ city.name }}</h1>
|
||||
<p class="text-muted">
|
||||
Code INSEE: {{ city.zone }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
<a href="{{ path('app_public_rss_city_demandes', {'insee_code': city.zone}) }}" class="btn btn-warning" target="_blank">
|
||||
<i class="bi bi-rss"></i> Flux RSS
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>
|
||||
Liste des demandes ({{ 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>Date de création</th>
|
||||
<th>Statut</th>
|
||||
<th>Dernière tentative de contact</th>
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<th>Actions</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for demande in demandes %}
|
||||
<tr>
|
||||
<td>{{ demande.id }}</td>
|
||||
<td>{{ demande.query }}</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.lastContactAttempt ? demande.lastContactAttempt|date('Y-m-d H:i:s') : '' }}</td>
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="{% if is_granted('ROLE_ADMIN') %}6{% else %}5{% endif %}" class="text-center">Aucune demande trouvée pour cette ville</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<p class="text-muted">
|
||||
<i class="bi bi-info-circle"></i> Les demandes sont des requêtes de modification de commerces sur OpenStreetMap pour la ville de {{ city.name }}.
|
||||
</p>
|
||||
<p>
|
||||
<a href="{{ path('app_public_cities') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Retour à la liste des villes
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -162,7 +162,7 @@
|
|||
<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
|
||||
<p class="card-text">Vos informations apparaîtront sur TomTom, Apple Plans, Facebook, CoMaps et des
|
||||
centaines d'autres applications.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -175,8 +175,8 @@
|
|||
<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>
|
||||
<p class="card-text">Mettez à jour vos horaires, contacts et services en quelques secondes
|
||||
seulement, sans avoir à créer de compte.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
33
templates/public/rss/city_demandes.xml.twig
Normal file
33
templates/public/rss/city_demandes.xml.twig
Normal file
|
@ -0,0 +1,33 @@
|
|||
<?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 pour {{ city.name }}</title>
|
||||
<link>https://{{ base_url }}</link>
|
||||
<description>Flux RSS des demandes de modification de commerces sur OpenStreetMap pour la ville de {{ city.name }}</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_city_demandes', {'insee_code': city.zone}) }}" 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>
|
||||
<p><strong>Ville:</strong> {{ city.name }} ({{ city.zone }})</p>
|
||||
{% 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>
|
45
templates/public/rss/city_themes.xml.twig
Normal file
45
templates/public/rss/city_themes.xml.twig
Normal file
|
@ -0,0 +1,45 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>Mon Commerce OSM - Changements thématiques pour {{ city.name }}</title>
|
||||
<link>https://{{ base_url }}</link>
|
||||
<description>Flux RSS des changements thématiques sur OpenStreetMap pour la ville de {{ city.name }}</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_city_themes', {'insee_code': city.zone}) }}" rel="self" type="application/rss+xml" />
|
||||
|
||||
{% for theme, changes in themeChanges %}
|
||||
{% if changes|length > 0 %}
|
||||
{% set latestChange = changes[0] %}
|
||||
<item>
|
||||
<title>{{ followup_labels[theme]|default(theme|capitalize) }} - {{ latestChange.measure }} objets</title>
|
||||
<link>https://{{ base_url }}{{ path('admin_followup_theme_graph', {'insee_code': city.zone, 'theme': theme}) }}</link>
|
||||
<guid>https://{{ base_url }}{{ path('admin_followup_theme_graph', {'insee_code': city.zone, 'theme': theme}) }}/{{ latestChange.date|date('Y-m-d') }}</guid>
|
||||
<pubDate>{{ latestChange.date|date("D, d M Y H:i:s O") }}</pubDate>
|
||||
<description>
|
||||
<![CDATA[
|
||||
<p><strong>Thème:</strong> {{ followup_labels[theme]|default(theme|capitalize) }}</p>
|
||||
<p><strong>Nombre d'objets:</strong> {{ latestChange.measure }}</p>
|
||||
<p><strong>Date de mesure:</strong> {{ latestChange.date|date("d/m/Y H:i:s") }}</p>
|
||||
<p><strong>Ville:</strong> {{ city.name }} ({{ city.zone }})</p>
|
||||
{% if changes|length > 1 %}
|
||||
<p><strong>Évolution:</strong>
|
||||
{% set previousChange = changes[1] %}
|
||||
{% set diff = latestChange.measure - previousChange.measure %}
|
||||
{% if diff > 0 %}
|
||||
<span style="color: green;">+{{ diff }} objets</span>
|
||||
{% elseif diff < 0 %}
|
||||
<span style="color: red;">{{ diff }} objets</span>
|
||||
{% else %}
|
||||
<span>Pas de changement</span>
|
||||
{% endif %}
|
||||
depuis le {{ previousChange.date|date("d/m/Y") }}
|
||||
</p>
|
||||
{% endif %}
|
||||
]]>
|
||||
</description>
|
||||
</item>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</channel>
|
||||
</rss>
|
|
@ -2,155 +2,171 @@
|
|||
|
||||
{% block title %}Évolutions des objets - {{ stats.name }}{% endblock %}
|
||||
|
||||
{% block stylesheets %}
|
||||
{{ parent() }}
|
||||
<link href='{{ asset('css/city-sidebar.css') }}' rel='stylesheet' />
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="container my-5">
|
||||
<h1>Évolutions des objets à {{ stats.name }} ({{ stats.zone }})</h1>
|
||||
<a href="{{ path('app_admin_stats', {'insee_code': stats.zone}) }}" class="btn btn-secondary mb-3"><i class="bi bi-arrow-left"></i> Retour aux stats</a>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<strong>Variation des décomptes d'objets par type</strong>
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<!-- Sidebar de navigation -->
|
||||
<div class="col-12">
|
||||
{% include 'admin/_city_sidebar.html.twig' with {'stats': stats, 'active_menu': 'stats_evolutions'} %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-bordered table-striped table-hover table-responsive table-sort">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th class="text-end">Décompte actuel</th>
|
||||
{% for p in periods %}
|
||||
<th class="text-end">Évolution sur {{ p }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for type, evo in evolutions %}
|
||||
<tr>
|
||||
<td style="min-width: 15rem;">
|
||||
<a href="{{ path('admin_followup_theme_graph', {'insee_code': stats.zone, 'theme': type}) }}" class="btn btn-outline-secondary btn-sm ms-2"
|
||||
title="Voir le graphe détaillé">
|
||||
<i class="bi bi-bar-chart"></i>
|
||||
</a>
|
||||
{{ tag_emoji(type) }} {{ type }}
|
||||
</td>
|
||||
<td class="text-end">{{ evo.now }}</td>
|
||||
{% for p in periods %}
|
||||
<td class="text-end{% if evo[p] is not null and evo[p] != 0 %} bg-success-subtle{% if evo[p] < 0 %} bg-danger-subtle{% endif %}{% endif %}">
|
||||
{% if evo[p] is not null %}
|
||||
{% if evo[p] > 0 %}
|
||||
<i class="bi bi-arrow-up text-success"></i>
|
||||
{% elseif evo[p] < 0 %}
|
||||
<i class="bi bi-arrow-down text-danger"></i>
|
||||
{% endif %}
|
||||
{{ evo[p] > 0 ? '+' ~ evo[p] : evo[p] }}
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="text-muted">Aucune donnée d'évolution trouvée.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Contenu principal -->
|
||||
<div class="col-md-9 col-lg-10 main-content">
|
||||
<div class="p-4">
|
||||
<h1>Évolutions des objets à {{ stats.name }} ({{ stats.zone }})</h1>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<strong>Variation des décomptes d'objets par type</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-bordered table-striped table-hover table-responsive table-sort">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th class="text-end">Décompte actuel</th>
|
||||
{% for p in periods %}
|
||||
<th class="text-end">Évolution sur {{ p }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for type, evo in evolutions %}
|
||||
<tr>
|
||||
<td style="min-width: 15rem;">
|
||||
<a href="{{ path('admin_followup_theme_graph', {'insee_code': stats.zone, 'theme': type}) }}" class="btn btn-outline-secondary btn-sm ms-2"
|
||||
title="Voir le graphe détaillé">
|
||||
<i class="bi bi-bar-chart"></i>
|
||||
</a>
|
||||
{{ tag_emoji(type) }} {{ type }}
|
||||
</td>
|
||||
<td class="text-end">{{ evo.now }}</td>
|
||||
{% for p in periods %}
|
||||
<td class="text-end{% if evo[p] is not null and evo[p] != 0 %} bg-success-subtle{% if evo[p] < 0 %} bg-danger-subtle{% endif %}{% endif %}">
|
||||
{% if evo[p] is not null %}
|
||||
{% if evo[p] > 0 %}
|
||||
<i class="bi bi-arrow-up text-success"></i>
|
||||
{% elseif evo[p] < 0 %}
|
||||
<i class="bi bi-arrow-down text-danger"></i>
|
||||
{% endif %}
|
||||
{{ evo[p] > 0 ? '+' ~ evo[p] : evo[p] }}
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="text-muted">Aucune donnée d'évolution trouvée.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-5">
|
||||
<div class="col-md-4">
|
||||
<h3>Lieux modifiés cette semaine</h3>
|
||||
{% if places_7j|length > 0 %}
|
||||
<ul class="list-group mb-4">
|
||||
{% for place in places_7j %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
<strong>
|
||||
<a href="{{ path('app_public_edit', {'zipcode': place.zipCode, 'name': (place.name|default('sans-nom'))|url_encode, 'uuid': place.uuidForUrl}) }}">
|
||||
{{ place.name ?: '(sans nom)' }}
|
||||
</a>
|
||||
</strong><br>
|
||||
<small>
|
||||
{% if place.street %}
|
||||
<a href="{{ path('app_public_street', {'cityId': stats.zone, 'streetName': place.street|url_encode }) }}">{{ place.street }}</a>
|
||||
{# {% else %}
|
||||
<span class="text-muted">(inconnue)</span> #}
|
||||
{% endif %}
|
||||
{{ place.housenumber }}
|
||||
</small>
|
||||
</span>
|
||||
<span>
|
||||
<span class="badge bg-primary">{{ place.getModifiedDate() ? place.getModifiedDate()|date('Y-m-d H:i') : '' }}</span>
|
||||
<a href="https://www.openstreetmap.org/{{ place.osmKind }}/{{ place.osmId }}" target="_blank" class="btn btn-outline-secondary btn-sm ms-2" title="Voir sur OSM"><i class="bi bi-globe"></i></a>
|
||||
</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-muted">Aucun lieu modifié dans les 7 derniers jours.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<h3>Ce mois-ci</h3>
|
||||
{% if places_30j|length > 0 %}
|
||||
<ul class="list-group mb-4">
|
||||
{% for place in places_30j %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
<strong>
|
||||
<a href="{{ path('app_public_edit', {'zipcode': place.zipCode, 'name': (place.name|default('sans-nom'))|url_encode, 'uuid': place.uuidForUrl}) }}">
|
||||
{{ place.name ?: '(sans nom)' }}
|
||||
</a>
|
||||
</strong><br>
|
||||
<small>
|
||||
{% if place.street %}
|
||||
<a href="{{ path('app_public_street', {'cityId': stats.zone, 'streetName': place.street|url_encode }) }}">{{ place.street }}</a>
|
||||
{# {% else %}
|
||||
<span class="text-muted">(inconnue)</span> #}
|
||||
{% endif %}
|
||||
{{ place.housenumber }}
|
||||
</small>
|
||||
</span>
|
||||
<span>
|
||||
<span class="badge bg-primary">{{ place.getModifiedDate() ? place.getModifiedDate()|date('Y-m-d H:i') : '' }}</span>
|
||||
<a href="https://www.openstreetmap.org/{{ place.osmKind }}/{{ place.osmId }}" target="_blank" class="btn btn-outline-secondary btn-sm ms-2" title="Voir sur OSM"><i class="bi bi-globe"></i></a>
|
||||
</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-muted">Aucun lieu modifié dans les 30 derniers jours.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<h3>6 derniers mois</h3>
|
||||
{% if places_6mois|length > 0 %}
|
||||
<ul class="list-group mb-4">
|
||||
{% for place in places_6mois %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
<strong>
|
||||
<a href="{{ path('app_public_edit', {'zipcode': place.zipCode, 'name': (place.name|default('sans-nom'))|url_encode, 'uuid': place.uuidForUrl}) }}">
|
||||
{{ place.name ?: '(sans nom)' }}
|
||||
</a>
|
||||
</strong><br>
|
||||
<small>
|
||||
{% if place.street %}
|
||||
<a href="{{ path('app_public_street', {'cityId': stats.zone, 'streetName': place.street|url_encode }) }}">{{ place.street }}</a>
|
||||
{# {% else %}
|
||||
<span class="text-muted">(inconnue)</span> #}
|
||||
{% endif %}
|
||||
{{ place.housenumber }}
|
||||
</small>
|
||||
</span>
|
||||
<span>
|
||||
<span class="badge bg-primary">{{ place.getModifiedDate() ? place.getModifiedDate()|date('Y-m-d H:i') : '' }}</span>
|
||||
<a href="https://www.openstreetmap.org/{{ place.osmKind }}/{{ place.osmId }}" target="_blank" class="btn btn-outline-secondary btn-sm ms-2" title="Voir sur OSM"><i class="bi bi-globe"></i></a>
|
||||
</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-muted">Aucun lieu modifié dans les 6 derniers mois.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-5">
|
||||
<div class="col-md-4">
|
||||
<h3>Lieux modifiés cette semaine</h3>
|
||||
{% if places_7j|length > 0 %}
|
||||
<ul class="list-group mb-4">
|
||||
{% for place in places_7j %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
<strong>
|
||||
<a href="{{ path('app_public_edit', {'zipcode': place.zipCode, 'name': (place.name|default('sans-nom'))|url_encode, 'uuid': place.uuidForUrl}) }}">
|
||||
{{ place.name ?: '(sans nom)' }}
|
||||
</a>
|
||||
</strong><br>
|
||||
<small>
|
||||
{% if place.street %}
|
||||
<a href="{{ path('app_public_street', {'cityId': stats.zone, 'streetName': place.street|url_encode }) }}">{{ place.street }}</a>
|
||||
{# {% else %}
|
||||
<span class="text-muted">(inconnue)</span> #}
|
||||
{% endif %}
|
||||
{{ place.housenumber }}
|
||||
</small>
|
||||
</span>
|
||||
<span>
|
||||
<span class="badge bg-primary">{{ place.getModifiedDate() ? place.getModifiedDate()|date('Y-m-d H:i') : '' }}</span>
|
||||
<a href="https://www.openstreetmap.org/{{ place.osmKind }}/{{ place.osmId }}" target="_blank" class="btn btn-outline-secondary btn-sm ms-2" title="Voir sur OSM"><i class="bi bi-globe"></i></a>
|
||||
</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-muted">Aucun lieu modifié dans les 7 derniers jours.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<h3>Ce mois-ci</h3>
|
||||
{% if places_30j|length > 0 %}
|
||||
<ul class="list-group mb-4">
|
||||
{% for place in places_30j %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
<strong>
|
||||
<a href="{{ path('app_public_edit', {'zipcode': place.zipCode, 'name': (place.name|default('sans-nom'))|url_encode, 'uuid': place.uuidForUrl}) }}">
|
||||
{{ place.name ?: '(sans nom)' }}
|
||||
</a>
|
||||
</strong><br>
|
||||
<small>
|
||||
{% if place.street %}
|
||||
<a href="{{ path('app_public_street', {'cityId': stats.zone, 'streetName': place.street|url_encode }) }}">{{ place.street }}</a>
|
||||
{# {% else %}
|
||||
<span class="text-muted">(inconnue)</span> #}
|
||||
{% endif %}
|
||||
{{ place.housenumber }}
|
||||
</small>
|
||||
</span>
|
||||
<span>
|
||||
<span class="badge bg-primary">{{ place.getModifiedDate() ? place.getModifiedDate()|date('Y-m-d H:i') : '' }}</span>
|
||||
<a href="https://www.openstreetmap.org/{{ place.osmKind }}/{{ place.osmId }}" target="_blank" class="btn btn-outline-secondary btn-sm ms-2" title="Voir sur OSM"><i class="bi bi-globe"></i></a>
|
||||
</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-muted">Aucun lieu modifié dans les 30 derniers jours.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<h3>6 derniers mois</h3>
|
||||
{% if places_6mois|length > 0 %}
|
||||
<ul class="list-group mb-4">
|
||||
{% for place in places_6mois %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
<strong>
|
||||
<a href="{{ path('app_public_edit', {'zipcode': place.zipCode, 'name': (place.name|default('sans-nom'))|url_encode, 'uuid': place.uuidForUrl}) }}">
|
||||
{{ place.name ?: '(sans nom)' }}
|
||||
</a>
|
||||
</strong><br>
|
||||
<small>
|
||||
{% if place.street %}
|
||||
<a href="{{ path('app_public_street', {'cityId': stats.zone, 'streetName': place.street|url_encode }) }}">{{ place.street }}</a>
|
||||
{# {% else %}
|
||||
<span class="text-muted">(inconnue)</span> #}
|
||||
{% endif %}
|
||||
{{ place.housenumber }}
|
||||
</small>
|
||||
</span>
|
||||
<span>
|
||||
<span class="badge bg-primary">{{ place.getModifiedDate() ? place.getModifiedDate()|date('Y-m-d H:i') : '' }}</span>
|
||||
<a href="https://www.openstreetmap.org/{{ place.osmKind }}/{{ place.osmId }}" target="_blank" class="btn btn-outline-secondary btn-sm ms-2" title="Voir sur OSM"><i class="bi bi-globe"></i></a>
|
||||
</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-muted">Aucun lieu modifié dans les 6 derniers mois.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue