Ajout des vérifications des types dans les formulaires (adresses, email, telephone)

This commit is contained in:
PTA
2026-08-21 13:50:05 +02:00
parent b3e53f83b8
commit 44730eefca
15 changed files with 898 additions and 17 deletions

20
api/search_address.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php';
/**
* GET — propositions d'adresses de la Base Adresse Nationale, SANS écriture.
* Un échec du service distant ressort en 502.
* Paramètres : q (saisie en cours), limit (facultatif).
*
* Point d'entrée mince : toute la logique est dans le contrôleur appelé.
* Voir CLAUDE.md, section « Deux types de points d'entrée ».
*/
use App\Controllers\AddressController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new AddressController($pdo))->search();

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Access;
use App\Core\JsonResponse;
use App\Services\AddressApiService;
use PDO;
use Throwable;
/**
* Recherche d'adresses postales (V7.25), relais vers la Base Adresse Nationale.
*
* Sert l'autocomplétion du champ « Adresse personnelle » de la page Agents. Le
* relais est indispensable : la CSP interdit au navigateur d'appeler un domaine
* tiers (`connect-src 'self'`, voir App\Core\Security).
*
* LECTURE PURE : aucune écriture en base, donc ni jeton CSRF ni journalisation
* (CLAUDE.md — « les lectures ne sont jamais journalisées »). Le garde reste
* celui de l'écriture des fiches agent, `requireService()` : c'est le seul rôle
* qui saisit une adresse.
*
* Un échec du service distant ressort en 502 et non en 500 : c'est la BAN qui
* n'a pas répondu, pas PTA qui a échoué. Côté interface, cet échec n'est jamais
* bloquant — l'adresse reste saisissable à la main.
*/
final class AddressController
{
private Access $access;
public function __construct(private PDO $pdo)
{
$this->access = new Access($pdo);
}
/**
* GET api/search_address.php — propositions d'adresses.
* Paramètres GET : q (saisie en cours), limit (facultatif, 1 à 8).
*/
public function search(): void
{
$this->access->requireService();
$query = trim((string) ($_GET['q'] ?? ''));
$length = function_exists('mb_strlen') ? mb_strlen($query, 'UTF-8') : strlen($query);
if ($length < AddressApiService::MIN_QUERY_LENGTH) {
JsonResponse::send(422, [
'error' => sprintf('Saisissez au moins %d caractères.', AddressApiService::MIN_QUERY_LENGTH),
]);
}
// Plafond de saisie : la BAN n'exploite pas au-delà, et cela borne le
// coût d'une requête forgée.
if ($length > 120) {
JsonResponse::send(422, ['error' => 'Saisie trop longue pour une recherche d’adresse.']);
}
$limit = filter_var($_GET['limit'] ?? 5, FILTER_VALIDATE_INT);
if ($limit === false) {
$limit = 5;
}
try {
$suggestions = (new AddressApiService())->search($query, $limit);
} catch (Throwable $e) {
JsonResponse::send(502, [
'error' => 'Le service national des adresses est momentanément indisponible. Vous pouvez saisir l’adresse manuellement.',
'details' => (getenv('PTA_DEBUG') ?: '0') !== '0' ? $e->getMessage() : null,
]);
}
JsonResponse::send(200, [
'query' => $query,
'suggestions' => $suggestions,
]);
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Controllers;
use App\Core\Access;
use App\Core\ContactFormat;
use App\Core\Csrf;
use App\Core\Journal;
use App\Core\JsonResponse;
@@ -484,8 +485,15 @@ final class AgentController
if ($rate === false || $rate <= 0 || $rate > 100) {
JsonResponse::send(422, ['error' => 'La quotité de travail doit être comprise entre 1 et 100 %.']);
}
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
JsonResponse::send(422, ['error' => 'L’adresse e-mail saisie n’est pas valide.']);
// Coordonnées FACULTATIVES : seule une valeur renseignée est contrôlée.
// Les règles de forme vivent dans ContactFormat, communes à create() et
// update(), et recopiées dans assets/js/field-format.js pour le retour
// immédiat à la saisie.
if ($email !== '' && !ContactFormat::isEmail($email)) {
JsonResponse::send(422, ['error' => 'L’adresse e-mail saisie n’est pas valide. Format attendu : prenom.nom@exemple.fr']);
}
if ($telephone !== '' && !ContactFormat::isPhone($telephone)) {
JsonResponse::send(422, ['error' => 'Le numéro de téléphone saisi n’est pas valide. Format attendu : 06 12 34 56 78 ou +33 6 12 34 56 78.']);
}
if (strlen($matricule) > 50 || strlen($nom) > 100 || strlen($prenom) > 100 || strlen($email) > 255
|| strlen($telephone) > 30 || strlen($adresse) > 255 || strlen($diplomeLibelle) > 150 || strlen($ptaComment) > 1000) {
@@ -624,8 +632,15 @@ final class AgentController
|| ($contractEnd !== '' && (!$contractEndDate || $contractEndDate->format('Y-m-d') !== $contractEnd || $contractEndDate < $contractStartDate))) {
JsonResponse::send(422, ['error' => 'Le poste, le type de contrat et les dates du contrat doivent être valides.']);
}
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
JsonResponse::send(422, ['error' => 'L’adresse e-mail saisie n’est pas valide.']);
// Coordonnées FACULTATIVES : seule une valeur renseignée est contrôlée.
// Les règles de forme vivent dans ContactFormat, communes à create() et
// update(), et recopiées dans assets/js/field-format.js pour le retour
// immédiat à la saisie.
if ($email !== '' && !ContactFormat::isEmail($email)) {
JsonResponse::send(422, ['error' => 'L’adresse e-mail saisie n’est pas valide. Format attendu : prenom.nom@exemple.fr']);
}
if ($telephone !== '' && !ContactFormat::isPhone($telephone)) {
JsonResponse::send(422, ['error' => 'Le numéro de téléphone saisi n’est pas valide. Format attendu : 06 12 34 56 78 ou +33 6 12 34 56 78.']);
}
if (strlen($nom) > 100 || strlen($prenom) > 100 || strlen($email) > 255 || strlen($telephone) > 30
|| strlen($adresse) > 255 || strlen($diplomeLibelle) > 150 || strlen($ptaComment) > 1000) {

View File

@@ -289,8 +289,8 @@ final class PageController extends Controller
'agents' => $agentModel->allActive(),
'structures' => (new StructureModel($this->pdo))->allActive(),
'posts' => $agentModel->allPosts(),
'scripts' => ['administration.js'],
'modules' => ['administration'],
'scripts' => ['administration.js', 'address-autocomplete.js', 'field-format.js'],
'modules' => ['administration', 'addressAutocomplete', 'fieldFormat'],
]);
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Core;
/**
* Contrôle de FORME des coordonnées d'un agent : adresse e-mail et numéro de
* téléphone. Source de vérité UNIQUE côté serveur — create() et update() de
* AgentController s'y réfèrent toutes les deux, et toute nouvelle voie
* d'écriture d'un contact doit faire de même plutôt que recopier une regex.
*
* Une copie de ces règles vit dans assets/js/field-format.js pour le retour
* immédiat à la saisie. C'est l'application habituelle du principe du projet :
* l'interface prévient, le serveur tranche. Modifier une règle ici impose donc
* de reporter la modification dans le module JS — l'inverse n'est jamais vrai.
*
* PORTÉE VOLONTAIREMENT LIMITÉE : ces deux champs sont FACULTATIFS et purement
* informatifs (ils n'ouvrent aucune session, n'envoient aucun message). Le
* contrôle attrape la faute de frappe, il ne prétend pas prouver qu'une boîte
* ou une ligne existe. Une valeur vide reste toujours acceptée.
*/
final class ContactFormat
{
/**
* Adresse e-mail. Regex délibérément plus stricte que la RFC 5322, qui
* autorise des formes que personne ne saisit dans un annuaire d'agents
* (commentaires, guillemets, adresses IP littérales). Elle est DOUBLÉE de
* FILTER_VALIDATE_EMAIL : la regex écarte ce que le filtre tolère (domaine
* sans point, par exemple « agent@mairie »), le filtre écarte ce que la
* regex laisserait passer.
*/
private const EMAIL_PATTERN = '/^[A-Za-z0-9._%+\-]+@[A-Za-z0-9]([A-Za-z0-9\-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9\-]*[A-Za-z0-9])?)*\.[A-Za-z]{2,}$/';
/**
* Numéro français, une fois les séparateurs retirés : 10 chiffres commençant
* par 0, ou la même chose en forme internationale (+33 / 0033). Couvre le
* fixe comme le mobile, métropole et outre-mer.
*/
private const PHONE_FR_PATTERN = '/^(?:\+33|0033|0)[1-9]\d{8}$/';
/**
* Repli international E.164 pour un agent joignable à l'étranger — refuser
* un numéro valide serait pire que d'en accepter un exotique, le champ
* n'étant qu'informatif.
*/
private const PHONE_INTERNATIONAL_PATTERN = '/^\+[1-9]\d{7,14}$/';
/** Séparateurs tolérés à la saisie : espaces (insécable compris), . - / ( ). */
private const PHONE_SEPARATORS = '/[\s.\-\/()\x{00A0}\x{202F}]/u';
/** Vrai si l'adresse e-mail est exploitable. La chaîne vide est refusée : c'est à l'appelant de traiter « non renseigné » avant. */
public static function isEmail(string $value): bool
{
$value = trim($value);
if ($value === '') {
return false;
}
return preg_match(self::EMAIL_PATTERN, $value) === 1
&& filter_var($value, FILTER_VALIDATE_EMAIL) !== false;
}
/** Vrai si le numéro de téléphone est exploitable, séparateurs de saisie ignorés. */
public static function isPhone(string $value): bool
{
$compact = self::compactPhone($value);
if ($compact === '') {
return false;
}
return preg_match(self::PHONE_FR_PATTERN, $compact) === 1
|| preg_match(self::PHONE_INTERNATIONAL_PATTERN, $compact) === 1;
}
/**
* Numéro débarrassé de ses séparateurs, seule forme sur laquelle les regex
* travaillent. « 06 12 34 56 78 », « 06.12.34.56.78 » et « 0612345678 »
* décrivent la même ligne : les distinguer n'apporterait rien.
*/
public static function compactPhone(string $value): string
{
return (string) preg_replace(self::PHONE_SEPARATORS, '', trim($value));
}
}

View File

@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
namespace App\Services;
use RuntimeException;
/**
* Recherche d'adresses postales sur l'API publique Base Adresse Nationale
* (api-adresse.data.gouv.fr), service officiel data.gouv.fr, sans clé ni quota
* déclaré. Adresses françaises uniquement.
*
* POURQUOI CÔTÉ SERVEUR : la CSP de PTA impose `connect-src 'self'` — le
* navigateur ne peut appeler aucun domaine tiers. Le service est donc appelé
* depuis PHP, comme SchoolHolidayApiService, et le conteneur php a besoin d'un
* accès réseau sortant. Ne pas contourner en élargissant la CSP.
*
* Ce service ne PROPOSE que : il ne touche jamais la base, et l'adresse reste
* saisissable librement. Une adresse absente de la BAN (lieu-dit, logement de
* fonction, résidence non référencée) doit rester enregistrable — d'où
* l'absence de tout contrôle bloquant côté écriture.
*/
final class AddressApiService
{
private const ENDPOINT = 'https://api-adresse.data.gouv.fr/search/';
/** Longueur minimale exigée par l'API distante. */
public const MIN_QUERY_LENGTH = 3;
/** Plafond de propositions renvoyées, aligné sur ce que la liste affiche. */
private const MAX_LIMIT = 8;
/**
* Propositions d'adresses pour une saisie partielle.
*
* `autocomplete=1` demande à la BAN son mode « saisie en cours », qui
* tolère un dernier mot tronqué — c'est ce qui permet de rafraîchir la
* liste à chaque frappe plutôt qu'à chaque adresse complète.
*
* @return array<int, array<string, mixed>> Propositions normalisées, les
* plus pertinentes d'abord (ordre de score rendu par la BAN).
*
* @throws RuntimeException Saisie trop courte, ou API injoignable.
*/
public function search(string $query, int $limit = 5): array
{
$query = trim(preg_replace('/\s+/u', ' ', $query) ?? $query);
$length = function_exists('mb_strlen') ? mb_strlen($query, 'UTF-8') : strlen($query);
if ($length < self::MIN_QUERY_LENGTH) {
throw new RuntimeException('Saisissez au moins ' . self::MIN_QUERY_LENGTH . ' caractères.');
}
$limit = max(1, min(self::MAX_LIMIT, $limit));
$url = self::ENDPOINT . '?' . http_build_query([
'q' => $query,
'limit' => $limit,
'autocomplete' => 1,
], '', '&', PHP_QUERY_RFC3986);
$payload = $this->fetchJson($url);
$features = $payload['features'] ?? null;
if (!is_array($features)) {
throw new RuntimeException('Réponse inattendue de l’API Adresse.');
}
$suggestions = [];
foreach ($features as $feature) {
$suggestion = $this->normalizeFeature($feature);
if ($suggestion !== null) {
$suggestions[] = $suggestion;
}
}
return $suggestions;
}
/**
* Convertit une « feature » GeoJSON de la BAN en proposition exploitable.
*
* `label` est l'adresse complète telle qu'elle sera écrite dans la fiche
* agent ; `nom` et `contexte` ne servent qu'à l'affichage de la liste.
* Une feature sans label est ignorée : sans elle il n'y a rien à insérer.
*
* @return array<string, mixed>|null
*/
private function normalizeFeature(mixed $feature): ?array
{
if (!is_array($feature) || !is_array($feature['properties'] ?? null)) {
return null;
}
$properties = $feature['properties'];
$label = trim((string) ($properties['label'] ?? ''));
if ($label === '') {
return null;
}
return [
'label' => $label,
'nom' => trim((string) ($properties['name'] ?? '')),
'code_postal' => trim((string) ($properties['postcode'] ?? '')),
'commune' => trim((string) ($properties['city'] ?? '')),
'contexte' => trim((string) ($properties['context'] ?? '')),
// housenumber | street | locality | municipality : une proposition
// « municipality » n'est qu'une commune, pas une adresse précise.
'type' => trim((string) ($properties['type'] ?? '')),
];
}
/**
* Requête HTTP + décodage JSON, sur le modèle de SchoolHolidayApiService :
* cURL quand l'extension est là, repli sur file_get_contents() sinon.
*
* Délais VOLONTAIREMENT COURTS (4 s de connexion, 8 s au total) : l'appel
* est déclenché à la frappe, un utilisateur ne patiente pas devant une
* liste de suggestions. Mieux vaut renoncer que faire attendre.
*
* @return array<mixed>
*
* @throws RuntimeException Erreur réseau, code HTTP >= 400, ou JSON invalide.
*/
private function fetchJson(string $url): array
{
$body = false;
$status = 0;
if (function_exists('curl_init')) {
$curl = curl_init($url);
if ($curl === false) {
throw new RuntimeException('Impossible d’initialiser cURL.');
}
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_CONNECTTIMEOUT => 4,
CURLOPT_TIMEOUT => 8,
CURLOPT_ENCODING => '',
CURLOPT_HTTPHEADER => ['Accept: application/json', 'User-Agent: PTA/7.26'],
]);
$body = curl_exec($curl);
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$error = curl_error($curl);
curl_close($curl);
if ($body === false) {
throw new RuntimeException($error ?: 'Erreur réseau cURL.');
}
} else {
$context = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => 8,
'ignore_errors' => true,
'header' => "Accept: application/json\r\nUser-Agent: PTA/7.26\r\n",
],
]);
$body = @file_get_contents($url, false, $context);
if ($body === false) {
throw new RuntimeException('La lecture d’URL distante est désactivée sur le serveur PHP. Activez cURL ou allow_url_fopen.');
}
foreach ($http_response_header ?? [] as $header) {
if (preg_match('/^HTTP\/\S+\s+(\d{3})/', $header, $matches)) {
$status = (int) $matches[1];
break;
}
}
}
if ($status >= 400) {
throw new RuntimeException('L’API Adresse a répondu avec le code HTTP ' . $status . '.');
}
$data = json_decode((string) $body, true);
if (!is_array($data)) {
throw new RuntimeException('Le contenu reçu depuis l’API Adresse n’est pas un JSON valide.');
}
return $data;
}
}

