Import
This commit is contained in:
1 parent
bb2686ef62
commit
a84acfc38f
5 files changed
+1468
No files matched your search
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
/**
|
||||
* admin/manual_add.php
|
||||
* Menu de navigation mis a jour (ajout Import .ntm, Edition de site).
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/admin_auth.php';
|
||||
require_once __DIR__ . '/../lib/db.php';
|
||||
require_once __DIR__ . '/_nav.php';
|
||||
|
||||
require_admin_auth();
|
||||
$db = pdo();
|
||||
$user = current_admin_username();
|
||||
|
||||
$operators = $db->query('SELECT id, country_id, mcc, mnc, brand FROM operators ORDER BY brand')->fetchAll(PDO::FETCH_ASSOC);
|
||||
$successMessage = null;
|
||||
$errorMessage = null;
|
||||
|
||||
function composeLocationDesc(string $tech, ?string $enb, ?string $band, ?string $frequency, $azimuth, ?string $bandwidth, string $address): string
|
||||
{
|
||||
$techLabel = ['2G' => 'GSM', '3G' => 'UMTS', '4G' => 'LTE', '5G' => 'NR', 'CDMA' => 'CDMA'][$tech] ?? $tech;
|
||||
$segments = [];
|
||||
|
||||
if ($enb !== null && $enb !== '') {
|
||||
$segments[] = "eNB ID $enb";
|
||||
}
|
||||
|
||||
$radioLine = trim($techLabel . ' ' . ($band ?? '') . ' ' . ($frequency ?? ''));
|
||||
if ($radioLine !== $techLabel) {
|
||||
$segments[] = $radioLine;
|
||||
}
|
||||
|
||||
if ($azimuth !== null && $azimuth !== '') {
|
||||
$segments[] = $azimuth . '°';
|
||||
}
|
||||
|
||||
if ($bandwidth !== null && $bandwidth !== '') {
|
||||
$segments[] = $bandwidth;
|
||||
}
|
||||
|
||||
if ($address !== '') {
|
||||
$segments[] = $address;
|
||||
}
|
||||
|
||||
return implode(' - ', $segments);
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
require_csrf_token();
|
||||
|
||||
$operatorId = (int)($_POST['operator_id'] ?? 0);
|
||||
$lat = (float)($_POST['lat'] ?? 0);
|
||||
$lon = (float)($_POST['lon'] ?? 0);
|
||||
$addressDesc = trim($_POST['address_desc'] ?? '');
|
||||
$batchLabel = trim($_POST['batch_label'] ?? ('Saisie manuelle - ' . $addressDesc));
|
||||
|
||||
$techs = $_POST['cell_tech'] ?? [];
|
||||
$cellIds = $_POST['cell_id'] ?? [];
|
||||
$areaCodes = $_POST['cell_area_code'] ?? [];
|
||||
$subIds = $_POST['cell_sub_id'] ?? [];
|
||||
$subIds2 = $_POST['cell_sub_id2'] ?? [];
|
||||
$arfcns = $_POST['cell_arfcn'] ?? [];
|
||||
$bands = $_POST['cell_band'] ?? [];
|
||||
$frequencies = $_POST['cell_frequency'] ?? [];
|
||||
$azimuths = $_POST['cell_azimuth'] ?? [];
|
||||
$bandwidths = $_POST['cell_bandwidth'] ?? [];
|
||||
|
||||
if (!$operatorId || !$lat || !$lon || empty($techs)) {
|
||||
$errorMessage = "Operateur, coordonnees et au moins une cellule sont obligatoires.";
|
||||
} else {
|
||||
$opStmt = $db->prepare('SELECT mcc, mnc FROM operators WHERE id = ?');
|
||||
$opStmt->execute([$operatorId]);
|
||||
$op = $opStmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$op) {
|
||||
$errorMessage = "Operateur invalide.";
|
||||
} else {
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$batchStmt = $db->prepare('INSERT INTO staging_batches (label, created_by) VALUES (?, ?)');
|
||||
$batchStmt->execute([$batchLabel, $user]);
|
||||
$batchId = (int)$db->lastInsertId();
|
||||
|
||||
$insertStmt = $db->prepare('
|
||||
INSERT INTO staging_cells
|
||||
(batch_id, tech, mcc, mnc, operator_guess, cell_id, area_code, sub_id, sub_id2,
|
||||
band, frequency, azimuth, bandwidth, address_desc,
|
||||
lat, lon, location_desc, arfcn, source, status)
|
||||
VALUES
|
||||
(:batch_id, :tech, :mcc, :mnc, :operator_guess, :cell_id, :area_code, :sub_id, :sub_id2,
|
||||
:band, :frequency, :azimuth, :bandwidth, :address_desc,
|
||||
:lat, :lon, :location_desc, :arfcn, "manual_form", "pending")
|
||||
');
|
||||
|
||||
$count = 0;
|
||||
foreach ($techs as $i => $tech) {
|
||||
if ($tech === '' || $cellIds[$i] === '' || $areaCodes[$i] === '') continue;
|
||||
|
||||
$enb = $subIds2[$i] !== '' ? $subIds2[$i] : null;
|
||||
$band = $bands[$i] !== '' ? $bands[$i] : null;
|
||||
$frequency = $frequencies[$i] !== '' ? $frequencies[$i] : null;
|
||||
$azimuth = $azimuths[$i] !== '' ? (int)$azimuths[$i] : null;
|
||||
$bandwidth = $bandwidths[$i] !== '' ? $bandwidths[$i] : null;
|
||||
|
||||
$composedLocation = composeLocationDesc($tech, $enb, $band, $frequency, $azimuth, $bandwidth, $addressDesc);
|
||||
|
||||
$insertStmt->execute([
|
||||
':batch_id' => $batchId, ':tech' => $tech, ':mcc' => $op['mcc'], ':mnc' => $op['mnc'], ':operator_guess' => $operatorId,
|
||||
':cell_id' => $cellIds[$i], ':area_code' => $areaCodes[$i],
|
||||
':sub_id' => $subIds[$i] !== '' ? $subIds[$i] : null, ':sub_id2' => $enb,
|
||||
':band' => $band, ':frequency' => $frequency, ':azimuth' => $azimuth, ':bandwidth' => $bandwidth,
|
||||
':address_desc' => $addressDesc,
|
||||
':lat' => $lat, ':lon' => $lon, ':location_desc' => $composedLocation,
|
||||
':arfcn' => $arfcns[$i] !== '' ? $arfcns[$i] : null,
|
||||
]);
|
||||
$count++;
|
||||
}
|
||||
$db->commit();
|
||||
$successMessage = "$count cellule(s) envoyee(s) en validation (lot #$batchId).";
|
||||
} catch (Throwable $e) {
|
||||
$db->rollBack();
|
||||
$errorMessage = "Erreur : " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8"><title>NetMonster Admin - Ajout manuel</title>
|
||||
<link rel="stylesheet" href="assets/theme.css">
|
||||
<style>
|
||||
table#cellsTable th, table#cellsTable td { font-size: .78rem; }
|
||||
table#cellsTable input, table#cellsTable select { font-size: .78rem; padding: .35rem; }
|
||||
.preview-line { font-family: monospace; font-size: .78rem; color: var(--text-muted); background: var(--surface-alt); padding: .3rem .5rem; border-radius: 4px; margin-top: .2rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<?php render_admin_nav('manual_add.php', $user); ?>
|
||||
|
||||
<div class="page-wrap">
|
||||
<h1>Ajout manuel d'un site</h1>
|
||||
<p class="page-subtitle">Un site peut porter plusieurs cellules/secteurs, chacune avec sa propre bande, frequence, azimut et largeur de bande.</p>
|
||||
|
||||
<?php if ($successMessage): ?><p class="msg-ok"><?= htmlspecialchars($successMessage) ?></p><?php endif; ?>
|
||||
<?php if ($errorMessage): ?><p class="msg-err"><?= htmlspecialchars($errorMessage) ?></p><?php endif; ?>
|
||||
|
||||
<form method="post" id="manualForm">
|
||||
<?= csrf_field() ?>
|
||||
<fieldset>
|
||||
<legend>Localisation du site</legend>
|
||||
<div class="form-grid wide-3">
|
||||
<div>
|
||||
<label>Operateur</label>
|
||||
<select name="operator_id" required>
|
||||
<option value="">-- choisir --</option>
|
||||
<?php foreach ($operators as $o): ?><option value="<?= $o['id'] ?>"><?= htmlspecialchars($o['brand'] . ' (' . $o['mcc'] . '-' . $o['mnc'] . ')') ?></option><?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div><label>Latitude</label><input type="number" step="0.000001" name="lat" required></div>
|
||||
<div><label>Longitude</label><input type="number" step="0.000001" name="lon" required></div>
|
||||
</div>
|
||||
<div class="form-grid" style="margin-top:0.6rem;">
|
||||
<div><label>Adresse / lieu (ex: E25 Echangeur Burange/Hellange, Dudelange)</label><input type="text" name="address_desc" placeholder="Description du lieu"></div>
|
||||
<div><label>Libelle du lot (optionnel)</label><input type="text" name="batch_label"></div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Cellules / secteurs de ce site</legend>
|
||||
<table class="data-table" id="cellsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Techno</th><th>Cell ID</th><th>Area code</th>
|
||||
<th>Sub ID (BSIC/PSC/PCI)</th><th>eNB/RNC</th>
|
||||
<th>Bande</th><th>Frequence</th><th>Azimut (°)</th><th>Largeur bande</th>
|
||||
<th>ARFCN</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="cellsBody"></tbody>
|
||||
</table>
|
||||
<button type="button" class="btn-primary" onclick="addCellRow()">+ Ajouter une cellule</button>
|
||||
<p class="page-subtitle" style="margin-top:.8rem;">Apercu du champ Location genere pour la premiere ligne :</p>
|
||||
<div class="preview-line" id="previewLine">—</div>
|
||||
</fieldset>
|
||||
|
||||
<button type="submit" class="primary">Envoyer en validation</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const techLabels = { '2G': 'GSM', '3G': 'UMTS', '4G': 'LTE', '5G': 'NR', 'CDMA': 'CDMA' };
|
||||
|
||||
function addCellRow() {
|
||||
const tbody = document.getElementById('cellsBody');
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td><select name="cell_tech[]" required onchange="updatePreview()">
|
||||
<option value="">--</option>
|
||||
<option value="2G">2G</option><option value="3G">3G</option>
|
||||
<option value="4G">4G</option><option value="5G">5G</option><option value="CDMA">CDMA</option>
|
||||
</select></td>
|
||||
<td><input type="text" name="cell_id[]" required></td>
|
||||
<td><input type="text" name="cell_area_code[]" required></td>
|
||||
<td><input type="text" name="cell_sub_id[]"></td>
|
||||
<td><input type="text" name="cell_sub_id2[]" placeholder="ex: 4620" oninput="updatePreview()"></td>
|
||||
<td><input type="text" name="cell_band[]" placeholder="ex: B7" oninput="updatePreview()"></td>
|
||||
<td><input type="text" name="cell_frequency[]" placeholder="ex: 700MHz" oninput="updatePreview()"></td>
|
||||
<td><input type="number" name="cell_azimuth[]" min="0" max="359" placeholder="ex: 280" oninput="updatePreview()"></td>
|
||||
<td><input type="text" name="cell_bandwidth[]" placeholder="ex: 10MHz" oninput="updatePreview()"></td>
|
||||
<td><input type="text" name="cell_arfcn[]"></td>
|
||||
<td><button type="button" class="reject-small" onclick="this.closest('tr').remove(); updatePreview();">Retirer</button></td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
tr.querySelector('select[name="cell_tech[]"]').addEventListener('change', updatePreview);
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
const firstRow = document.querySelector('#cellsBody tr');
|
||||
const preview = document.getElementById('previewLine');
|
||||
if (!firstRow) { preview.textContent = '—'; return; }
|
||||
|
||||
const tech = firstRow.querySelector('[name="cell_tech[]"]').value;
|
||||
const enb = firstRow.querySelector('[name="cell_sub_id2[]"]').value;
|
||||
const band = firstRow.querySelector('[name="cell_band[]"]').value;
|
||||
const freq = firstRow.querySelector('[name="cell_frequency[]"]').value;
|
||||
const az = firstRow.querySelector('[name="cell_azimuth[]"]').value;
|
||||
const bw = firstRow.querySelector('[name="cell_bandwidth[]"]').value;
|
||||
const address = document.querySelector('[name="address_desc"]').value;
|
||||
|
||||
const techLabel = techLabels[tech] || tech || '';
|
||||
const segments = [];
|
||||
if (enb) segments.push('eNB ID ' + enb);
|
||||
const radioLine = [techLabel, band, freq].filter(Boolean).join(' ');
|
||||
if (radioLine && radioLine !== techLabel) segments.push(radioLine);
|
||||
if (az) segments.push(az + '°');
|
||||
if (bw) segments.push(bw);
|
||||
if (address) segments.push(address);
|
||||
|
||||
preview.textContent = segments.length ? segments.join(' - ') : '—';
|
||||
}
|
||||
|
||||
document.querySelector('[name="address_desc"]')?.addEventListener('input', updatePreview);
|
||||
addCellRow();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
/**
|
||||
* admin/map_layers.php
|
||||
*
|
||||
* Gestion des couches cartographiques (visibilite, couleur, ordre
|
||||
* d'affichage) utilisees par la carte publique/consultation. Priorite
|
||||
* "Faible" dans la roadmap : fonctionnalites simples de configuration,
|
||||
* avec CSRF systematique sur toute ecriture et confirmation avant
|
||||
* suppression d'une couche.
|
||||
*
|
||||
* Hypothese de schema :
|
||||
* map_layers(id, name, layer_key, color, is_visible, sort_order,
|
||||
* description)
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/admin_auth.php';
|
||||
require_once __DIR__ . '/../lib/db.php';
|
||||
require_once __DIR__ . '/_nav.php';
|
||||
|
||||
require_admin_auth();
|
||||
|
||||
$db = pdo();
|
||||
$feedback = null;
|
||||
$feedbackType = 'success';
|
||||
|
||||
function sanitize_hex_color(string $color): ?string
|
||||
{
|
||||
$color = trim($color);
|
||||
return preg_match('/^#[0-9a-fA-F]{6}$/', $color) ? $color : null;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
require_csrf_token();
|
||||
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'create_layer') {
|
||||
$name = trim((string)($_POST['name'] ?? ''));
|
||||
$layerKey = trim((string)($_POST['layer_key'] ?? ''));
|
||||
$color = sanitize_hex_color((string)($_POST['color'] ?? '#3388ff'));
|
||||
$description = trim((string)($_POST['description'] ?? ''));
|
||||
|
||||
if ($name === '' || !preg_match('/^[a-z0-9_\-]+$/', $layerKey)) {
|
||||
$feedback = 'Nom ou clé de couche invalide (clé··: lettres minuscules, chiffres, - et _ uniquement).';
|
||||
$feedbackType = 'error';
|
||||
} elseif ($color === null) {
|
||||
$feedback = 'Couleur invalide (format attendu : #rrggbb).';
|
||||
$feedbackType = 'error';
|
||||
} else {
|
||||
try {
|
||||
$stmt = $db->prepare(
|
||||
'INSERT INTO map_layers (name, layer_key, color, is_visible, sort_order, description)
|
||||
VALUES (?, ?, ?, 1, (SELECT COALESCE(MAX(sort_order), 0) + 1 FROM map_layers AS m2), ?)'
|
||||
);
|
||||
$stmt->execute([$name, $layerKey, $color, $description]);
|
||||
$feedback = 'Couche créée.';
|
||||
} catch (Throwable $e) {
|
||||
$feedback = 'Erreur lors de la création (clé·· déjà utilisée ?) : ' . $e->getMessage();
|
||||
$feedbackType = 'error';
|
||||
}
|
||||
}
|
||||
} elseif ($action === 'toggle_visibility') {
|
||||
$layerId = filter_input(INPUT_POST, 'layer_id', FILTER_VALIDATE_INT);
|
||||
if ($layerId) {
|
||||
$stmt = $db->prepare('UPDATE map_layers SET is_visible = 1 - is_visible WHERE id = ?');
|
||||
$stmt->execute([$layerId]);
|
||||
$feedback = 'Visibilité·· mise à jour.';
|
||||
}
|
||||
} elseif ($action === 'update_color') {
|
||||
$layerId = filter_input(INPUT_POST, 'layer_id', FILTER_VALIDATE_INT);
|
||||
$color = sanitize_hex_color((string)($_POST['color'] ?? ''));
|
||||
if ($layerId && $color !== null) {
|
||||
$stmt = $db->prepare('UPDATE map_layers SET color = ? WHERE id = ?');
|
||||
$stmt->execute([$color, $layerId]);
|
||||
$feedback = 'Couleur mise à jour.';
|
||||
} else {
|
||||
$feedback = 'Couleur invalide.';
|
||||
$feedbackType = 'error';
|
||||
}
|
||||
} elseif ($action === 'delete_layer') {
|
||||
$layerId = filter_input(INPUT_POST, 'layer_id', FILTER_VALIDATE_INT);
|
||||
$confirmed = isset($_POST['confirm']) && $_POST['confirm'] === '1';
|
||||
if (!$confirmed || !$layerId) {
|
||||
$feedback = 'Suppression refusé··e : confirmation manquante.';
|
||||
$feedbackType = 'error';
|
||||
} else {
|
||||
$stmt = $db->prepare('DELETE FROM map_layers WHERE id = ? LIMIT 1');
|
||||
$stmt->execute([$layerId]);
|
||||
$feedback = $stmt->rowCount() > 0 ? 'Couche supprimé··e.' : 'Couche introuvable.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$layers = $db->query('SELECT id, name, layer_key, color, is_visible, sort_order, description FROM map_layers ORDER BY sort_order ASC')->fetchAll(PDO::FETCH_ASSOC);
|
||||
$user = current_admin_username();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Couches cartographiques — NetMonster Admin</title>
|
||||
<link rel="stylesheet" href="assets/theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<?php render_admin_nav('map_layers.php', $user); ?>
|
||||
|
||||
<main class="container">
|
||||
<h1>Couches cartographiques</h1>
|
||||
<p>Gé··rez les couches affich ées sur la carte (visibilité··, couleur, ordre). Toute suppression demande une confirmation explicite.</p>
|
||||
|
||||
<?php if ($feedback !== null): ?>
|
||||
<div class="alert alert-<?= $feedbackType === 'error' ? 'error' : 'success' ?>">
|
||||
<?= htmlspecialchars($feedback, ENT_QUOTES, 'UTF-8') ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<section class="card">
|
||||
<h2>Nouvelle couche</h2>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="create_layer">
|
||||
<label>Nom affiché··e
|
||||
<input type="text" name="name" required maxlength="100">
|
||||
</label>
|
||||
<label>Clé·· technique
|
||||
<input type="text" name="layer_key" required pattern="[a-z0-9_\-]+" placeholder="ex: sites_4g">
|
||||
</label>
|
||||
<label>Couleur
|
||||
<input type="color" name="color" value="#3388ff">
|
||||
</label>
|
||||
<label>Description
|
||||
<input type="text" name="description" maxlength="255">
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary">Cré··er</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Couches existantes (<?= count($layers) ?>)</h2>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ordre</th>
|
||||
<th>Nom</th>
|
||||
<th>Clé··</th>
|
||||
<th>Couleur</th>
|
||||
<th>Visible</th>
|
||||
<th>Description</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($layers as $layer): ?>
|
||||
<tr>
|
||||
<td><?= (int)$layer['sort_order'] ?></td>
|
||||
<td><?= htmlspecialchars((string)$layer['name'], ENT_QUOTES, 'UTF-8') ?></td>
|
||||
<td><code><?= htmlspecialchars((string)$layer['layer_key'], ENT_QUOTES, 'UTF-8') ?></code></td>
|
||||
<td>
|
||||
<form method="post" style="display:inline-flex;gap:.5rem;align-items:center">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="update_color">
|
||||
<input type="hidden" name="layer_id" value="<?= (int)$layer['id'] ?>">
|
||||
<input type="color" name="color" value="<?= htmlspecialchars((string)$layer['color'], ENT_QUOTES, 'UTF-8') ?>" onchange="this.form.submit()">
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="toggle_visibility">
|
||||
<input type="hidden" name="layer_id" value="<?= (int)$layer['id'] ?>">
|
||||
<button type="submit" class="btn btn-secondary">
|
||||
<?= ((int)$layer['is_visible'] === 1) ? 'Visible' : 'Masqué··e' ?>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
<td><?= htmlspecialchars((string)$layer['description'], ENT_QUOTES, 'UTF-8') ?></td>
|
||||
<td>
|
||||
<form method="post" onsubmit="return confirm('Confirmer la suppression de cette couche ?');">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="delete_layer">
|
||||
<input type="hidden" name="confirm" value="1">
|
||||
<input type="hidden" name="layer_id" value="<?= (int)$layer['id'] ?>">
|
||||
<button type="submit" class="btn btn-danger">Supprimer</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($layers)): ?>
|
||||
<tr><td colspan="7">Aucune couche configuré··e.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
/**
|
||||
* admin/operator_sites.php
|
||||
*
|
||||
* Vue en lecture seule des antennes par operateur. Aucune ecriture en
|
||||
* base : uniquement des filtres GET et des requetes preparees en
|
||||
* lecture. Protegee par require_admin_auth() comme toutes les pages du
|
||||
* dossier /admin.
|
||||
*
|
||||
* Hypothese de schema :
|
||||
* operators(id, name, mcc, mnc, country_id)
|
||||
* countries(id, name, iso_code)
|
||||
* sites(id, name, lat, lon, operator_id, country_id)
|
||||
* cells(id, site_id, technology, band, frequency, azimuth, enb, cell_id)
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/admin_auth.php';
|
||||
require_once __DIR__ . '/../lib/db.php';
|
||||
|
||||
require_admin_auth();
|
||||
|
||||
$db = pdo();
|
||||
|
||||
$countries = $db->query('SELECT id, name FROM countries ORDER BY name')->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$countryId = filter_input(INPUT_GET, 'country_id', FILTER_VALIDATE_INT) ?: null;
|
||||
|
||||
$operatorsSql = 'SELECT id, name, mcc, mnc, country_id FROM operators';
|
||||
$operatorsParams = [];
|
||||
if ($countryId) {
|
||||
$operatorsSql .= ' WHERE country_id = ?';
|
||||
$operatorsParams[] = $countryId;
|
||||
}
|
||||
$operatorsSql .= ' ORDER BY name';
|
||||
$stmt = $db->prepare($operatorsSql);
|
||||
$stmt->execute($operatorsParams);
|
||||
$operators = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$operatorId = filter_input(INPUT_GET, 'operator_id', FILTER_VALIDATE_INT) ?: null;
|
||||
|
||||
$page = max(1, filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT) ?: 1);
|
||||
$perPage = 50;
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$sites = [];
|
||||
$totalSites = 0;
|
||||
$stats = null;
|
||||
|
||||
if ($operatorId) {
|
||||
$countStmt = $db->prepare('SELECT COUNT(*) FROM sites WHERE operator_id = ?');
|
||||
$countStmt->execute([$operatorId]);
|
||||
$totalSites = (int)$countStmt->fetchColumn();
|
||||
|
||||
$stmt = $db->prepare(
|
||||
'SELECT s.id, s.name, s.lat, s.lon,
|
||||
(SELECT COUNT(*) FROM cells c WHERE c.site_id = s.id) AS cell_count
|
||||
FROM sites s
|
||||
WHERE s.operator_id = ?
|
||||
ORDER BY s.name
|
||||
LIMIT :limit OFFSET :offset'
|
||||
);
|
||||
$stmt->bindValue(1, $operatorId, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
$sites = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$statStmt = $db->prepare(
|
||||
"SELECT c.technology, COUNT(*) AS total
|
||||
FROM cells c
|
||||
INNER JOIN sites s ON s.id = c.site_id
|
||||
WHERE s.operator_id = ?
|
||||
GROUP BY c.technology
|
||||
ORDER BY total DESC"
|
||||
);
|
||||
$statStmt->execute([$operatorId]);
|
||||
$stats = $statStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
$totalPages = max(1, (int)ceil($totalSites / $perPage));
|
||||
$user = current_admin_username();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Antennes par opé··rateur — NetMonster Admin</title>
|
||||
<link rel="stylesheet" href="assets/theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<?php render_admin_nav('operator_sites.php', $user); ?>
|
||||
|
||||
<main class="container">
|
||||
<h1>Antennes par opé··rateur</h1>
|
||||
<p>Vue de consultation, sans action d'ecriture. Filtrez par pays puis par operateur pour lister les sites et leur nombre de cellules.</p>
|
||||
|
||||
<section class="card">
|
||||
<form method="get" class="filters">
|
||||
<label>Pays
|
||||
<select name="country_id" onchange="this.form.submit()">
|
||||
<option value="">Tous</option>
|
||||
<?php foreach ($countries as $c): ?>
|
||||
<option value="<?= (int)$c['id'] ?>" <?= $countryId === (int)$c['id'] ? 'selected' : '' ?>>
|
||||
<?= htmlspecialchars((string)$c['name'], ENT_QUOTES, 'UTF-8') ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label>Opé··rateur
|
||||
<select name="operator_id" onchange="this.form.submit()">
|
||||
<option value="">— Choisir —</option>
|
||||
<?php foreach ($operators as $op): ?>
|
||||
<option value="<?= (int)$op['id'] ?>" <?= $operatorId === (int)$op['id'] ? 'selected' : '' ?>>
|
||||
<?= htmlspecialchars((string)$op['name'], ENT_QUOTES, 'UTF-8') ?>
|
||||
(MCC <?= htmlspecialchars((string)$op['mcc'], ENT_QUOTES, 'UTF-8') ?> / MNC <?= htmlspecialchars((string)$op['mnc'], ENT_QUOTES, 'UTF-8') ?>)
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<?php if ($operatorId): ?>
|
||||
<section class="card">
|
||||
<h2>Ré··partition par technologie</h2>
|
||||
<?php if (empty($stats)): ?>
|
||||
<p>Aucune cellule pour cet opé··rateur.</p>
|
||||
<?php else: ?>
|
||||
<table class="table">
|
||||
<thead><tr><th>Technologie</th><th>Nombre de cellules</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($stats as $s): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars((string)$s['technology'], ENT_QUOTES, 'UTF-8') ?></td>
|
||||
<td><?= (int)$s['total'] ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Sites (<?= $totalSites ?>)</h2>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nom</th>
|
||||
<th>Latitude</th>
|
||||
<th>Longitude</th>
|
||||
<th>Cellules</th>
|
||||
<th>Dé··tail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($sites as $site): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars((string)$site['name'], ENT_QUOTES, 'UTF-8') ?></td>
|
||||
<td><?= htmlspecialchars((string)$site['lat'], ENT_QUOTES, 'UTF-8') ?></td>
|
||||
<td><?= htmlspecialchars((string)$site['lon'], ENT_QUOTES, 'UTF-8') ?></td>
|
||||
<td><?= (int)$site['cell_count'] ?></td>
|
||||
<td><a class="btn btn-secondary" href="edit_site.php?id=<?= (int)$site['id'] ?>">Ouvrir</a></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($sites)): ?>
|
||||
<tr><td colspan="5">Aucun site pour cet opé··rateur.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<nav class="pagination">
|
||||
<?php for ($p = 1; $p <= $totalPages; $p++): ?>
|
||||
<a href="operator_sites.php?operator_id=<?= (int)$operatorId ?>&country_id=<?= (int)$countryId ?>&page=<?= $p ?>" class="<?= $p === $page ? 'active' : '' ?>"><?= $p ?></a>
|
||||
<?php endfor; ?>
|
||||
</nav>
|
||||
</section>
|
||||
<?php else: ?>
|
||||
<section class="card">
|
||||
<p>Sé··lectionnez un opé··rateur pour afficher ses sites.</p>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,383 @@
|
||||
<?php
|
||||
/**
|
||||
* admin/review.php
|
||||
*
|
||||
* Corrige suite a l'audit du 20/09/2026 :
|
||||
* - Le flux WMS cadastre est encode en JSON avec JSON_HEX_* au lieu
|
||||
* d'addslashes() (memes raisons que edit_site.php).
|
||||
* - Le catch silencieux de validate_group loggue desormais l'erreur
|
||||
* et affiche un message a l'administrateur au lieu de rediriger
|
||||
* silencieusement comme si tout s'etait bien passe.
|
||||
* - La popup de la carte (groupes en attente) est reconstruite en DOM
|
||||
* (textContent) plutot qu'en template literal HTML.
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/admin_auth.php';
|
||||
require_once __DIR__ . '/../lib/db.php';
|
||||
require_once __DIR__ . '/_nav.php';
|
||||
|
||||
require_admin_auth();
|
||||
$db = pdo();
|
||||
$user = current_admin_username();
|
||||
|
||||
function deriveSiteKey(array $row): string
|
||||
{
|
||||
$mcc = $row['mcc'];
|
||||
$mnc = $row['mnc'];
|
||||
|
||||
if ($row['tech'] === '4G' && !empty($row['sub_id2'])) {
|
||||
return "enb:{$mcc}:{$mnc}:{$row['area_code']}:{$row['sub_id2']}";
|
||||
}
|
||||
|
||||
$latR = $row['lat'] !== null ? round((float)$row['lat'], 4) : 'na';
|
||||
$lonR = $row['lon'] !== null ? round((float)$row['lon'], 4) : 'na';
|
||||
return "geo:{$mcc}:{$mnc}:{$row['area_code']}:{$latR}:{$lonR}";
|
||||
}
|
||||
|
||||
$flashError = null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
require_csrf_token();
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'validate_group') {
|
||||
$stagingIds = array_map('intval', $_POST['staging_ids'] ?? []);
|
||||
$lat = (float)($_POST['lat'] ?? 0); $lon = (float)($_POST['lon'] ?? 0);
|
||||
$locationDesc = $_POST['location_desc'] ?? ''; $operatorId = (int)($_POST['operator_id'] ?? 0);
|
||||
|
||||
// Verifie que l'operateur declare existe reellement avant toute ecriture.
|
||||
$opCheck = $db->prepare('SELECT country_id FROM operators WHERE id = ?');
|
||||
$opCheck->execute([$operatorId]);
|
||||
$countryId = $opCheck->fetchColumn();
|
||||
|
||||
if ($stagingIds && $operatorId && $countryId !== false) {
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$near = $db->prepare('SELECT id FROM sites WHERE operator_id = ? AND ABS(lat - ?) < 0.0003 AND ABS(lon - ?) < 0.0003 LIMIT 1');
|
||||
$near->execute([$operatorId, $lat, $lon]);
|
||||
$siteId = $near->fetchColumn();
|
||||
|
||||
if (!$siteId) {
|
||||
$addrStmt = $db->prepare('SELECT address_desc FROM staging_cells WHERE id = ? LIMIT 1');
|
||||
$addrStmt->execute([$stagingIds[0]]);
|
||||
$addressDesc = $addrStmt->fetchColumn() ?: null;
|
||||
|
||||
$insertSite = $db->prepare('INSERT INTO sites (country_id, operator_id, lat, lon, location_desc, address_desc, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
||||
$insertSite->execute([$countryId, $operatorId, $lat, $lon, $locationDesc, $addressDesc, $user]);
|
||||
$siteId = (int)$db->lastInsertId();
|
||||
}
|
||||
|
||||
$fetchStaging = $db->prepare('SELECT * FROM staging_cells WHERE id = ? AND status = "pending"');
|
||||
$insertCell = $db->prepare('
|
||||
INSERT INTO cells (site_id, tech, mcc, mnc, cell_id, area_code, sub_id, sub_id2, band, frequency, azimuth, bandwidth, arfcn, validated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
site_id = VALUES(site_id), band = VALUES(band), frequency = VALUES(frequency),
|
||||
azimuth = VALUES(azimuth), bandwidth = VALUES(bandwidth),
|
||||
validated_by = VALUES(validated_by), validated_at = NOW()
|
||||
');
|
||||
$markValidated = $db->prepare('UPDATE staging_cells SET status = "validated", reviewed_at = NOW(), reviewed_by = ? WHERE id = ?');
|
||||
|
||||
foreach ($stagingIds as $sid) {
|
||||
$fetchStaging->execute([$sid]);
|
||||
$cell = $fetchStaging->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$cell) continue;
|
||||
$insertCell->execute([
|
||||
$siteId, $cell['tech'], $cell['mcc'], $cell['mnc'], $cell['cell_id'], $cell['area_code'],
|
||||
$cell['sub_id'], $cell['sub_id2'], $cell['band'], $cell['frequency'], $cell['azimuth'], $cell['bandwidth'],
|
||||
$cell['arfcn'], $user,
|
||||
]);
|
||||
$markValidated->execute([$user, $sid]);
|
||||
}
|
||||
$db->commit();
|
||||
} catch (Throwable $e) {
|
||||
$db->rollBack();
|
||||
// Corrige : l'echec n'est plus silencieux. On loggue le
|
||||
// detail technique cote serveur et on informe
|
||||
// l'administrateur qu'aucune validation n'a ete
|
||||
// appliquee, plutot que de rediriger comme si de rien
|
||||
// n'etait alors que la transaction a echoue.
|
||||
error_log('[review] validate_group: ' . $e->getMessage());
|
||||
$flashError = "La validation du site a echoue et a ete annulee (aucune donnee ecrite). Consultez les journaux du serveur si le probleme persiste.";
|
||||
}
|
||||
} else {
|
||||
$flashError = "Validation refusee : operateur invalide ou selection de cellules vide.";
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'reject') {
|
||||
$stagingId = (int)($_POST['staging_id'] ?? 0);
|
||||
try {
|
||||
$db->prepare('UPDATE staging_cells SET status = "rejected", reviewed_at = NOW(), reviewed_by = ?, reject_reason = ? WHERE id = ? AND status = "pending"')
|
||||
->execute([$user, $_POST['reject_reason'] ?? null, $stagingId]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('[review] reject: ' . $e->getMessage());
|
||||
$flashError = 'Le rejet de cette cellule a echoue.';
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'add_missing_cell') {
|
||||
// Verifie que l'operateur/pays declares existent reellement (coherence
|
||||
// MCC/MNC) avant d'inserer une nouvelle ligne staging manuelle.
|
||||
$operatorId = (int)($_POST['operator_id'] ?? 0);
|
||||
$opCheck = $db->prepare('SELECT mcc, mnc FROM operators WHERE id = ?');
|
||||
$opCheck->execute([$operatorId]);
|
||||
$op = $opCheck->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($op && in_array($_POST['tech'] ?? '', ['2G', '3G', '4G', '5G', 'CDMA'], true)) {
|
||||
try {
|
||||
$db->prepare('
|
||||
INSERT INTO staging_cells (tech, mcc, mnc, operator_guess, cell_id, area_code, sub_id, sub_id2, band, frequency, azimuth, bandwidth, lat, lon, location_desc, arfcn, source, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, "manual_form", "pending")
|
||||
')->execute([
|
||||
$_POST['tech'], $op['mcc'], $op['mnc'], $operatorId,
|
||||
$_POST['cell_id'], $_POST['area_code'],
|
||||
$_POST['sub_id'] !== '' ? $_POST['sub_id'] : null, $_POST['sub_id2'] !== '' ? $_POST['sub_id2'] : null,
|
||||
$_POST['band'] !== '' ? $_POST['band'] : null, $_POST['frequency'] !== '' ? $_POST['frequency'] : null,
|
||||
$_POST['azimuth'] !== '' ? (int)$_POST['azimuth'] : null, $_POST['bandwidth'] !== '' ? $_POST['bandwidth'] : null,
|
||||
$_POST['lat'], $_POST['lon'], $_POST['location_desc'], $_POST['arfcn'] !== '' ? $_POST['arfcn'] : null,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('[review] add_missing_cell: ' . $e->getMessage());
|
||||
$flashError = "L'ajout de la cellule manquante a echoue.";
|
||||
}
|
||||
} else {
|
||||
$flashError = "Ajout refuse : operateur ou technologie invalide.";
|
||||
}
|
||||
}
|
||||
|
||||
if ($flashError !== null) {
|
||||
$_SESSION['review_flash_error'] = $flashError;
|
||||
}
|
||||
|
||||
header('Location: review.php' . (isset($_GET['country']) ? '?country=' . urlencode($_GET['country']) : ''));
|
||||
exit;
|
||||
}
|
||||
|
||||
$flashError = $_SESSION['review_flash_error'] ?? null;
|
||||
unset($_SESSION['review_flash_error']);
|
||||
|
||||
$countryFilter = $_GET['country'] ?? ''; $operatorFilter = $_GET['operator'] ?? '';
|
||||
|
||||
$sql = '
|
||||
SELECT sc.*, o.brand AS operator_brand, c.name AS country_name
|
||||
FROM staging_cells sc
|
||||
LEFT JOIN operators o ON o.id = sc.operator_guess
|
||||
LEFT JOIN countries c ON c.mcc = sc.mcc
|
||||
WHERE sc.status = "pending"
|
||||
';
|
||||
$params = [];
|
||||
if ($countryFilter !== '') { $sql .= ' AND c.iso2 = ?'; $params[] = $countryFilter; }
|
||||
if ($operatorFilter !== '') { $sql .= ' AND sc.operator_guess = ?'; $params[] = $operatorFilter; }
|
||||
$sql .= ' ORDER BY sc.submitted_at DESC LIMIT 500';
|
||||
|
||||
$stmt = $db->prepare($sql); $stmt->execute($params);
|
||||
$allPending = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$groups = [];
|
||||
foreach ($allPending as $row) {
|
||||
$key = deriveSiteKey($row);
|
||||
$groups[$key]['cells'][] = $row;
|
||||
$groups[$key]['operator_brand'] = $row['operator_brand'];
|
||||
$groups[$key]['operator_id'] = $row['operator_guess'];
|
||||
$groups[$key]['mcc'] = $row['mcc']; $groups[$key]['mnc'] = $row['mnc'];
|
||||
$groups[$key]['lat'] = $groups[$key]['lat'] ?? $row['lat'];
|
||||
$groups[$key]['lon'] = $groups[$key]['lon'] ?? $row['lon'];
|
||||
$groups[$key]['location_desc'] = $groups[$key]['location_desc'] ?? $row['location_desc'];
|
||||
$groups[$key]['country_name'] = $row['country_name'];
|
||||
$groups[$key]['has_dup'] = ($groups[$key]['has_dup'] ?? false) || (bool)$row['possible_dup_of'];
|
||||
}
|
||||
|
||||
$operators = $db->query('SELECT id, mcc, mnc, brand FROM operators ORDER BY brand')->fetchAll(PDO::FETCH_ASSOC);
|
||||
$countries = $db->query('SELECT iso2, name, mcc, default_lat, default_lon, default_zoom, cadastre_wms_url, cadastre_wms_layer, cadastre_attribution_html, cadastre_srs FROM countries ORDER BY name')->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$activeCountry = null;
|
||||
if ($countryFilter !== '') {
|
||||
foreach ($countries as $c) { if ($c['iso2'] === $countryFilter) { $activeCountry = $c; break; } }
|
||||
} elseif (!empty($groups)) {
|
||||
$firstMcc = reset($groups)['mcc'];
|
||||
foreach ($countries as $c) { if ($c['mcc'] === $firstMcc) { $activeCountry = $c; break; } }
|
||||
}
|
||||
if (!$activeCountry) {
|
||||
$activeCountry = ['default_lat' => 20, 'default_lon' => 0, 'default_zoom' => 2, 'cadastre_wms_url' => null, 'cadastre_wms_layer' => null, 'cadastre_attribution_html' => null, 'cadastre_srs' => 'EPSG:3857'];
|
||||
}
|
||||
|
||||
$mapPoints = [];
|
||||
foreach ($groups as $key => $g) {
|
||||
if ($g['lat'] && $g['lon']) {
|
||||
$mapPoints[] = ['key' => $key, 'lat' => (float)$g['lat'], 'lon' => (float)$g['lon'], 'operator' => $g['operator_brand'], 'count' => count($g['cells']), 'has_dup' => $g['has_dup']];
|
||||
}
|
||||
}
|
||||
$hasCadastre = !empty($activeCountry['cadastre_wms_url']) && !empty($activeCountry['cadastre_wms_layer']);
|
||||
$totalPending = count($allPending);
|
||||
$totalDup = count(array_filter($allPending, fn($r) => (bool)$r['possible_dup_of']));
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8"><title>NetMonster Admin - Validation</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
|
||||
<link rel="stylesheet" href="assets/theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<?php render_admin_nav('review.php', $user); ?>
|
||||
|
||||
<div class="page-wrap">
|
||||
<h1>Validation groupee par site</h1>
|
||||
<p class="page-subtitle">Verifiez chaque site avant qu'il ne rejoigne la base finale.</p>
|
||||
|
||||
<?php if ($flashError): ?><p class="msg-err"><?= htmlspecialchars($flashError, ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
|
||||
|
||||
<div class="stats-bar">
|
||||
<div class="stat-chip"><strong><?= count($groups) ?></strong>site(s) en attente</div>
|
||||
<div class="stat-chip"><strong><?= $totalPending ?></strong>ligne(s) au total</div>
|
||||
<div class="stat-chip"><strong><?= $totalDup ?></strong>doublon(s) potentiel(s)</div>
|
||||
</div>
|
||||
|
||||
<div class="map-controls <?= $hasCadastre ? '' : 'disabled' ?>">
|
||||
<label><input type="checkbox" id="toggleCadastre" <?= $hasCadastre ? 'checked' : 'disabled' ?>>
|
||||
Afficher le cadastre<?= $hasCadastre ? '' : ' (non configure pour ce pays)' ?></label>
|
||||
</div>
|
||||
<div id="map"></div>
|
||||
|
||||
<form class="filters" method="get">
|
||||
<select name="country" onchange="this.form.submit()">
|
||||
<option value="">Tous les pays</option>
|
||||
<?php foreach ($countries as $c): ?>
|
||||
<option value="<?= htmlspecialchars($c['iso2'], ENT_QUOTES, 'UTF-8') ?>" <?= $countryFilter === $c['iso2'] ? 'selected' : '' ?>><?= htmlspecialchars($c['name'], ENT_QUOTES, 'UTF-8') ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<select name="operator" onchange="this.form.submit()">
|
||||
<option value="">Tous les operateurs</option>
|
||||
<?php foreach ($operators as $o): ?>
|
||||
<option value="<?= $o['id'] ?>" <?= (string)$operatorFilter === (string)$o['id'] ? 'selected' : '' ?>><?= htmlspecialchars($o['brand'], ENT_QUOTES, 'UTF-8') ?> (<?= htmlspecialchars($o['mcc'].'-'.$o['mnc'], ENT_QUOTES, 'UTF-8') ?>)</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</form>
|
||||
|
||||
<?php foreach ($groups as $key => $group): ?>
|
||||
<div class="group-card" id="group-<?= htmlspecialchars($key, ENT_QUOTES, 'UTF-8') ?>">
|
||||
<div class="group-header">
|
||||
<h3><?= htmlspecialchars($group['operator_brand'] ?? 'Operateur inconnu', ENT_QUOTES, 'UTF-8') ?> · <?= htmlspecialchars($group['country_name'] ?? '?', ENT_QUOTES, 'UTF-8') ?> · <?= htmlspecialchars((string)$group['lat'], ENT_QUOTES, 'UTF-8') ?>, <?= htmlspecialchars((string)$group['lon'], ENT_QUOTES, 'UTF-8') ?></h3>
|
||||
<div>
|
||||
<span class="badge badge-info"><?= count($group['cells']) ?> cellule<?= count($group['cells']) > 1 ? 's' : '' ?></span>
|
||||
<?php if ($group['has_dup']): ?><span class="badge badge-dup">Doublons potentiels</span><?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<table class="cells-table">
|
||||
<tr><th>Techno</th><th>Cell ID</th><th>Area code</th><th>Sub ID</th><th>eNB/RNC</th><th>Bande</th><th>Frequence</th><th>Azimut</th><th>Largeur</th><th>ARFCN</th><th>Dup.</th><th></th></tr>
|
||||
<?php foreach ($group['cells'] as $c): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars((string)$c['tech'], ENT_QUOTES, 'UTF-8') ?></td>
|
||||
<td><?= htmlspecialchars((string)$c['cell_id'], ENT_QUOTES, 'UTF-8') ?></td>
|
||||
<td><?= htmlspecialchars((string)$c['area_code'], ENT_QUOTES, 'UTF-8') ?></td>
|
||||
<td><?= htmlspecialchars((string)($c['sub_id'] ?? ''), ENT_QUOTES, 'UTF-8') ?></td>
|
||||
<td><?= htmlspecialchars((string)($c['sub_id2'] ?? ''), ENT_QUOTES, 'UTF-8') ?></td>
|
||||
<td><?= htmlspecialchars((string)($c['band'] ?? ''), ENT_QUOTES, 'UTF-8') ?></td>
|
||||
<td><?= htmlspecialchars((string)($c['frequency'] ?? ''), ENT_QUOTES, 'UTF-8') ?></td>
|
||||
<td><?= $c['azimuth'] !== null ? htmlspecialchars((string)$c['azimuth'], ENT_QUOTES, 'UTF-8') . '°' : '' ?></td>
|
||||
<td><?= htmlspecialchars((string)($c['bandwidth'] ?? ''), ENT_QUOTES, 'UTF-8') ?></td>
|
||||
<td><?= htmlspecialchars((string)($c['arfcn'] ?? ''), ENT_QUOTES, 'UTF-8') ?></td>
|
||||
<td><?= $c['possible_dup_of'] ? '#' . htmlspecialchars((string)$c['possible_dup_of'], ENT_QUOTES, 'UTF-8') : '—' ?></td>
|
||||
<td>
|
||||
<form method="post" style="display:inline">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="reject">
|
||||
<input type="hidden" name="staging_id" value="<?= htmlspecialchars((string)$c['id'], ENT_QUOTES, 'UTF-8') ?>">
|
||||
<button type="submit" class="reject-small" onclick="return confirm('Rejeter ?')">Rejeter</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<div class="actions-row">
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<?php foreach ($group['cells'] as $c): ?><input type="hidden" name="staging_ids[]" value="<?= htmlspecialchars((string)$c['id'], ENT_QUOTES, 'UTF-8') ?>"><?php endforeach; ?>
|
||||
<input type="hidden" name="action" value="validate_group">
|
||||
<input type="hidden" name="lat" value="<?= htmlspecialchars((string)$group['lat'], ENT_QUOTES, 'UTF-8') ?>">
|
||||
<input type="hidden" name="lon" value="<?= htmlspecialchars((string)$group['lon'], ENT_QUOTES, 'UTF-8') ?>">
|
||||
<input type="hidden" name="location_desc" value="<?= htmlspecialchars((string)$group['location_desc'], ENT_QUOTES, 'UTF-8') ?>">
|
||||
<input type="hidden" name="operator_id" value="<?= htmlspecialchars((string)$group['operator_id'], ENT_QUOTES, 'UTF-8') ?>">
|
||||
<button type="submit" class="validate">Valider le site complet</button>
|
||||
</form>
|
||||
<button type="button" class="add-missing" onclick="toggleMissingForm('<?= htmlspecialchars($key, ENT_QUOTES, 'UTF-8') ?>')">+ Ajouter un secteur / une bande manquante</button>
|
||||
</div>
|
||||
<div class="missing-form" id="missing-<?= htmlspecialchars($key, ENT_QUOTES, 'UTF-8') ?>">
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="add_missing_cell">
|
||||
<input type="hidden" name="mcc" value="<?= htmlspecialchars($group['mcc'], ENT_QUOTES, 'UTF-8') ?>">
|
||||
<input type="hidden" name="mnc" value="<?= htmlspecialchars($group['mnc'], ENT_QUOTES, 'UTF-8') ?>">
|
||||
<input type="hidden" name="operator_id" value="<?= htmlspecialchars((string)$group['operator_id'], ENT_QUOTES, 'UTF-8') ?>">
|
||||
<input type="hidden" name="lat" value="<?= htmlspecialchars((string)$group['lat'], ENT_QUOTES, 'UTF-8') ?>">
|
||||
<input type="hidden" name="lon" value="<?= htmlspecialchars((string)$group['lon'], ENT_QUOTES, 'UTF-8') ?>">
|
||||
<input type="hidden" name="location_desc" value="<?= htmlspecialchars((string)$group['location_desc'], ENT_QUOTES, 'UTF-8') ?>">
|
||||
<div class="missing-grid">
|
||||
<label>Techno<select name="tech" required><option value="2G">2G</option><option value="3G">3G</option><option value="4G" selected>4G</option><option value="5G">5G</option><option value="CDMA">CDMA</option></select></label>
|
||||
<label>Cell ID<input type="text" name="cell_id" required></label>
|
||||
<label>Area code<input type="text" name="area_code" required></label>
|
||||
<label>Sub ID (BSIC/PSC/PCI)<input type="text" name="sub_id"></label>
|
||||
<label>eNB/RNC<input type="text" name="sub_id2"></label>
|
||||
<label>Bande<input type="text" name="band" placeholder="ex: B7"></label>
|
||||
<label>Frequence<input type="text" name="frequency" placeholder="ex: 700MHz"></label>
|
||||
<label>Azimut<input type="number" name="azimuth" min="0" max="359" placeholder="ex: 280"></label>
|
||||
<label>Largeur de bande<input type="text" name="bandwidth" placeholder="ex: 10MHz"></label>
|
||||
<label>ARFCN<input type="text" name="arfcn"></label>
|
||||
</div>
|
||||
<button type="submit" class="validate" style="margin-top:0.8rem;">Ajouter cette cellule au site</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleMissingForm(key){const el=document.getElementById('missing-'+key);el.style.display=el.style.display==='block'?'none':'block';}
|
||||
const map = L.map('map').setView([<?= (float)$activeCountry['default_lat'] ?>, <?= (float)$activeCountry['default_lon'] ?>], <?= (int)$activeCountry['default_zoom'] ?>);
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {maxZoom:19, attribution:'© <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a> contributors'}).addTo(map);
|
||||
<?php if ($hasCadastre): ?>
|
||||
const cadastreLayer = L.tileLayer.wms(
|
||||
<?= json_encode($activeCountry['cadastre_wms_url'], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>,
|
||||
{
|
||||
layers: <?= json_encode($activeCountry['cadastre_wms_layer'], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>,
|
||||
format: 'image/png',
|
||||
transparent: true,
|
||||
opacity: 0.6,
|
||||
attribution: <?= json_encode($activeCountry['cadastre_attribution_html'] ?? '', JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>
|
||||
}
|
||||
).addTo(map);
|
||||
document.getElementById('toggleCadastre').addEventListener('change', function(){ if(this.checked){map.addLayer(cadastreLayer);}else{map.removeLayer(cadastreLayer);} });
|
||||
<?php endif; ?>
|
||||
const groupPoints = <?= json_encode($mapPoints, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
|
||||
|
||||
function buildGroupPopup(point) {
|
||||
const container = document.createElement('div');
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = point.operator ?? '?';
|
||||
container.appendChild(title);
|
||||
container.appendChild(document.createElement('br'));
|
||||
const count = document.createElement('span');
|
||||
count.textContent = `${point.count} cellule(s)`;
|
||||
container.appendChild(count);
|
||||
if (point.has_dup) {
|
||||
container.appendChild(document.createElement('br'));
|
||||
const dup = document.createElement('span');
|
||||
dup.textContent = 'doublon potentiel';
|
||||
container.appendChild(dup);
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
groupPoints.forEach(g => {
|
||||
const color = g.has_dup ? '#e0392f' : '#2f5fe0';
|
||||
L.circleMarker([g.lat, g.lon], {radius:9, color, fillColor:color, fillOpacity:0.7, weight:2}).addTo(map)
|
||||
.bindPopup(buildGroupPopup(g))
|
||||
.on('click', () => document.getElementById('group-' + g.key)?.scrollIntoView({behavior:'smooth'}));
|
||||
});
|
||||
if (groupPoints.length > 0) map.fitBounds(L.latLngBounds(groupPoints.map(g => [g.lat, g.lon])), {padding:[30,30]});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,454 @@
|
||||
<?php
|
||||
/**
|
||||
* admin/search_site.php
|
||||
*
|
||||
* Recherche d'une antenne par eNB/RNC, Cell ID, bande, frequence ou adresse.
|
||||
* Affiche les cellules regroupees par site et les azimuts sur une carte Leaflet.
|
||||
*
|
||||
* Corrige suite a l'audit du 20/09/2026 : les popups Leaflet (marqueurs
|
||||
* de site et secteurs d'azimut) construisaient du HTML par
|
||||
* concatenation de template literals a partir de donnees issues de la
|
||||
* base (operateur, pays, adresse, bande, frequence...). Un contenu
|
||||
* malveillant stocke via import .ntm ou saisie manuelle pouvait donc
|
||||
* s'executer comme JavaScript dans le navigateur de l'administrateur.
|
||||
* Les popups utilisent maintenant des elements DOM avec textContent.
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/admin_auth.php';
|
||||
require_once __DIR__ . '/../lib/db.php';
|
||||
require_once __DIR__ . '/_nav.php';
|
||||
|
||||
require_admin_auth();
|
||||
|
||||
$db = pdo();
|
||||
$user = current_admin_username();
|
||||
|
||||
$query = trim((string)($_GET['q'] ?? ''));
|
||||
$countryFilter = trim((string)($_GET['country'] ?? ''));
|
||||
$operatorFilter = trim((string)($_GET['operator'] ?? ''));
|
||||
$techFilter = trim((string)($_GET['tech'] ?? ''));
|
||||
$selectedSiteId = (int)($_GET['site_id'] ?? 0);
|
||||
|
||||
$countries = $db->query('SELECT id, iso2, name FROM countries ORDER BY name')->fetchAll(PDO::FETCH_ASSOC);
|
||||
$operators = $db->query('SELECT id, mcc, mnc, brand FROM operators ORDER BY brand')->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$sites = [];
|
||||
$selectedSite = null;
|
||||
$selectedCells = [];
|
||||
|
||||
if ($query !== '' || $countryFilter !== '' || $operatorFilter !== '' || $techFilter !== '' || $selectedSiteId > 0) {
|
||||
$sql = '
|
||||
SELECT
|
||||
s.id AS site_id,
|
||||
s.lat,
|
||||
s.lon,
|
||||
s.location_desc,
|
||||
s.address_desc,
|
||||
s.country_id,
|
||||
s.operator_id,
|
||||
o.brand AS operator_brand,
|
||||
o.mcc AS operator_mcc,
|
||||
o.mnc AS operator_mnc,
|
||||
c.name AS country_name,
|
||||
cl.id AS cell_pk,
|
||||
cl.mcc,
|
||||
cl.mnc,
|
||||
cl.tech,
|
||||
cl.cell_id,
|
||||
cl.area_code,
|
||||
cl.sub_id,
|
||||
cl.sub_id2,
|
||||
cl.band,
|
||||
cl.frequency,
|
||||
cl.azimuth,
|
||||
cl.bandwidth,
|
||||
cl.arfcn
|
||||
FROM sites s
|
||||
JOIN operators o ON o.id = s.operator_id
|
||||
JOIN countries c ON c.id = s.country_id
|
||||
JOIN cells cl ON cl.site_id = s.id
|
||||
WHERE 1=1
|
||||
';
|
||||
$params = [];
|
||||
|
||||
if ($query !== '') {
|
||||
$sql .= '
|
||||
AND (
|
||||
cl.sub_id2 LIKE ?
|
||||
OR cl.cell_id LIKE ?
|
||||
OR cl.sub_id LIKE ?
|
||||
OR cl.band LIKE ?
|
||||
OR cl.frequency LIKE ?
|
||||
OR s.location_desc LIKE ?
|
||||
OR s.address_desc LIKE ?
|
||||
)
|
||||
';
|
||||
$like = '%' . $query . '%';
|
||||
array_push($params, $like, $like, $like, $like, $like, $like, $like);
|
||||
}
|
||||
|
||||
if ($countryFilter !== '') {
|
||||
$sql .= ' AND c.iso2 = ?';
|
||||
$params[] = $countryFilter;
|
||||
}
|
||||
|
||||
if ($operatorFilter !== '') {
|
||||
$sql .= ' AND o.id = ?';
|
||||
$params[] = (int)$operatorFilter;
|
||||
}
|
||||
|
||||
if ($techFilter !== '') {
|
||||
$sql .= ' AND cl.tech = ?';
|
||||
$params[] = $techFilter;
|
||||
}
|
||||
|
||||
if ($selectedSiteId > 0) {
|
||||
$sql .= ' AND s.id = ?';
|
||||
$params[] = $selectedSiteId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY s.id, cl.sub_id2, cl.tech, cl.band, cl.frequency, cl.id LIMIT 1000';
|
||||
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$siteId = (int)$row['site_id'];
|
||||
|
||||
if (!isset($sites[$siteId])) {
|
||||
$sites[$siteId] = [
|
||||
'site_id' => $siteId,
|
||||
'lat' => (float)$row['lat'],
|
||||
'lon' => (float)$row['lon'],
|
||||
'location_desc' => (string)($row['location_desc'] ?? ''),
|
||||
'address_desc' => (string)($row['address_desc'] ?? ''),
|
||||
'operator_brand' => (string)$row['operator_brand'],
|
||||
'country_name' => (string)$row['country_name'],
|
||||
'cell_count' => 0,
|
||||
'cells' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$cell = [
|
||||
'cell_pk' => (int)$row['cell_pk'],
|
||||
'mcc' => (string)($row['mcc'] ?? ''),
|
||||
'mnc' => (string)($row['mnc'] ?? ''),
|
||||
'tech' => (string)($row['tech'] ?? ''),
|
||||
'cell_id' => (string)($row['cell_id'] ?? ''),
|
||||
'area_code' => (string)($row['area_code'] ?? ''),
|
||||
'sub_id' => (string)($row['sub_id'] ?? ''),
|
||||
'sub_id2' => (string)($row['sub_id2'] ?? ''),
|
||||
'band' => (string)($row['band'] ?? ''),
|
||||
'frequency' => (string)($row['frequency'] ?? ''),
|
||||
'azimuth' => $row['azimuth'] !== null && $row['azimuth'] !== '' ? (float)$row['azimuth'] : null,
|
||||
'bandwidth' => (string)($row['bandwidth'] ?? ''),
|
||||
'arfcn' => (string)($row['arfcn'] ?? ''),
|
||||
];
|
||||
|
||||
$sites[$siteId]['cells'][] = $cell;
|
||||
$sites[$siteId]['cell_count']++;
|
||||
}
|
||||
|
||||
$sites = array_values($sites);
|
||||
|
||||
if ($selectedSiteId > 0 && isset($sites[0]) && (int)$sites[0]['site_id'] === $selectedSiteId) {
|
||||
$selectedSite = $sites[0];
|
||||
$selectedCells = $selectedSite['cells'];
|
||||
} elseif (count($sites) === 1) {
|
||||
$selectedSite = $sites[0];
|
||||
$selectedCells = $selectedSite['cells'];
|
||||
}
|
||||
}
|
||||
|
||||
function h(mixed $value): string
|
||||
{
|
||||
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
function techColor(string $tech): string
|
||||
{
|
||||
return match (strtoupper($tech)) {
|
||||
'2G' => '#eab308',
|
||||
'3G' => '#2563eb',
|
||||
'4G' => '#16a34a',
|
||||
'5G' => '#9333ea',
|
||||
'CDMA' => '#ea580c',
|
||||
default => '#64748b',
|
||||
};
|
||||
}
|
||||
|
||||
$mapSites = $selectedSite ? [$selectedSite] : $sites;
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>NetMonster Admin - Recherche antenne</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin>
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin></script>
|
||||
<link rel="stylesheet" href="assets/theme.css">
|
||||
<style>
|
||||
.search-layout { display:grid; grid-template-columns:minmax(300px, 38%) minmax(420px, 62%); gap:1rem; align-items:start; }
|
||||
.search-card { margin-bottom:1rem; }
|
||||
.search-form { display:grid; grid-template-columns:2fr 1fr 1fr 1fr auto; gap:.55rem; align-items:end; }
|
||||
.search-form label { display:block; font-size:.78rem; margin-bottom:.25rem; color:var(--muted,#64748b); }
|
||||
.search-form input, .search-form select { width:100%; box-sizing:border-box; }
|
||||
.results-list { display:flex; flex-direction:column; gap:.6rem; max-height:650px; overflow:auto; }
|
||||
.site-result { border:1px solid #dce2ea; border-radius:8px; padding:.75rem; background:#fff; cursor:pointer; transition:.15s; }
|
||||
.site-result:hover, .site-result.selected { border-color:var(--primary); box-shadow:0 0 0 2px rgba(37,99,235,.12); }
|
||||
.site-result h3 { margin:0 0 .3rem; font-size:1rem; }
|
||||
.site-result p { margin:.2rem 0; font-size:.82rem; color:#526174; }
|
||||
.site-result .meta { display:flex; gap:.4rem; flex-wrap:wrap; margin-top:.45rem; }
|
||||
.badge { display:inline-block; padding:.16rem .42rem; border-radius:999px; color:#fff; font-size:.72rem; font-weight:600; }
|
||||
.badge-neutral { background:#64748b; }
|
||||
#searchMap { height:650px; min-height:420px; border-radius:var(--radius-sm); background:#e8eaf0; }
|
||||
.map-card { position:sticky; top:1rem; }
|
||||
.legend { display:flex; gap:.8rem; flex-wrap:wrap; margin-top:.65rem; font-size:.76rem; }
|
||||
.legend span { display:inline-flex; align-items:center; gap:.25rem; }
|
||||
.legend i { width:10px; height:10px; border-radius:50%; display:inline-block; }
|
||||
.cells-card { margin-top:1rem; overflow:auto; }
|
||||
.cells-card table { min-width:950px; }
|
||||
.azimuth-cell { white-space:nowrap; font-weight:600; }
|
||||
.empty-state { padding:1.2rem; text-align:center; color:#64748b; }
|
||||
.site-actions { display:flex; gap:.5rem; margin-top:.65rem; flex-wrap:wrap; }
|
||||
.popup-title { font-weight: 700; }
|
||||
.popup-line { margin: 0; }
|
||||
@media (max-width:1000px) { .search-layout { grid-template-columns:1fr; } .map-card { position:static; } .search-form { grid-template-columns:1fr 1fr; } #searchMap { height:480px; } }
|
||||
@media (max-width:600px) { .search-form { grid-template-columns:1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<?php render_admin_nav('search_site.php', $user); ?>
|
||||
|
||||
<div class="page-wrap">
|
||||
<h1>Recherche antenne / eNB</h1>
|
||||
<p class="page-subtitle">Recherchez un eNB, une cellule, une bande, une frequence ou une adresse. Les secteurs avec azimut sont dessines sur la carte.</p>
|
||||
|
||||
<div class="card search-card">
|
||||
<form class="search-form" method="get">
|
||||
<div>
|
||||
<label for="q">Recherche</label>
|
||||
<input id="q" type="search" name="q" value="<?= h($query) ?>" placeholder="eNB, Cell ID, bande, frequence, adresse..." autofocus>
|
||||
</div>
|
||||
<div>
|
||||
<label for="country">Pays</label>
|
||||
<select id="country" name="country">
|
||||
<option value="">Tous les pays</option>
|
||||
<?php foreach ($countries as $country): ?>
|
||||
<option value="<?= h($country['iso2']) ?>" <?= $countryFilter === $country['iso2'] ? 'selected' : '' ?>><?= h($country['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="operator">Operateur</label>
|
||||
<select id="operator" name="operator">
|
||||
<option value="">Tous les operateurs</option>
|
||||
<?php foreach ($operators as $operator): ?>
|
||||
<option value="<?= (int)$operator['id'] ?>" <?= $operatorFilter === (string)$operator['id'] ? 'selected' : '' ?>><?= h($operator['brand']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="tech">Technologie</label>
|
||||
<select id="tech" name="tech">
|
||||
<option value="">Toutes</option>
|
||||
<?php foreach (['2G','3G','4G','5G','CDMA'] as $tech): ?>
|
||||
<option value="<?= $tech ?>" <?= $techFilter === $tech ? 'selected' : '' ?>><?= $tech ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="primary">Rechercher</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="search-layout">
|
||||
<div>
|
||||
<div class="card">
|
||||
<h2>Antennes trouvees (<?= count($sites) ?>)</h2>
|
||||
<?php if (!$query && !$countryFilter && !$operatorFilter && !$techFilter): ?>
|
||||
<div class="empty-state">Saisissez un eNB, un Cell ID, une bande ou une adresse.</div>
|
||||
<?php elseif (empty($sites)): ?>
|
||||
<div class="empty-state">Aucune antenne trouvee.</div>
|
||||
<?php else: ?>
|
||||
<div class="results-list">
|
||||
<?php foreach ($sites as $site): ?>
|
||||
<?php $isSelected = $selectedSite && (int)$selectedSite['site_id'] === (int)$site['site_id']; ?>
|
||||
<a class="site-result <?= $isSelected ? 'selected' : '' ?>" href="?<?= h(http_build_query(['q' => $query, 'country' => $countryFilter, 'operator' => $operatorFilter, 'tech' => $techFilter, 'site_id' => $site['site_id']])) ?>">
|
||||
<h3><?= h($site['operator_brand']) ?> — <?= h($site['country_name']) ?></h3>
|
||||
<p><strong>eNB/RNC :</strong> <?= h($site['cells'][0]['sub_id2'] ?: 'non renseigne') ?></p>
|
||||
<p><?= h($site['address_desc'] ?: $site['location_desc'] ?: 'Adresse non renseignee') ?></p>
|
||||
<p><strong><?= (int)$site['cell_count'] ?></strong> cellule(s) — <?= h((string)$site['lat']) ?>, <?= h((string)$site['lon']) ?></p>
|
||||
<div class="meta">
|
||||
<?php foreach (array_values(array_unique(array_filter(array_map(static fn(array $cell): string => $cell['tech'], $site['cells'])))) as $tech): ?>
|
||||
<span class="badge" style="background:<?= h(techColor($tech)) ?>"><?= h($tech) ?></span>
|
||||
<?php endforeach; ?>
|
||||
<?php $azimuthCount = count(array_filter($site['cells'], static fn(array $cell): bool => $cell['azimuth'] !== null)); ?>
|
||||
<span class="badge badge-neutral"><?= $azimuthCount ?> azimut(s)</span>
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($selectedSite): ?>
|
||||
<div class="card cells-card">
|
||||
<h2>Cellules de l'antenne</h2>
|
||||
<p class="page-subtitle"><?= h($selectedSite['operator_brand']) ?> — <?= h($selectedSite['address_desc'] ?: $selectedSite['location_desc']) ?></p>
|
||||
<div class="site-actions"><a class="primary" href="edit_site.php?site_id=<?= (int)$selectedSite['site_id'] ?>">Modifier cette antenne</a></div>
|
||||
<table class="data-table">
|
||||
<tr><th>Techno</th><th>Cell ID</th><th>eNB/RNC</th><th>Bande</th><th>Frequence</th><th>Largeur</th><th>Azimut</th><th>ARFCN</th></tr>
|
||||
<?php foreach ($selectedCells as $cell): ?>
|
||||
<tr>
|
||||
<td><span class="badge" style="background:<?= h(techColor($cell['tech'])) ?>"><?= h($cell['tech']) ?></span></td>
|
||||
<td><?= h($cell['cell_id']) ?></td>
|
||||
<td><?= h($cell['sub_id2']) ?></td>
|
||||
<td><?= h($cell['band']) ?></td>
|
||||
<td><?= h($cell['frequency']) ?></td>
|
||||
<td><?= h($cell['bandwidth']) ?></td>
|
||||
<td class="azimuth-cell"><?= $cell['azimuth'] === null ? '—' : h(rtrim(rtrim(number_format((float)$cell['azimuth'], 2, '.', ''), '0'), '.')) . '°' ?></td>
|
||||
<td><?= h($cell['arfcn']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="card map-card">
|
||||
<h2>Carte des azimuts</h2>
|
||||
<div id="searchMap"></div>
|
||||
<div class="legend">
|
||||
<?php foreach (['2G','3G','4G','5G','CDMA'] as $tech): ?><span><i style="background:<?= h(techColor($tech)) ?>"></i><?= $tech ?></span><?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const mapSites = <?= json_encode($mapSites, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_NUMERIC_CHECK) ?>;
|
||||
const techColors = { '2G': '#eab308', '3G': '#2563eb', '4G': '#16a34a', '5G': '#9333ea', 'CDMA': '#ea580c' };
|
||||
|
||||
function destinationPoint(lat, lon, bearing, distanceMeters) {
|
||||
const radius = 6371000;
|
||||
const angularDistance = distanceMeters / radius;
|
||||
const bearingRad = bearing * Math.PI / 180;
|
||||
const latRad = lat * Math.PI / 180;
|
||||
const lonRad = lon * Math.PI / 180;
|
||||
const destLat = Math.asin(Math.sin(latRad) * Math.cos(angularDistance) + Math.cos(latRad) * Math.sin(angularDistance) * Math.cos(bearingRad));
|
||||
const destLon = lonRad + Math.atan2(Math.sin(bearingRad) * Math.sin(angularDistance) * Math.cos(latRad), Math.cos(angularDistance) - Math.sin(latRad) * Math.sin(destLat));
|
||||
return [destLat * 180 / Math.PI, destLon * 180 / Math.PI];
|
||||
}
|
||||
|
||||
function sectorPoints(lat, lon, azimuth, radius, opening) {
|
||||
const points = [[lat, lon]];
|
||||
const start = azimuth - opening / 2;
|
||||
const end = azimuth + opening / 2;
|
||||
for (let angle = start; angle <= end; angle += 4) {
|
||||
points.push(destinationPoint(lat, lon, angle, radius));
|
||||
}
|
||||
points.push(destinationPoint(lat, lon, end, radius));
|
||||
points.push([lat, lon]);
|
||||
return points;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit la popup d'un site via des elements DOM (textContent),
|
||||
* jamais via innerHTML/template literal, pour empecher toute
|
||||
* injection HTML/JS a partir de donnees stockees en base.
|
||||
*/
|
||||
function buildSitePopup(site) {
|
||||
const container = document.createElement('div');
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'popup-title';
|
||||
title.textContent = `${site.operator_brand ?? ''} — ${site.country_name ?? ''}`;
|
||||
container.appendChild(title);
|
||||
|
||||
const addr = document.createElement('div');
|
||||
addr.className = 'popup-line';
|
||||
addr.textContent = site.address_desc || site.location_desc || 'Adresse non renseignee';
|
||||
container.appendChild(addr);
|
||||
|
||||
const count = document.createElement('div');
|
||||
count.className = 'popup-line';
|
||||
count.textContent = `${site.cell_count} cellule(s)`;
|
||||
container.appendChild(count);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit la popup d'un secteur (polygone d'azimut) via des
|
||||
* elements DOM (textContent), meme logique que buildSitePopup.
|
||||
*/
|
||||
function buildSectorPopup(cell) {
|
||||
const container = document.createElement('div');
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'popup-title';
|
||||
title.textContent = `${cell.tech || ''} ${cell.band || ''}`.trim();
|
||||
container.appendChild(title);
|
||||
|
||||
const lines = [
|
||||
`Cell ID : ${cell.cell_id || ''}`,
|
||||
`eNB/RNC : ${cell.sub_id2 || ''}`,
|
||||
`Frequence : ${cell.frequency || ''}`,
|
||||
`Largeur : ${cell.bandwidth || ''}`,
|
||||
`Azimut : ${cell.azimuth}°`,
|
||||
];
|
||||
lines.forEach(text => {
|
||||
const line = document.createElement('div');
|
||||
line.className = 'popup-line';
|
||||
line.textContent = text;
|
||||
container.appendChild(line);
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
const mapElement = document.getElementById('searchMap');
|
||||
const defaultCenter = [49.8153, 6.1296];
|
||||
let mapCenter = defaultCenter;
|
||||
let mapZoom = 8;
|
||||
|
||||
if (mapSites.length > 0) {
|
||||
mapCenter = [Number(mapSites[0].lat), Number(mapSites[0].lon)];
|
||||
mapZoom = mapSites.length === 1 ? 16 : 10;
|
||||
}
|
||||
|
||||
const searchMap = L.map(mapElement).setView(mapCenter, mapZoom);
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a> contributors'
|
||||
}).addTo(searchMap);
|
||||
|
||||
const bounds = [];
|
||||
|
||||
mapSites.forEach(site => {
|
||||
const lat = Number(site.lat);
|
||||
const lon = Number(site.lon);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
|
||||
|
||||
const marker = L.marker([lat, lon]).addTo(searchMap);
|
||||
marker.bindPopup(buildSitePopup(site));
|
||||
bounds.push([lat, lon]);
|
||||
|
||||
(site.cells || []).forEach(cell => {
|
||||
if (cell.azimuth === null || cell.azimuth === '' || !Number.isFinite(Number(cell.azimuth))) return;
|
||||
const azimuth = Number(cell.azimuth);
|
||||
const sectorColor = techColors[String(cell.tech).toUpperCase()] || '#64748b';
|
||||
const polygon = L.polygon(sectorPoints(lat, lon, azimuth, 250, 65), { color: sectorColor, fillColor: sectorColor, fillOpacity: 0.28, weight: 2 }).addTo(searchMap);
|
||||
polygon.bindPopup(buildSectorPopup(cell));
|
||||
bounds.push(...sectorPoints(lat, lon, azimuth, 250, 65));
|
||||
});
|
||||
});
|
||||
|
||||
if (bounds.length > 1) searchMap.fitBounds(bounds, { padding: [25, 25] });
|
||||
setTimeout(() => searchMap.invalidateSize(), 100);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user