Ajout des vérifications des types dans les formulaires (adresses, email, telephone)
This commit is contained in:
78
app/Controllers/AddressController.php
Normal file
78
app/Controllers/AddressController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
85
app/Core/ContactFormat.php
Normal file
85
app/Core/ContactFormat.php
Normal 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));
|
||||
}
|
||||
}
|
||||
178
app/Services/AddressApiService.php
Normal file
178
app/Services/AddressApiService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user