View File

@@ -51,10 +51,10 @@ window.PTA_PAGE_CONFIG = <?= json_encode([
'params' => $pageConfig,
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>;
</script>
<script src="assets/js/core.js?v=7.24"></script>
<script src="assets/js/core.js?v=7.26"></script>
<?php foreach ($scripts as $script): ?>
<script src="assets/js/<?= htmlspecialchars($script) ?>?v=7.24"></script>
<script src="assets/js/<?= htmlspecialchars($script) ?>?v=7.26"></script>
<?php endforeach; ?>
<script src="assets/js/page-bootstrap.js?v=7.24"></script>
<script src="assets/js/page-bootstrap.js?v=7.26"></script>
</body>
</html>

View File

@@ -104,7 +104,7 @@ $showHelpTab = in_array('faq', $allowedPages, true);
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($pageTitle) ?> — PTA</title>
<link rel="stylesheet" href="assets/style.css?v=7.24">
<link rel="stylesheet" href="assets/style.css?v=7.26">
</head>
<body>
<main class="app-shell">

View File

@@ -43,7 +43,7 @@ $titre = $titres[$etape] ?? 'Connexion';
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($titre) ?> — PTA</title>
<link rel="stylesheet" href="assets/style.css?v=7.24">
<link rel="stylesheet" href="assets/style.css?v=7.26">
</head>
<body class="role-page-body">
<main class="role-page-shell">

View File

@@ -151,6 +151,8 @@ $sommaire = [
<li><strong>Dates de contrat</strong> : information RH. Attention, la date de début <em>ne décale pas</em> l’année de référence, qui reste l’année civile complète.</li>
<li><strong>Qualification et diplôme</strong> : le champ « Diplôme » ne s’active que si l’agent est déclaré diplômé. Cette information alimente le contrôle du taux de diplômés dans les équipes.</li>
<li><strong>Droit à l’enveloppe de formation de 14 h</strong> : coché par défaut. Décoché, le motif « Formation » ne sera pas proposé pour cet agent lors de la saisie.</li>
<li><strong>E-mail et téléphone</strong> : facultatifs, mais contrôlés au format dès la saisie. Le téléphone accepte les séparateurs habituels (<code>06 12 34 56 78</code>, <code>06.12.34.56.78</code>, <code>+33 6 12 34 56 78</code>) ; une saisie mal formée est signalée sous le champ et bloque l’enregistrement.</li>
<li><strong>Adresse personnelle</strong> : facultative et purement informative. Dès trois caractères saisis, une liste d’adresses officielles est proposée sous le champ (source : Base Adresse Nationale) — cliquez sur la bonne, ou continuez à écrire librement si l’adresse n’y figure pas.</li>
<li><strong>Observations utiles à la construction du PTA</strong> : disponibilités particulières, organisation connue… Ces notes se retrouvent sur l’écran « PTA détaillé ».</li>
<li>Cliquez sur <strong>Ajouter l’agent</strong>.</li>
</ol>

View File

@@ -13,6 +13,17 @@
*
* L'ordre est celui du travail réel : on crée un agent, puis on consulte ou
* corrige sa fiche dans le tableau qui suit.
*
* V7.25 : les deux champs d'adresse (création et fiche) sont enveloppés dans un
* conteneur `[data-address-autocomplete]` que le module `addressAutocomplete`
* branche sur la Base Adresse Nationale. La structure attendue est fixe —
* champ + `[data-address-suggestions]` + `[data-address-status]` — mais aucun
* identifiant n'est connu du module.
*
* Même principe pour l'e-mail et le téléphone : `data-format="email|phone"` sur
* le champ et un `[data-format-status]` frère suffisent au module `fieldFormat`
* à contrôler la saisie. Ce contrôle ne fait que PRÉVENIR : c'est
* App\Core\ContactFormat qui refuse pour de bon, côté serveur.
*/
?>
<section id="manage-agents-view" class="app-view page-content">
@@ -66,11 +77,13 @@
</label>
<label class="field">
<span>E-mail</span>
<input id="new-agent-email" name="email" type="email" maxlength="255" placeholder="prenom.nom@exemple.fr">
<input id="new-agent-email" name="email" type="email" maxlength="255" placeholder="prenom.nom@exemple.fr" data-format="email">
<p class="field-format-status" role="status" aria-live="polite" data-format-status></p>
</label>
<label class="field">
<span>Téléphone</span>
<input id="new-agent-phone" name="telephone" type="tel" maxlength="30" placeholder="Ex. 06 12 34 56 78">
<input id="new-agent-phone" name="telephone" type="tel" maxlength="30" placeholder="Ex. 06 12 34 56 78" data-format="phone">
<p class="field-format-status" role="status" aria-live="polite" data-format-status></p>
</label>
<label class="field">
<span>Qualification</span>
@@ -85,7 +98,11 @@
</label>
<label class="field admin-field-wide">
<span>Adresse personnelle</span>
<textarea id="new-agent-address" name="adresse" maxlength="255" rows="2" placeholder="Adresse de l’agent"></textarea>
<div class="address-autocomplete" data-address-autocomplete>
<textarea id="new-agent-address" name="adresse" maxlength="255" rows="2" placeholder="Ex. 12 rue de la Liberté, Dijon"></textarea>
<ul id="new-agent-address-suggestions" class="address-suggestions" role="listbox" aria-label="Adresses proposées" data-address-suggestions hidden></ul>
<p class="address-autocomplete-status" role="status" aria-live="polite" data-address-status></p>
</div>
</label>
<label class="field">
<span>Quotité de travail <strong>*</strong></span>
@@ -219,11 +236,13 @@
</label>
<label class="field">
<span>E-mail</span>
<input id="edit-agent-profile-email" type="email" maxlength="255">
<input id="edit-agent-profile-email" type="email" maxlength="255" placeholder="prenom.nom@exemple.fr" data-format="email">
<p class="field-format-status" role="status" aria-live="polite" data-format-status></p>
</label>
<label class="field">
<span>Téléphone</span>
<input id="edit-agent-profile-phone" type="tel" maxlength="30" placeholder="Ex. 06 12 34 56 78">
<input id="edit-agent-profile-phone" type="tel" maxlength="30" placeholder="Ex. 06 12 34 56 78" data-format="phone">
<p class="field-format-status" role="status" aria-live="polite" data-format-status></p>
</label>
<label class="field">
<span>Qualification</span>
@@ -238,7 +257,11 @@
</label>
<label class="field admin-field-wide">
<span>Adresse personnelle</span>
<textarea id="edit-agent-profile-address" maxlength="255" rows="3"></textarea>
<div class="address-autocomplete" data-address-autocomplete>
<textarea id="edit-agent-profile-address" maxlength="255" rows="3" placeholder="Ex. 12 rue de la Liberté, Dijon"></textarea>
<ul id="edit-agent-profile-address-suggestions" class="address-suggestions" role="listbox" aria-label="Adresses proposées" data-address-suggestions hidden></ul>
<p class="address-autocomplete-status" role="status" aria-live="polite" data-address-status></p>
</div>
</label>
<label class="field">
<span>Date de début du contrat <strong>*</strong></span>

View File

@@ -0,0 +1,232 @@
/**
* Autocomplétion d'adresse (`PTA.modules.addressAutocomplete`), page Agents.
*
* Branche chaque conteneur `[data-address-autocomplete]` sur l'API Base Adresse
* Nationale, relayée par api/search_address.php — le navigateur ne peut pas
* appeler la BAN directement, la CSP le lui interdit.
*
* PRINCIPE : la liste PROPOSE, elle n'impose pas. Le champ reste un texte libre
* et rien n'est bloqué à l'enregistrement : un lieu-dit ou une résidence absents
* de la BAN doivent rester saisissables. Un échec du service distant n'affiche
* qu'une note, jamais une erreur bloquante.
*
* Le module est générique : il ne connaît aucun identifiant de champ, seulement
* la structure de conteneur posée dans le partiel (textarea ou input + liste
* `[data-address-suggestions]` + note `[data-address-status]`). Il peut donc
* être réutilisé tel quel sur un autre écran portant un champ d'adresse.
*/
(() => {
'use strict';
const PTA = window.PTA;
if (!PTA) throw new Error('PTA core doit être chargé avant address-autocomplete.js');
const { utils } = PTA;
const { readJsonResponse, apiErrorMessage, escapeHtml } = utils;
/** Seuil aligné sur celui du serveur (AddressApiService::MIN_QUERY_LENGTH). */
const MIN_LENGTH = 3;
/** Attente après la dernière frappe : assez court pour suivre la saisie,
* assez long pour ne pas lancer une requête par caractère. */
const DEBOUNCE_MS = 300;
/** Une adresse trouvée est décrite par son numéro/voie puis son contexte. */
function suggestionMarkup(suggestion, index, activeIndex) {
const nom = suggestion.nom || suggestion.label;
const detail = [suggestion.code_postal, suggestion.commune]
.filter(Boolean)
.join(' ');
const contexte = suggestion.contexte ? ` — ${suggestion.contexte}` : '';
return `
<li class="address-suggestion${index === activeIndex ? ' is-active' : ''}"
role="option" id="address-suggestion-${index}"
aria-selected="${index === activeIndex ? 'true' : 'false'}"
data-index="${index}">
<strong>${escapeHtml(nom)}</strong>
<small>${escapeHtml(detail)}${escapeHtml(contexte)}</small>
</li>`;
}
/**
* Installe l'autocomplétion sur un conteneur.
* Chaque conteneur garde son propre état : deux champs d'adresse peuvent
* coexister sur la page (création et modification) sans interférer.
*/
function attach(container) {
if (container.dataset.addressBound === '1') return;
container.dataset.addressBound = '1';
const field = container.querySelector('textarea, input[type="text"]');
const list = container.querySelector('[data-address-suggestions]');
const status = container.querySelector('[data-address-status]');
if (!field || !list) return;
let suggestions = [];
let activeIndex = -1;
let timer = null;
/** Requête en vol, annulée dès qu'une frappe la rend obsolète : sans
* cela une réponse lente écraserait une réponse plus récente. */
let controller = null;
/** Dernière valeur retenue dans la liste, pour dire à l'utilisateur que
* son adresse est bien une adresse connue de la BAN. */
let validatedValue = '';
function setStatus(text = '', type = '') {
if (!status) return;
status.textContent = text;
status.className = `address-autocomplete-status${type ? ` address-status-${type}` : ''}`;
}
function close() {
list.hidden = true;
list.innerHTML = '';
suggestions = [];
activeIndex = -1;
field.setAttribute('aria-expanded', 'false');
field.removeAttribute('aria-activedescendant');
}
function render() {
if (suggestions.length === 0) {
close();
return;
}
list.innerHTML = suggestions
.map((suggestion, index) => suggestionMarkup(suggestion, index, activeIndex))
.join('');
list.hidden = false;
field.setAttribute('aria-expanded', 'true');
if (activeIndex >= 0) {
field.setAttribute('aria-activedescendant', `address-suggestion-${activeIndex}`);
} else {
field.removeAttribute('aria-activedescendant');
}
}
/** Retient une proposition : le champ prend l'adresse complète. */
function choose(index) {
const suggestion = suggestions[index];
if (!suggestion) return;
field.value = suggestion.label;
validatedValue = suggestion.label;
close();
setStatus('Adresse vérifiée auprès de la Base Adresse Nationale.', 'ok');
field.focus();
// Les autres modules lisent la valeur au moment de l'envoi, mais un
// champ rempli par programme n'émet aucun événement : on le fait,
// pour ne pas dépendre de la façon dont ils écoutent.
field.dispatchEvent(new Event('input', { bubbles: true }));
field.dispatchEvent(new Event('change', { bubbles: true }));
}
function move(step) {
if (suggestions.length === 0) return;
activeIndex = (activeIndex + step + suggestions.length) % suggestions.length;
render();
}
async function search(query) {
controller?.abort();
controller = new AbortController();
setStatus('Recherche d’adresses…');
try {
const params = new URLSearchParams({ q: query, limit: '5' });
const response = await fetch(`api/search_address.php?${params.toString()}`, {
headers: { Accept: 'application/json' },
signal: controller.signal,
});
const data = await readJsonResponse(response);
if (!response.ok) {
throw new Error(apiErrorMessage(data, 'Recherche d’adresse indisponible.'));
}
suggestions = Array.isArray(data.suggestions) ? data.suggestions : [];
activeIndex = -1;
render();
setStatus(
suggestions.length === 0
? 'Aucune adresse trouvée : la saisie manuelle reste possible.'
: 'Choisissez une adresse dans la liste, ou continuez la saisie.'
);
} catch (error) {
if (error.name === 'AbortError') return;
close();
// Jamais bloquant : le service peut être injoignable sans que
// cela empêche d'enregistrer une fiche agent.
setStatus(error.message, 'warning');
}
}
function scheduleSearch() {
window.clearTimeout(timer);
const query = field.value.trim();
if (query.length < MIN_LENGTH) {
controller?.abort();
close();
setStatus('');
return;
}
if (query === validatedValue) {
close();
setStatus('Adresse vérifiée auprès de la Base Adresse Nationale.', 'ok');
return;
}
timer = window.setTimeout(() => search(query), DEBOUNCE_MS);
}
field.setAttribute('autocomplete', 'off');
field.setAttribute('role', 'combobox');
field.setAttribute('aria-autocomplete', 'list');
field.setAttribute('aria-expanded', 'false');
if (list.id) field.setAttribute('aria-controls', list.id);
field.addEventListener('input', scheduleSearch);
field.addEventListener('keydown', (event) => {
if (list.hidden) {
// Flèche bas sur un champ déjà rempli : rouvrir la recherche
// sans obliger à retoucher le texte.
if (event.key === 'ArrowDown' && field.value.trim().length >= MIN_LENGTH) {
event.preventDefault();
search(field.value.trim());
}
return;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
move(1);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
move(-1);
} else if (event.key === 'Enter' && activeIndex >= 0) {
// Entrée ne vaut sélection QUE si une proposition est surlignée :
// sinon le champ est une zone de texte, la frappe lui appartient.
event.preventDefault();
choose(activeIndex);
} else if (event.key === 'Escape') {
close();
}
});
// mousedown plutôt que click : le clic ferait perdre le focus au champ
// avant que la sélection ne soit traitée.
list.addEventListener('mousedown', (event) => {
const option = event.target.closest('.address-suggestion');
if (!option) return;
event.preventDefault();
choose(Number(option.dataset.index));
});
field.addEventListener('blur', () => {
window.setTimeout(close, 120);
});
}
/**
* Branche tous les conteneurs présents. Rappelable : la fiche de
* modification est dans une fenêtre déjà rendue, mais un écran futur
* pourrait injecter son champ après coup.
*/
function init() {
document.querySelectorAll('[data-address-autocomplete]').forEach(attach);
}
PTA.modules.addressAutocomplete = { init, refresh: init };
})();

View File

@@ -340,6 +340,10 @@
document.querySelector('#edit-agent-profile-pta-comment').value = row.dataset.commentairePta || '';
document.querySelector('#edit-agent-profile-structure').value = row.dataset.structureId || '';
toggleDiplomaField(document.querySelector('#edit-agent-profile-qualified'), document.querySelector('#edit-agent-profile-diploma'));
// Les champs viennent d'être remplis par programme, ce qui n'émet aucun
// événement de saisie : sans ce rappel, les notes de format resteraient
// celles de l'agent précédemment ouvert.
PTA.modules.fieldFormat?.refresh?.();
const message = document.querySelector('#agent-profile-message');
if (message) {

136
assets/js/field-format.js Normal file
View File

@@ -0,0 +1,136 @@
/**
* Contrôle de format à la saisie (`PTA.modules.fieldFormat`), page Agents.
*
* Vérifie l'adresse e-mail et le numéro de téléphone pendant la frappe, dans le
* formulaire de création comme dans la fiche de modification.
*
* COPIE ASSUMÉE DES RÈGLES SERVEUR (App\Core\ContactFormat) : le projet n'a ni
* bundler ni gabarit partagé PHP/JS, la duplication est le prix du retour
* immédiat. Elle ne déplace PAS la décision — le serveur revérifie et refuse en
* 422 quoi qu'affiche l'interface. Toute modification de ContactFormat doit être
* reportée ici ; l'inverse ne suffit jamais.
*
* Le contrôle passe par `setCustomValidity()` plutôt que par un attribut
* `pattern` : le numéro est comparé APRÈS retrait des séparateurs, ce qu'une
* expression HTML ne sait pas faire. Le champ invalide bloque donc l'envoi du
* formulaire par la validation native du navigateur, sans que les modules de
* formulaire aient à s'en occuper.
*
* Les deux champs restent FACULTATIFS : vide vaut valide.
*/
(() => {
'use strict';
const PTA = window.PTA;
if (!PTA) throw new Error('PTA core doit être chargé avant field-format.js');
/** Miroir de ContactFormat::EMAIL_PATTERN. */
const EMAIL_PATTERN = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*\.[A-Za-z]{2,}$/;
/** Miroir de ContactFormat::PHONE_FR_PATTERN. */
const PHONE_FR_PATTERN = /^(?:\+33|0033|0)[1-9]\d{8}$/;
/** Miroir de ContactFormat::PHONE_INTERNATIONAL_PATTERN. */
const PHONE_INTERNATIONAL_PATTERN = /^\+[1-9]\d{7,14}$/;
/** Miroir de ContactFormat::PHONE_SEPARATORS. */
const PHONE_SEPARATORS = /[\s.\-/()\u00A0\u202F]/g;
/** Champs déjà branchés, pour pouvoir les réévaluer sur demande. */
const bound = new Map();
const RULES = {
email: {
test: (value) => EMAIL_PATTERN.test(value),
invalide: 'Adresse e-mail invalide. Format attendu : prenom.nom@exemple.fr',
valide: 'Adresse e-mail au bon format.',
},
phone: {
test: (value) => {
const compact = value.replace(PHONE_SEPARATORS, '');
return PHONE_FR_PATTERN.test(compact) || PHONE_INTERNATIONAL_PATTERN.test(compact);
},
invalide: 'Numéro de téléphone invalide. Format attendu : 06 12 34 56 78 ou +33 6 12 34 56 78.',
valide: 'Numéro de téléphone au bon format.',
},
};
/**
* Branche un champ sur sa règle.
* L'état est recalculé à chaque frappe, mais n'est AFFICHÉ en erreur qu'une
* fois le champ quitté ou l'envoi tenté : signaler « invalide » au deuxième
* caractère tapé serait juste et insupportable.
*/
function attach(field) {
if (bound.has(field)) return;
const rule = RULES[field.dataset.format];
if (!rule) return;
const status = field.parentElement?.querySelector('[data-format-status]')
|| field.closest('.field')?.querySelector('[data-format-status]');
let touched = false;
function evaluate() {
const value = field.value.trim();
// Champ facultatif : vide est valide, et rien n'est affiché.
const empty = value === '';
const ok = empty || rule.test(value);
field.setCustomValidity(ok ? '' : rule.invalide);
field.classList.toggle('field-invalid', !ok && touched);
field.classList.toggle('field-valid', ok && !empty);
if (!status) return;
if (empty) {
status.textContent = '';
status.className = 'field-format-status';
} else if (ok) {
status.textContent = rule.valide;
status.className = 'field-format-status field-format-ok';
} else if (touched) {
status.textContent = rule.invalide;
status.className = 'field-format-status field-format-error';
} else {
status.textContent = '';
status.className = 'field-format-status';
}
}
bound.set(field, () => {
touched = false;
evaluate();
});
field.addEventListener('input', evaluate);
field.addEventListener('blur', () => {
touched = true;
evaluate();
});
// Envoi refusé par la validation native : le champ fautif s'explique.
field.addEventListener('invalid', () => {
touched = true;
evaluate();
});
// Après un form.reset(), aucun événement de saisie n'est émis : sans
// cela la note du champ resterait celle de l'agent précédent.
field.form?.addEventListener('reset', () => {
window.setTimeout(() => {
touched = false;
evaluate();
}, 0);
});
evaluate();
}
/**
* Branche les champs porteurs de `data-format` et remet à zéro l'état de
* ceux déjà branchés.
*
* Rappelable, et RAPPELÉ : la fiche de modification est remplie par
* programme (module `administration`), ce qui n'émet aucun événement de
* saisie — sans ce rappel, la note afficherait l'état de l'agent précédent.
*/
function init() {
document.querySelectorAll('[data-format]').forEach(attach);
bound.forEach((reset) => reset());
}
PTA.modules.fieldFormat = { init, refresh: init };
})();

View File

@@ -3286,3 +3286,111 @@ textarea:disabled {
.dsi-token-row { flex-direction: column; align-items: flex-start; }
.dsi-pagination { flex-direction: column; }
}
/* ============================================================
V7.25 - Autocomplétion d'adresse (Base Adresse Nationale)
============================================================ */
.address-autocomplete {
position: relative;
display: flex;
flex-direction: column;
gap: 6px;
font-weight: 400;
}
.address-autocomplete textarea,
.address-autocomplete input {
width: 100%;
}
/* La liste flotte au-dessus du formulaire : elle ne doit pas décaler les
champs suivants pendant la frappe. */
.address-suggestions {
position: absolute;
z-index: 30;
top: 100%;
left: 0;
right: 0;
max-height: 16rem;
margin: 4px 0 0;
padding: 4px;
overflow-y: auto;
list-style: none;
border: 1px solid #cfd8dc;
border-radius: 10px;
background: #fff;
box-shadow: 0 10px 24px rgba(23, 32, 51, 0.14);
}
.address-suggestions[hidden] {
display: none;
}
.address-suggestion {
padding: 0.5rem 0.6rem;
border-radius: 8px;
cursor: pointer;
font-weight: 400;
}
.address-suggestion strong,
.address-suggestion small {
display: block;
}
.address-suggestion small {
margin-top: 0.1rem;
color: #6b7280;
font-size: 0.8rem;
}
.address-suggestion:hover,
.address-suggestion.is-active {
background: #eef4ff;
}
.address-autocomplete-status {
margin: 0;
min-height: 1.05rem;
color: #6b7280;
font-size: 0.8rem;
font-weight: 400;
}
.address-status-ok {
color: #1b7f3b;
}
.address-status-warning {
color: #8a5a00;
}
/* ============================================================
V7.26 - Contrôle de format des coordonnées (e-mail, téléphone)
============================================================ */
.field-format-status {
margin: 0;
min-height: 1.05rem;
color: #6b7280;
font-size: 0.8rem;
font-weight: 400;
}
.field-format-ok {
color: #1b7f3b;
}
.field-format-error {
color: #b42318;
}
/* L'erreur n'est marquée qu'après que le champ a été quitté : le module retire
la classe tant que la saisie est en cours. */
input.field-invalid {
border-color: #b42318;
background: #fff7f6;
}
input.field-valid {
border-color: #1b7f3b;
}