First commit

This commit is contained in:
theShlavuk 2026-01-20 07:54:37 +01:00
parent 54605c1715
commit 412268a186
111 changed files with 34734 additions and 0 deletions

94
ESI_crvi_agenda.php Normal file
View File

@ -0,0 +1,94 @@
<?php
/**
* Plugin Name: ESI_crvi_agenda
* Description: Gestion d'agenda avancée pour CRVI (Custom Post Types, ACF, FullCalendar, API export...)
* Version: 0.1.0
* Author: Votre Nom
* Text Domain: esi_crvi_agenda
*/
declare(strict_types=1);
define('ESI_CRVI_AGENDA_VERSION', '0.1.0');
define('ESI_CRVI_AGENDA_DIR', plugin_dir_path(__FILE__));
define('ESI_CRVI_AGENDA_URL', plugin_dir_url(__FILE__));
if (!defined('ABSPATH')) {
exit;
}
// Autoloader simple pour les classes CRVI et celles avec namespace
spl_autoload_register(function ($class) {
if (strpos($class, 'ESI_CRVI_AGENDA\\') === 0 || strpos($class, 'CRVI_') === 0) {
// Gestion des classes avec namespace ESI_CRVI_AGENDA
if (strpos($class, 'ESI_CRVI_AGENDA') === 0) {
$base_dir = __DIR__ . '/app/';
$relative_class = substr($class, strlen('ESI_CRVI_AGENDA\\'));
// Mapping simple pour les classes CRVI_
if (strpos($relative_class, 'controllers\\CRVI_') === 0) {
$class_name = substr($relative_class, strlen('controllers\\CRVI_'));
// Pour CRVI_Event_Controller -> Event_Controller.php
$file = $base_dir . 'controllers/' . $class_name . '.php';
if (file_exists($file)) {
require_once $file;
return;
}
}
if(strpos($relative_class, 'views\\CRVI_') === 0) {
$class_name = substr($relative_class, strlen('views\\CRVI_'));
$file = $base_dir . 'views/' . $class_name . '.php';
if (file_exists($file)) {
require_once $file;
return;
}
}
if(strpos($relative_class, 'models\\CRVI_') === 0) {
$class_name = substr($relative_class, strlen('models\\CRVI_'));
$file = $base_dir . 'models/' . $class_name . '.php';
if (file_exists($file)) {
require_once $file;
return;
}
}
// Fallback : essayer le mapping standard
$file = $base_dir . str_replace('\\', '/', $relative_class) . '.php';
if (file_exists($file)) {
require_once $file;
return;
}
}
}
});
// Import de la classe principale du plugin
use ESI_CRVI_AGENDA\controllers\CRVI_Plugin;
// Hooks d'activation/désactivation
register_activation_hook(__FILE__, ['CRVI_Plugin', 'activate']);
register_deactivation_hook(__FILE__, ['CRVI_Plugin', 'deactivate']);
// Initialisation du plugin
/* add_action('init', function () {
if (class_exists('ESI_CRVI_AGENDA\controllers\CRVI_Plugin')) {
CRVI_Plugin::init();
}
}); */
$plugin = new CRVI_Plugin();
$plugin->load_actions();
$plugin->load_filters();
$plugin->load_shortcodes();
$plugin->load_frontend_assets();
$plugin->load_admin_assets();

15
README_old.md Normal file
View File

@ -0,0 +1,15 @@
# CRVI Agenda
Plugin WordPress pour la gestion d'agenda (CPT, ACF, FullCalendar, API export).
## Structure
- app/controllers
- app/models
- app/libraries
- templates/front
- templates/email
- assets
- vendor
## Installation
Copiez le dossier dans `wp-content/plugins` et activez-le depuis l'admin WordPress.

27
app/config.php Normal file
View File

@ -0,0 +1,27 @@
<?php
// Configuration du plugin CRVI Agenda
// Configuration SMS OVH
// Ces valeurs peuvent être surchargées par des constantes définies ailleurs
if (!defined('OVH_SMS_ACCOUNT')) {
define('OVH_SMS_ACCOUNT', 'sms-xxxxxxx'); // Identifiant SMS OVH (à configurer)
}
if (!defined('OVH_SMS_FROM')) {
define('OVH_SMS_FROM', '0032496390437'); // Numéro expéditeur (à configurer)
}
// Note: login et mot_de_passe sont configurés dans ACF > Options > Option sms
// Anciennes variables (conservées pour compatibilité)
$sms_api_url = 'https://api.sms.com/v1/sms';
$sms_api_key = 'your_api_key';
$sms_api_secret = 'your_api_secret';
$sms_api_sender = 'your_sender';
$sms_api_sender_name = 'your_sender_name';
$sms_api_sender_email = 'your_sender_email';
$sms_api_sender_phone = 'your_sender_phone';
$sms_api_sender_password = 'your_sender_password';
$sms_api_sender_username = 'your_sender_username';
// Activer le bouton de debug SMS dans la modal d'édition
$debug_sms = false;

View File

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace ESI_CRVI_AGENDA\controllers;
use ESI_CRVI_AGENDA\models\Event_Model;
use ESI_CRVI_AGENDA\helpers\Api_Helper;
use ESI_CRVI_AGENDA\models\CRVI_Event_Model;
class CRVI_Agenda_Controller {
public static function register_routes() {
\register_rest_route('crvi/v1', '/agenda/disponibilites', [
'methods' => 'GET',
'callback' => [self::class, 'get_disponibilites'],
'permission_callback' => '__return_true',
]);
\register_rest_route('crvi/v1', '/events/(?P<id>\\d+)/conflits', [
[
'methods' => 'GET',
'callback' => [self::class, 'conflits_item'],
'permission_callback' => [self::class, 'can_edit'],
],
]);
\register_rest_route('crvi/v1', '/events/export', [
[
'methods' => 'GET',
'callback' => [self::class, 'export_items'],
'permission_callback' => [self::class, 'can_edit'],
],
]);
}
/**
* Endpoint global pour récupérer les entités disponibles à une date/créneau donné.
* @param \WP_REST_Request $request
* @return \WP_REST_Response
*/
public static function get_disponibilites($request) {
$date = $request->get_param('date');
$langue = $request->get_param('langue');
$departement = $request->get_param('departement');
$specialisation = $request->get_param('specialisation');
// Appel aux méthodes de chaque controller (à implémenter)
$traducteurs = \ESI_CRVI_AGENDA\controllers\CRVI_Traducteur_Controller::filtrer_disponibles($date, $langue);
$intervenants = \ESI_CRVI_AGENDA\controllers\CRVI_Intervenant_Controller::filtrer_disponibles($date, $departement, $specialisation);
$locaux = \ESI_CRVI_AGENDA\controllers\CRVI_Local_Controller::filtrer_disponibles($date);
return new \WP_REST_Response([
'traducteurs' => $traducteurs,
'intervenants' => $intervenants,
'locaux' => $locaux,
]);
}
public static function conflits_item($request) {
$id = (int) $request['id'];
$model = new Event_Model();
$result = $model->get_conflits($id);
return Api_Helper::json_success($result);
}
public static function export_items($request) {
$model = new Event_Model();
$result = $model->export_events($request->get_params());
return Api_Helper::json_success($result);
}
}

View File

@ -0,0 +1,338 @@
<?php
declare(strict_types=1);
namespace ESI_CRVI_AGENDA\controllers;
use ESI_CRVI_AGENDA\models\CRVI_Beneficiaire_Model;
use ESI_CRVI_AGENDA\helpers\Api_Helper;
class CRVI_Beneficiaire_Controller {
public static function register_cpt() {
\register_post_type('beneficiaire', [
'label' => 'Bénéficiaires',
'labels' => [
'name' => 'Bénéficiaires',
'singular_name' => 'Bénéficiaire',
'add_new' => 'Ajouter un bénéficiaire',
'add_new_item' => 'Ajouter un nouveau bénéficiaire',
'edit_item' => 'Modifier le bénéficiaire',
'new_item' => 'Nouveau bénéficiaire',
'view_item' => 'Voir le bénéficiaire',
'search_items' => 'Rechercher un bénéficiaire',
'not_found' => 'Aucun bénéficiaire trouvé',
'not_found_in_trash' => 'Aucun bénéficiaire dans la corbeille',
],
'public' => true,
'show_in_menu' => true,
'menu_position' => 21,
'menu_icon' => 'dashicons-groups',
'supports' => ['title'],
'has_archive' => false,
'show_in_rest' => true,
]);
// Déclaration de la taxonomie 'langue' (si pas déjà déclarée)
self::register_taxonomy();
// Association de la taxonomie 'langue' au CPT 'beneficiaire'
\register_taxonomy_for_object_type('langue', 'beneficiaire');
}
public static function register_taxonomy() {
\register_taxonomy('langue_beneficiaire', ['beneficiaire'], [
'label' => 'Langues',
'labels' => [
'name' => 'Langues',
'singular_name' => 'Langue',
'search_items' => 'Rechercher des langues',
'all_items' => 'Toutes les langues',
'edit_item' => 'Modifier la langue',
'update_item' => 'Mettre à jour la langue',
'add_new_item' => 'Ajouter une nouvelle langue',
'new_item_name' => 'Nom de la nouvelle langue',
'menu_name' => 'Langues',
],
'public' => true,
'show_ui' => true,
'show_in_menu' => true,
'show_in_nav_menus' => true,
'show_tagcloud' => false,
'show_in_quick_edit' => true,
'show_admin_column' => true,
'hierarchical' => false, // comme une étiquette
'show_in_rest' => true,
'rewrite' => [ 'slug' => 'langue' ],
]);
}
public static function register_routes() {
register_rest_route('crvi/v1', '/beneficiaires', [
[
'methods' => 'GET',
'callback' => [self::class, 'get_items'],
'permission_callback' => '__return_true',
],
[
'methods' => 'POST',
'callback' => [self::class, 'create_item'],
'permission_callback' => [self::class, 'can_edit'],
],
]);
register_rest_route('crvi/v1', '/beneficiaires/(?P<id>\\d+)', [
[
'methods' => 'GET',
'callback' => [self::class, 'get_item'],
'permission_callback' => '__return_true',
],
[
'methods' => 'PUT,PATCH',
'callback' => [self::class, 'update_item'],
'permission_callback' => [self::class, 'can_edit'],
],
[
'methods' => 'DELETE',
'callback' => [self::class, 'delete_item'],
'permission_callback' => [self::class, 'can_delete'],
],
]);
// Endpoint pour récupérer les absences d'un bénéficiaire
register_rest_route('crvi/v1', '/beneficiaires/(?P<id>\\d+)/absences', [
[
'methods' => 'GET',
'callback' => [self::class, 'get_absences'],
'permission_callback' => '__return_true',
],
]);
// Endpoint pour mettre à jour le statut liste rouge d'un bénéficiaire
register_rest_route('crvi/v1', '/beneficiaires/(?P<id>\\d+)/liste-rouge', [
[
'methods' => 'PUT,PATCH',
'callback' => [self::class, 'update_liste_rouge'],
'permission_callback' => [self::class, 'can_edit'],
],
]);
}
public static function get_items($request) {
$model = new CRVI_Beneficiaire_Model();
$items = $model->get_all_beneficiaires($request->get_params());
return Api_Helper::json_success($items);
}
public static function get_item($request) {
$id = (int) $request['id'];
$item = CRVI_Beneficiaire_Model::load($id);
if (!$item) {
return Api_Helper::json_error('Bénéficiaire introuvable', 404);
}
// Convertir l'objet en tableau pour pouvoir ajouter des champs
$data = (array) $item;
// Ajouter le champ ACF personne_en_liste_rouge
if (function_exists('get_field')) {
$personne_en_liste_rouge = get_field('field_69495826ac495', $id);
$data['personne_en_liste_rouge'] = $personne_en_liste_rouge;
}
return Api_Helper::json_success($data);
}
public static function create_item($request) {
$data = $request->get_json_params();
$validation = self::validate_beneficiaire_data($data);
if (is_wp_error($validation)) {
return Api_Helper::json_error($validation->get_error_message(), 400);
}
$model = new CRVI_Beneficiaire_Model();
$result = $model->create($data);
if (is_wp_error($result)) {
return $result;
}
return Api_Helper::json_success([
'id' => $result,
'nom' => $data['nom'],
'prenom' => $data['prenom'] ?? '',
'message' => 'Bénéficiaire créé avec succès'
]);
}
public static function update_item($request) {
$id = (int) $request['id'];
$model = new CRVI_Beneficiaire_Model();
$result = $model->update($id, $request->get_json_params());
if (is_wp_error($result)) {
return $result;
}
}
public static function delete_item($request) {
$id = (int) $request['id'];
$model = new CRVI_Beneficiaire_Model();
$result = $model->delete($id);
if (is_wp_error($result)) {
return $result;
}
return Api_Helper::json_success(['id' => $id, 'deleted' => true]);
}
/**
* Récupère les absences d'un bénéficiaire
* @param \WP_REST_Request $request
* @return \WP_REST_Response
*/
public static function get_absences($request) {
$id = (int) $request['id'];
if (!$id) {
return Api_Helper::json_error('ID du bénéficiaire requis', 400);
}
$model = new CRVI_Beneficiaire_Model();
$absences = $model->get_absences($id);
if (is_wp_error($absences)) {
return $absences;
}
return Api_Helper::json_success($absences);
}
/**
* Met à jour le statut liste rouge d'un bénéficiaire
* @param \WP_REST_Request $request
* @return \WP_REST_Response
*/
public static function update_liste_rouge($request) {
$id = (int) $request['id'];
if (!$id) {
return Api_Helper::json_error('ID du bénéficiaire requis', 400);
}
// Vérifier que le bénéficiaire existe
$beneficiaire = get_post($id);
if (!$beneficiaire || $beneficiaire->post_type !== 'beneficiaire') {
return Api_Helper::json_error('Bénéficiaire introuvable', 404);
}
$data = $request->get_json_params();
$is_liste_rouge = isset($data['liste_rouge']) ? (bool) $data['liste_rouge'] : false;
// Mettre à jour le champ ACF personne_en_liste_rouge
if (!function_exists('update_field')) {
return Api_Helper::json_error('ACF n\'est pas disponible', 500);
}
// Le champ checkbox ACF retourne un tableau avec "oui" si coché
$field_value = $is_liste_rouge ? ['oui'] : [];
$result = update_field('field_69495826ac495', $field_value, $id);
if ($result === false) {
return Api_Helper::json_error('Erreur lors de la mise à jour du statut liste rouge', 500);
}
return Api_Helper::json_success([
'id' => $id,
'liste_rouge' => $is_liste_rouge,
'message' => $is_liste_rouge ? 'Bénéficiaire ajouté à la liste rouge' : 'Bénéficiaire retiré de la liste rouge'
]);
}
// Permissions personnalisées
public static function can_edit() {
return current_user_can('edit_posts');
}
public static function can_delete() {
return current_user_can('delete_posts');
}
public static function import_csv() {
if (!current_user_can('manage_options')) {
wp_die('Non autorisé');
}
if (empty($_FILES['import_csv']['tmp_name'])) {
wp_redirect(admin_url('admin.php?page=crvi_agenda&import=error&msg=Fichier manquant'));
exit;
}
$file = $_FILES['import_csv']['tmp_name'];
$handle = fopen($file, 'r');
if (!$handle) {
wp_redirect(admin_url('admin.php?page=crvi_agenda&import=error&msg=Impossible d\'ouvrir le fichier'));
exit;
}
$header = fgetcsv($handle, 0, ',');
$created = $updated = $errors = 0;
while (($row = fgetcsv($handle, 0, ',')) !== false) {
$data = array_combine($header, $row);
$result = CRVI_Beneficiaire_Model::create($data, true);
if ($result === 'created') $created++;
elseif ($result === 'updated') $updated++;
else $errors++;
}
fclose($handle);
$msg = "Créés: $created, Modifiés: $updated, Erreurs: $errors";
wp_redirect(admin_url('admin.php?page=crvi_agenda&import=success&msg=' . urlencode($msg)));
exit;
}
// Handler pour l'import CSV via formulaire admin
public static function import_csv_admin() {
if (!current_user_can('edit_posts')) {
wp_die('Non autorisé');
}
check_admin_referer('crvi_import_beneficiaire');
if (empty($_FILES['import_csv']['tmp_name'])) {
wp_redirect(admin_url('admin.php?page=crvi_agenda&import=error&msg=Fichier manquant'));
exit;
}
$file = $_FILES['import_csv']['tmp_name'];
$handle = fopen($file, 'r');
if (!$handle) {
wp_redirect(admin_url('admin.php?page=crvi_agenda&import=error&msg=Impossible d\'ouvrir le fichier'));
exit;
}
$header = fgetcsv($handle, 0, ',');
$created = $updated = $errors = 0;
while (($row = fgetcsv($handle, 0, ',')) !== false) {
$data = array_combine($header, $row);
//sanitize data
$data = array_combine(
array_map(function($k) { return sanitize_title($k); }, array_keys($data)),
array_map('trim', array_values($data))
);
$result = CRVI_Beneficiaire_Model::create($data, false);
if ($result === 'created') $created++;
elseif ($result === 'updated') $updated++;
else $errors++;
}
fclose($handle);
$msg = "Créés: $created, Modifiés: $updated, Erreurs: $errors";
wp_redirect(admin_url('admin.php?page=crvi_agenda&import=success&msg=' . urlencode($msg)));
exit;
}
/**
* Validation des données de bénéficiaire
*/
public static function validate_beneficiaire_data($data) {
if (empty($data['nom'])) {
return new \WP_Error('missing_nom', 'Le nom est obligatoire');
}
if (!empty($data['email']) && !is_email($data['email'])) {
return new \WP_Error('invalid_email', 'L\'email n\'est pas valide');
}
return true;
}
}

View File

@ -0,0 +1,103 @@
<?php
namespace ESI_CRVI_AGENDA\controllers;
use ESI_CRVI_AGENDA\models\CRVI_Departement_Model;
class CRVI_Departement_Controller {
public static function register_cpt() {
register_post_type('departement', [
'label' => 'Départements',
'labels' => [
'name' => 'Départements',
'singular_name' => 'Département',
'add_new' => 'Ajouter un département',
'add_new_item' => 'Ajouter un nouveau département',
'edit_item' => 'Modifier le département',
'new_item' => 'Nouveau département',
'view_item' => 'Voir le département',
'search_items' => 'Rechercher un département',
'not_found' => 'Aucun département trouvé',
'not_found_in_trash' => 'Aucun département dans la corbeille',
],
'public' => false,
'show_ui' => true,
'show_in_menu' => true,
'menu_position' => 25,
'supports' => ['title'],
'capability_type' => 'post',
]);
}
public static function register_routes() {
register_rest_route('crvi_agenda/v1', '/departements', [
'methods' => 'GET',
'callback' => [self::class, 'get_items'],
'permission_callback' => [self::class, 'can_edit'],
]);
register_rest_route('crvi_agenda/v1', '/departements/(?P<id>\d+)', [
'methods' => 'GET',
'callback' => [self::class, 'get_item'],
'permission_callback' => [self::class, 'can_edit'],
]);
register_rest_route('crvi_agenda/v1', '/departements', [
'methods' => 'POST',
'callback' => [self::class, 'create_item'],
'permission_callback' => [self::class, 'can_edit'],
]);
register_rest_route('crvi_agenda/v1', '/departements/(?P<id>\d+)', [
'methods' => 'PUT',
'callback' => [self::class, 'update_item'],
'permission_callback' => [self::class, 'can_edit'],
]);
register_rest_route('crvi_agenda/v1', '/departements/(?P<id>\d+)', [
'methods' => 'DELETE',
'callback' => [self::class, 'delete_item'],
'permission_callback' => [self::class, 'can_delete'],
]);
}
//import csv
public static function import_csv_admin() {
if (!current_user_can('manage_options')) {
wp_die('Non autorisé');
}
if (empty($_FILES['import_csv']['tmp_name'])) {
wp_redirect(admin_url('admin.php?page=crvi_agenda&import=error&msg=Fichier manquant'));
exit;
}
$file = $_FILES['import_csv']['tmp_name'];
$handle = fopen($file, 'r');
if (!$handle) {
wp_redirect(admin_url('admin.php?page=crvi_agenda&import=error&msg=Impossible d\'ouvrir le fichier'));
exit;
}
$header = fgetcsv($handle, 0, ',');
$created = $updated = $errors = 0;
while (($row = fgetcsv($handle, 0, ',')) !== false) {
if (count($row) !== count($header)) continue;
$data = array_combine($header, $row);
$data = array_combine(
array_map(function($k) { return sanitize_title($k); }, array_keys($data)),
array_map('trim', array_values($data))
);
$result = CRVI_Departement_Model::create($data, false);
if ($result === 'created') $created++;
elseif ($result === 'updated') $updated++;
else $errors++;
}
fclose($handle);
$msg = "Créés: $created, Modifiés: $updated, Erreurs: $errors";
wp_redirect(admin_url('admin.php?page=crvi_agenda&import=success&msg=' . urlencode($msg)));
exit;
}
}

View File

@ -0,0 +1,361 @@
<?php
declare(strict_types=1);
namespace ESI_CRVI_AGENDA\controllers;
use ESI_CRVI_AGENDA\helpers\Api_Helper;
use ESI_CRVI_AGENDA\controllers\CRVI_Local_Controller;
use ESI_CRVI_AGENDA\controllers\CRVI_Traducteur_Controller;
use ESI_CRVI_AGENDA\controllers\CRVI_Intervenant_Controller;
use ESI_CRVI_AGENDA\controllers\CRVI_Beneficiaire_Controller;
class CRVI_Entity_Controller {
public static function register_routes() {
// Endpoints pour la création de bénéficiaires
\register_rest_route('crvi/v1', '/agenda/beneficiaires', [
[
'methods' => 'POST',
'callback' => [CRVI_Beneficiaire_Controller::class, 'create_item'],
'permission_callback' => [self::class, 'can_create_entity'],
],
]);
// Endpoints pour la création d'intervenants
\register_rest_route('crvi/v1', '/agenda/intervenants', [
[
'methods' => 'POST',
'callback' => [CRVI_Intervenant_Controller::class, 'create_item'],
'permission_callback' => [self::class, 'can_create_entity'],
],
]);
// Endpoints pour la création de traducteurs
\register_rest_route('crvi/v1', '/agenda/traducteurs', [
[
'methods' => 'POST',
'callback' => [CRVI_Traducteur_Controller::class, 'create_item'],
'permission_callback' => [self::class, 'can_create_entity'],
],
]);
// Endpoints pour la création de locaux
\register_rest_route('crvi/v1', '/agenda/locaux', [
[
'methods' => 'GET',
'callback' => [CRVI_Local_Controller::class, 'get_items'],
'permission_callback' => '__return_true',
],
[
'methods' => 'POST',
'callback' => [CRVI_Local_Controller::class, 'create'],
'permission_callback' => '__return_true', // Temporairement désactivé pour les tests
],
]);
// Route de test pour diagnostiquer l'authentification
\register_rest_route('crvi/v1', '/agenda/test-auth', [
[
'methods' => 'GET',
'callback' => [self::class, 'test_auth'],
'permission_callback' => '__return_true',
],
]);
// Route pour obtenir un nonce d'authentification
\register_rest_route('crvi/v1', '/agenda/get-nonce', [
[
'methods' => 'GET',
'callback' => [self::class, 'get_nonce'],
'permission_callback' => '__return_true',
],
]);
}
/**
* Vérifie si l'utilisateur peut créer des entités
* Seuls les administrateurs et opérateurs peuvent créer des entités
*/
public static function can_create_entity($request = null) {
// Log pour débogage
error_log('CRVI Auth - Checking permissions for user: ' . (is_user_logged_in() ? get_current_user_id() : 'not logged in'));
// Vérifier d'abord l'authentification par nonce
$nonce = $request->get_header('X-WP-Nonce');
if ($nonce && wp_verify_nonce($nonce, 'wp_rest')) {
error_log('CRVI Auth - Valid nonce provided');
// Nonce valide, vérifier les permissions
if (current_user_can('manage_options') || current_user_can('edit_posts')) {
error_log('CRVI Auth - User has required permissions');
return true;
} else {
error_log('CRVI Auth - User lacks required permissions');
return new \WP_Error('insufficient_permissions', 'Permissions insuffisantes. Seuls les administrateurs et opérateurs peuvent créer des entités.', ['status' => 403]);
}
}
// Fallback : vérifier l'authentification normale
if (!is_user_logged_in()) {
error_log('CRVI Auth - User not logged in');
return new \WP_Error('not_logged_in', 'Utilisateur non connecté. Utilisez un nonce valide ou connectez-vous.', ['status' => 401]);
}
$user = wp_get_current_user();
error_log('CRVI Auth - User roles: ' . implode(', ', $user->roles));
// Administrateurs : accès total
if (current_user_can('manage_options')) {
error_log('CRVI Auth - Administrator access granted');
return true;
}
// Opérateurs : peuvent créer des entités
if (current_user_can('edit_posts')) {
error_log('CRVI Auth - Operator access granted');
return true;
}
// Intervenants : lecture seule (pas de création d'entités)
if (current_user_can('read')) {
error_log('CRVI Auth - Intervenant access denied (read-only)');
return new \WP_Error('insufficient_permissions', 'Les intervenants ont un accès en lecture seule.', ['status' => 403]);
}
error_log('CRVI Auth - No valid permissions found');
return new \WP_Error('insufficient_permissions', 'Permissions insuffisantes.', ['status' => 403]);
}
/**
* Test d'authentification pour diagnostiquer les problèmes
*/
public static function test_auth($request) {
$auth_info = [
'is_logged_in' => is_user_logged_in(),
'current_user_id' => get_current_user_id(),
'user_roles' => [],
'permissions' => [
'manage_options' => current_user_can('manage_options'),
'edit_posts' => current_user_can('edit_posts'),
'read' => current_user_can('read'),
]
];
if (is_user_logged_in()) {
$user = wp_get_current_user();
$auth_info['user_roles'] = $user->roles;
$auth_info['user_login'] = $user->user_login;
}
return \ESI_CRVI_AGENDA\helpers\Api_Helper::json_success($auth_info);
}
/**
* Obtient un nonce pour l'authentification REST API
*/
public static function get_nonce($request) {
$nonce = wp_create_nonce('wp_rest');
return \ESI_CRVI_AGENDA\helpers\Api_Helper::json_success([
'nonce' => $nonce,
'instructions' => 'Utilisez ce nonce dans l\'en-tête X-WP-Nonce pour les requêtes POST'
]);
}
/**
* Crée un nouveau bénéficiaire
*/
/* public static function create_beneficiaire($request) {
$data = $request->get_json_params();
// Validation des données
$validation = CRVI_Beneficiaire_Controller::validate_beneficiaire_data($data);
if (is_wp_error($validation)) {
return Api_Helper::json_error($validation->get_error_message(), 400);
}
// Création du post bénéficiaire
$post_data = [
'post_title' => $data['nom'] . ' ' . ($data['prenom'] ?? ''),
'post_type' => 'beneficiaire',
'post_status' => 'publish',
'meta_input' => [
'nom' => sanitize_text_field($data['nom']),
'prenom' => sanitize_text_field($data['prenom'] ?? ''),
'email' => sanitize_email($data['email'] ?? ''),
'telephone' => sanitize_text_field($data['telephone'] ?? ''),
]
];
$post_id = wp_insert_post($post_data);
if (is_wp_error($post_id)) {
return Api_Helper::json_error('Erreur lors de la création du bénéficiaire', 500);
}
return Api_Helper::json_success([
'id' => $post_id,
'nom' => $data['nom'],
'prenom' => $data['prenom'] ?? '',
'message' => 'Bénéficiaire créé avec succès'
]);
} */
/**
* Crée un nouvel intervenant
*/
/* public static function create_intervenant($request) {
$data = $request->get_json_params();
// Validation des données
$validation = self::validate_intervenant_data($data);
if (is_wp_error($validation)) {
return Api_Helper::json_error($validation->get_error_message(), 400);
}
// Création du post intervenant
$post_data = [
'post_title' => $data['nom'] . ' ' . ($data['prenom'] ?? ''),
'post_type' => 'intervenant',
'post_status' => 'publish',
'meta_input' => [
'nom' => sanitize_text_field($data['nom']),
'prenom' => sanitize_text_field($data['prenom'] ?? ''),
'email' => sanitize_email($data['email'] ?? ''),
'telephone' => sanitize_text_field($data['telephone'] ?? ''),
'specialite' => sanitize_text_field($data['specialite'] ?? ''),
]
];
$post_id = wp_insert_post($post_data);
if (is_wp_error($post_id)) {
return Api_Helper::json_error('Erreur lors de la création de l\'intervenant', 500);
}
return Api_Helper::json_success([
'id' => $post_id,
'nom' => $data['nom'],
'prenom' => $data['prenom'] ?? '',
'message' => 'Intervenant créé avec succès'
]);
} */
/**
* Crée un nouveau traducteur
*/
/* public static function create_traducteur($request) {
$data = $request->get_json_params();
// Validation des données
$validation = self::validate_traducteur_data($data);
if (is_wp_error($validation)) {
return Api_Helper::json_error($validation->get_error_message(), 400);
}
// Création du post traducteur
$post_data = [
'post_title' => $data['nom'] . ' ' . ($data['prenom'] ?? ''),
'post_type' => 'traducteur',
'post_status' => 'publish',
'meta_input' => [
'nom' => sanitize_text_field($data['nom']),
'prenom' => sanitize_text_field($data['prenom'] ?? ''),
'email' => sanitize_email($data['email'] ?? ''),
'telephone' => sanitize_text_field($data['telephone'] ?? ''),
'langues_parlees' => sanitize_text_field($data['langues_parlees'] ?? ''),
]
];
$post_id = wp_insert_post($post_data);
if (is_wp_error($post_id)) {
return Api_Helper::json_error('Erreur lors de la création du traducteur', 500);
}
return Api_Helper::json_success([
'id' => $post_id,
'nom' => $data['nom'],
'prenom' => $data['prenom'] ?? '',
'message' => 'Traducteur créé avec succès'
]);
} */
/**
* Crée un nouveau local
*/
/* public static function create_local($request) {
$data = $request->get_json_params();
// Debug: Log les données reçues
error_log('CRVI Local Create - Data received: ' . json_encode($data));
// Validation des données
$validation = CRVI_Local_Controller::validate_local_data($data);
if (is_wp_error($validation)) {
error_log('CRVI Local Create - Validation error: ' . $validation->get_error_message());
return Api_Helper::json_error($validation->get_error_message(), 400);
}
// Utiliser le modèle pour créer le local
$model = new \ESI_CRVI_AGENDA\models\CRVI_Local_Model();
$result = $model->create($data);
if (is_wp_error($result)) {
error_log('CRVI Local Create - Model error: ' . $result->get_error_message());
return Api_Helper::json_error($result->get_error_message(), $result->get_error_data()['status'] ?? 500);
}
error_log('CRVI Local Create - Success: ' . $result);
return Api_Helper::json_success([
'id' => $result,
'nom' => $data['nom'],
'message' => 'Local créé avec succès'
]);
} */
/**
* Validation des données de bénéficiaire
*/
/* private static function validate_beneficiaire_data($data) {
if (empty($data['nom'])) {
return new \WP_Error('missing_nom', 'Le nom est obligatoire');
}
if (!empty($data['email']) && !is_email($data['email'])) {
return new \WP_Error('invalid_email', 'L\'email n\'est pas valide');
}
return true;
} */
/**
* Validation des données d'intervenant
*/
/* private static function validate_intervenant_data($data) {
if (empty($data['nom'])) {
return new \WP_Error('missing_nom', 'Le nom est obligatoire');
}
if (!empty($data['email']) && !is_email($data['email'])) {
return new \WP_Error('invalid_email', 'L\'email n\'est pas valide');
}
return true;
} */
/**
* Validation des données de traducteur
*/
/* private static function validate_traducteur_data($data) {
if (empty($data['nom'])) {
return new \WP_Error('missing_nom', 'Le nom est obligatoire');
}
if (!empty($data['email']) && !is_email($data['email'])) {
return new \WP_Error('invalid_email', 'L\'email n\'est pas valide');
}
return true;
} */
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,486 @@
<?php
declare(strict_types=1);
namespace ESI_CRVI_AGENDA\controllers;
use ESI_CRVI_AGENDA\models\Event_Model;
use ESI_CRVI_AGENDA\helpers\Api_Helper;
use ESI_CRVI_AGENDA\models\CRVI_Event_Model;
use ESI_CRVI_AGENDA\models\CRVI_Traducteur_Model;
use ESI_CRVI_AGENDA\models\CRVI_Intervenant_Model;
use ESI_CRVI_AGENDA\controllers\Intervenant_Controller;
class CRVI_Event_Controller {
public static function register_routes() {
register_rest_route('crvi/v1', '/agenda/permissions', [
[
'methods' => 'GET',
'callback' => [self::class, 'get_user_permissions'],
'permission_callback' => '__return_true',
],
]);
register_rest_route('crvi/v1', '/agenda/disponibilites', [
[
'methods' => 'GET',
'callback' => [self::class, 'get_disponibilites'],
'permission_callback' => '__return_true',
],
[
'methods' => 'POST',
'callback' => [self::class, 'get_disponibilites'],
'permission_callback' => '__return_true',
],
]);
\register_rest_route('crvi/v1', '/events/(?P<id>\\d+)/conflits', [
[
'methods' => 'GET',
'callback' => [self::class, 'conflits_item'],
'permission_callback' => [self::class, 'can_edit'],
],
]);
\register_rest_route('crvi/v1', '/events/export', [
[
'methods' => 'GET',
'callback' => [self::class, 'export_items'],
'permission_callback' => [self::class, 'can_edit'],
],
]);
// --- Filtres dynamiques ---
\register_rest_route('crvi/v1', '/filters/departements', [
'methods' => 'GET',
'callback' => [self::class, 'get_departements'],
'permission_callback' => '__return_true',
]);
\register_rest_route('crvi/v1', '/filters/types-intervention', [
'methods' => 'GET',
'callback' => [self::class, 'get_types_intervention'],
'permission_callback' => '__return_true',
]);
\register_rest_route('crvi/v1', '/filters/langues', [
'methods' => 'GET',
'callback' => [self::class, 'get_langues'],
'permission_callback' => '__return_true',
]);
\register_rest_route('crvi/v1', '/filters/langues-beneficiaire', [
'methods' => 'GET',
'callback' => [self::class, 'get_langues_beneficiaire'],
'permission_callback' => '__return_true',
]);
\register_rest_route('crvi/v1', '/filters/statuts', [
'methods' => 'GET',
'callback' => [self::class, 'get_statuts'],
'permission_callback' => '__return_true',
]);
// --- CRUD Events ---
\register_rest_route('crvi/v1', '/events', [
[
'methods' => 'GET',
'callback' => [self::class, 'get_events'],
'permission_callback' => '__return_true',
],
[
'methods' => 'POST',
'callback' => [self::class, 'create_event'],
'permission_callback' => [self::class, 'can_edit'],
],
]);
\register_rest_route('crvi/v1', '/events/(?P<id>\\d+)', [
[
'methods' => 'GET',
'callback' => [self::class, 'get_event'],
'permission_callback' => '__return_true',
],
[
'methods' => 'PUT,PATCH',
'callback' => [self::class, 'update_event'],
'permission_callback' => [self::class, 'can_edit'],
],
[
'methods' => 'DELETE',
'callback' => [self::class, 'delete_event'],
'permission_callback' => [self::class, 'can_edit'],
],
]);
}
/**
* Endpoint global pour récupérer les entités disponibles à une date/créneau donné.
* @param \WP_REST_Request $request
* @return \WP_REST_Response
*/
public static function get_disponibilites($request) {
// Récupérer les paramètres depuis query string ou JSON body
$params = $request->get_params();
$json_params = $request->get_json_params() ?: [];
// Fusionner les paramètres (JSON body a priorité sur query string)
$all_params = array_merge($params, $json_params);
$date = $all_params['date'] ?? $all_params['date_rdv'] ?? null;
$heure = $all_params['heure'] ?? $all_params['heure_rdv'] ?? null;
$langue = $all_params['langue'] ?? null;
$departement = $all_params['departement'] ?? null;
$specialisation = $all_params['specialisation'] ?? null;
$type = $all_params['type'] ?? null;
$event_id = $all_params['event_id'] ?? null;
// Appel aux méthodes de chaque controller
$traducteurs = \ESI_CRVI_AGENDA\models\CRVI_Traducteur_Model::filtrer_disponibles($date, $langue, $event_id);
$intervenants = \ESI_CRVI_AGENDA\models\CRVI_Intervenant_Model::filtrer_disponibles($date, $departement, $specialisation, $event_id);
// Récupérer les locaux selon le type de RDV
$type_rdv = $all_params['type_rdv'] ?? null;
if ($type_rdv && in_array($type_rdv, ['individuel', 'groupe'])) {
$locaux = \ESI_CRVI_AGENDA\models\CRVI_Local_Model::get_locaux_par_type($type_rdv);
} else {
// Si pas de type spécifié, récupérer tous les locaux
$locals_posts = get_posts([
'post_type' => 'local',
'numberposts' => -1,
'post_status' => 'publish',
]);
$locaux = [];
foreach ($locals_posts as $local_post) {
$type_local = get_post_meta($local_post->ID, 'type_de_local', true);
$capacite = get_post_meta($local_post->ID, 'capacite', true);
// Filtrer par type de RDV si spécifié
if ($type_rdv && in_array($type_rdv, ['individuel', 'groupe'])) {
// Logique de filtrage basée sur le type de local
if ($type_rdv === 'individuel' && $type_local === 'individuel') {
$locaux[] = [
'id' => $local_post->ID,
'nom' => $local_post->post_title,
'type_de_local' => $type_local,
'capacite' => $capacite,
];
} elseif ($type_rdv === 'groupe' && $type_local === 'groupe') {
$locaux[] = [
'id' => $local_post->ID,
'nom' => $local_post->post_title,
'type_de_local' => $type_local,
'capacite' => $capacite,
];
}
} else {
// Si pas de type spécifié, récupérer tous les locaux
$locaux[] = [
'id' => $local_post->ID,
'nom' => $local_post->post_title,
'type_de_local' => $type_local,
'capacite' => $capacite,
];
}
}
}
// Récupérer tous les bénéficiaires
$beneficiaire_model = new \ESI_CRVI_AGENDA\models\CRVI_Beneficiaire_Model();
$beneficiaires_objects = $beneficiaire_model->get_all_beneficiaires();
$beneficiaires = [];
foreach ($beneficiaires_objects as $beneficiaire) {
$beneficiaires[] = [
'id' => $beneficiaire->id,
'nom' => $beneficiaire->nom . ' ' . $beneficiaire->prenom,
];
}
return Api_Helper::json_success([
'traducteurs' => $traducteurs,
'intervenants' => $intervenants,
'locaux' => $locaux,
'beneficiaires' => $beneficiaires,
]);
}
/**
* Endpoint pour récupérer les permissions de l'utilisateur courant.
* @param WP_REST_Request $request
* @return WP_REST_Response
*/
public static function get_user_permissions($request) {
$permissions = Api_Helper::get_user_permissions();
return Api_Helper::json_success($permissions);
}
public static function conflits_item($request) {
$id = (int) $request['id'];
$date = $request->get_param('date');
$heure = $request->get_param('heure');
$id_intervenant = $request->get_param('id_intervenant');
$id_traducteur = $request->get_param('id_traducteur');
$id_local = $request->get_param('id_local');
$model = new CRVI_Event_Model();
$result = $model->get_conflits($date, $heure, $id_intervenant, $id_traducteur, $id_local);
return Api_Helper::json_success($result);
}
public static function export_items($request) {
$model = new CRVI_Event_Model();
$params = $request->get_params();
// Support de la pagination pour l'export
$date_debut = $params['start'] ?? $params['date_debut'] ?? null;
$date_fin = $params['end'] ?? $params['date_fin'] ?? null;
// Filtrer les paramètres pour éviter les conflits
$filters = array_diff_key($params, array_flip(['start', 'end', 'date_debut', 'date_fin']));
$result = $model->get_events_by('date_rdv', null, $filters, $date_debut, $date_fin);
return Api_Helper::json_success($result);
}
// --- CRUD ---
public static function get_events($request) {
$params = $request->get_params();
$model = new CRVI_Event_Model();
$events = $model->get_events_by_filters($params);
return Api_Helper::json_success($events);
}
public static function get_event($request) {
$id = (int) $request['id'];
$event = CRVI_Event_Model::load($id);
if (!$event) {
return Api_Helper::json_error('Événement introuvable', 404);
}
return Api_Helper::json_success($event);
}
public static function create_event($request) {
$data = $request->get_json_params();
$model = new CRVI_Event_Model();
$result = $model->create_event($data);
if (is_wp_error($result)) {
return Api_Helper::json_error($result->get_error_message(), 400);
}
return Api_Helper::json_success(['id' => $result, 'message' => 'Événement créé avec succès']);
}
public static function update_event($request) {
$id = (int) $request['id'];
$data = $request->get_json_params();
$model = new CRVI_Event_Model();
$result = $model->update_event($id, $data);
if (is_wp_error($result)) {
return Api_Helper::json_error($result->get_error_message(), 400);
}
return Api_Helper::json_success(['id' => $id, 'message' => 'Événement modifié avec succès']);
}
public static function delete_event($request) {
$id = (int) $request['id'];
$model = new CRVI_Event_Model();
$result = $model->delete_event($id);
if (is_wp_error($result)) {
return Api_Helper::json_error($result->get_error_message(), 400);
}
return Api_Helper::json_success(['id' => $id, 'message' => 'Événement supprimé avec succès']);
}
// --- Avancés ---
public static function cloture_event($request) {
$id = (int) $request['id'];
$data = $request->get_json_params();
$model = new CRVI_Event_Model();
$result = $model->cloture_event($id, $data['statut'] ?? '');
if (is_wp_error($result)) {
return Api_Helper::json_error($result->get_error_message(), 400);
}
return Api_Helper::json_success(['id' => $id, 'message' => 'Événement clôturé avec succès']);
}
public static function change_statut($request) {
$id = (int) $request['id'];
$data = $request->get_json_params();
$model = new CRVI_Event_Model();
$result = $model->change_statut($id, $data);
if (is_wp_error($result)) {
return Api_Helper::json_error($result->get_error_message(), 400);
}
return Api_Helper::json_success(['id' => $id, 'message' => 'Statut modifié avec succès']);
}
/**
* Retourne la couleur selon le statut
*/
private static function get_status_color($statut) {
switch ($statut) {
case 'prevu':
return '#28a745'; // Vert
case 'annule':
return '#dc3545'; // Rouge
case 'non_tenu':
return '#ffc107'; // Jaune
case 'cloture':
return '#6c757d'; // Gris
case 'absence':
return '#fd7e14'; // Orange
default:
return '#007bff'; // Bleu
}
}
public static function get_events_fullcalendar($request) {
$model = new CRVI_Event_Model();
$params = $request->get_params();
// Support de la pagination FullCalendar
$date_debut = $params['start'] ?? $params['date_debut'] ?? null;
$date_fin = $params['end'] ?? $params['date_fin'] ?? null;
// Filtrer les paramètres pour éviter les conflits
$filters = array_diff_key($params, array_flip(['start', 'end', 'date_debut', 'date_fin']));
// Récupérer les événements pour cette période
$events = $model->get_events_by('date_rdv', null, $filters, $date_debut, $date_fin);
// Formater pour FullCalendar
$formatted_events = [];
foreach ($events as $event) {
// Récupérer les détails des entités liées
$details = $model->get_details($event['id']);
$formatted_events[] = [
'id' => $event['id'],
'title' => ($details->beneficiaire->nom ?? '') . ' ' . ($details->beneficiaire->prenom ?? '') . ' - ' .
($details->intervenant->nom ?? '') . ' ' . ($details->intervenant->prenom ?? ''),
'start' => $event['date_rdv'] . 'T' . $event['heure_rdv'],
'end' => $event['date_fin'] . 'T' . $event['heure_fin'],
'backgroundColor' => self::get_status_color($event['statut']),
'borderColor' => self::get_status_color($event['statut']),
'textColor' => $event['statut'] === 'non_tenu' ? '#000' : '#fff',
'extendedProps' => [
'type' => $event['type'],
'statut' => $event['statut'],
'langue' => $event['langue'],
'beneficiaire' => ($details->beneficiaire->nom ?? '') . ' ' . ($details->beneficiaire->prenom ?? ''),
'intervenant' => ($details->intervenant->nom ?? '') . ' ' . ($details->intervenant->prenom ?? ''),
'traducteur' => ($details->traducteur->nom ?? '') . ' ' . ($details->traducteur->prenom ?? ''),
'local' => $details->local->nom ?? '',
'commentaire' => $event['commentaire'],
'id_beneficiaire' => $event['id_beneficiaire'],
'id_intervenant' => $event['id_intervenant'],
'id_traducteur' => $event['id_traducteur'],
'id_local' => $event['id_local']
]
];
}
return Api_Helper::json_success($formatted_events);
}
public static function get_events_stats($request) {
$params = $request->get_params();
$model = new CRVI_Event_Model();
$stats = $model->get_events_stats($params);
return Api_Helper::json_success($stats);
}
public static function get_event_historique($request) {
$id = (int) $request['id'];
$model = new CRVI_Event_Model();
$historique = $model->get_historique();
return Api_Helper::json_success($historique);
}
// --- Filtres dynamiques ---
public static function get_departements($request) {
$departements = get_terms([
'taxonomy' => 'departement',
'hide_empty' => false,
]);
$result = [];
foreach ($departements as $departement) {
$result[] = [
'id' => $departement->term_id,
'name' => $departement->name,
'slug' => $departement->slug,
];
}
return Api_Helper::json_success($result);
}
public static function get_types_intervention($request) {
$types = get_terms([
'taxonomy' => 'type_intervention',
'hide_empty' => false,
]);
$result = [];
foreach ($types as $type) {
$result[] = [
'id' => $type->term_id,
'name' => $type->name,
'slug' => $type->slug,
];
}
return Api_Helper::json_success($result);
}
public static function get_langues($request) {
$langues = get_terms([
'taxonomy' => 'langue',
'hide_empty' => false,
]);
$result = [];
foreach ($langues as $langue) {
$result[] = [
'id' => $langue->term_id,
'name' => $langue->name,
'slug' => $langue->slug,
];
}
return Api_Helper::json_success($result);
}
public static function get_langues_beneficiaire($request) {
$langues = \ESI_CRVI_AGENDA\helpers\Api_Helper::get_languages(true);
return Api_Helper::json_success($langues);
}
public static function get_statuts($request) {
$statuts = [
['id' => 'prevu', 'label' => 'Prévu'],
['id' => 'annule', 'label' => 'Annulé'],
['id' => 'non_tenu', 'label' => 'Non tenu'],
['id' => 'cloture', 'label' => 'Clôturé'],
['id' => 'absence', 'label' => 'Absence'],
];
return Api_Helper::json_success($statuts);
}
/**
* Vérifie si l'utilisateur peut modifier un événement
* @param \WP_REST_Request $request
* @return bool
*/
public static function can_edit($request = null) {
// Si admin ou rôle ayant edit_posts : accès total
if (current_user_can('edit_posts')) {
return true;
}
// Récupérer l'id_intervenant depuis la requête ou l'événement
$id_intervenant = null;
if ($request) {
// Si on modifie un événement existant, récupérer l'ID depuis l'événement
if ($request->get_param('id')) {
$event = \ESI_CRVI_AGENDA\models\CRVI_Event_Model::load((int)$request->get_param('id'));
if ($event && isset($event->id_intervenant)) {
$id_intervenant = $event->id_intervenant;
}
}
// Sinon, essayer de le prendre dans les paramètres de la requête (création)
if (!$id_intervenant) {
$data = $request->get_json_params();
if ($data && isset($data['id_intervenant'])) {
$id_intervenant = $data['id_intervenant'];
}
}
}
// Appel à la logique intervenant
return CRVI_Intervenant_Controller::can_edit_own_event($id_intervenant);
}
}

View File

@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace ESI_CRVI_AGENDA\controllers;
use ESI_CRVI_AGENDA\models\CRVI_Incident_Model;
use ESI_CRVI_AGENDA\helpers\Api_Helper;
class CRVI_Incident_Controller {
/**
* Enregistre les routes REST pour les incidents
*/
public static function register_routes() {
\register_rest_route('crvi/v1', '/incidents', [
[
'methods' => 'GET',
'callback' => [self::class, 'get_all_incidents'],
'permission_callback' => [self::class, 'can_view'],
],
[
'methods' => 'POST',
'callback' => [self::class, 'create_incident'],
'permission_callback' => [self::class, 'can_view'],
],
]);
\register_rest_route('crvi/v1', '/incidents/(?P<id>\\d+)', [
[
'methods' => 'GET',
'callback' => [self::class, 'get_incident'],
'permission_callback' => [self::class, 'can_view'],
],
]);
\register_rest_route('crvi/v1', '/events/(?P<event_id>\\d+)/incidents', [
[
'methods' => 'GET',
'callback' => [self::class, 'get_incidents_by_event'],
'permission_callback' => [self::class, 'can_view'],
],
]);
}
/**
* Récupérer tous les incidents
* @param \WP_REST_Request $request
* @return \WP_REST_Response|\WP_Error
*/
public static function get_all_incidents($request) {
// Récupérer les paramètres de pagination
$page = $request->get_param('page') ?? 1;
$per_page = $request->get_param('per_page') ?? 20;
$event_id = $request->get_param('event_id') ?? null;
$beneficiaire_id = $request->get_param('beneficiaire_id') ?? null;
// Construire les filtres
$filters = [];
if ($event_id) {
$filters['event_id'] = (int) $event_id;
}
if ($beneficiaire_id) {
$filters['beneficiaire_id'] = (int) $beneficiaire_id;
}
// Récupérer les incidents
$incidents = CRVI_Incident_Model::get_all($filters);
$result = [];
foreach ($incidents as $incident) {
$result[] = [
'id' => $incident->id,
'beneficiaire_id' => $incident->beneficiaire_id,
'event_id' => $incident->event_id,
'resume_incident' => $incident->resume_incident,
'commentaire_incident' => $incident->commentaire_incident,
'created_at' => $incident->created_at ?? null,
];
}
return Api_Helper::json_success($result);
}
/**
* Créer un incident
* @param \WP_REST_Request $request
* @return \WP_REST_Response|\WP_Error
*/
public static function create_incident($request) {
$data = $request->get_json_params();
// Validation des données
if (empty($data['event_id'])) {
return Api_Helper::json_error('L\'ID de l\'événement est requis', 400);
}
if (empty($data['beneficiaire_id'])) {
return Api_Helper::json_error('L\'ID du bénéficiaire est requis', 400);
}
if (empty($data['resume_incident'])) {
return Api_Helper::json_error('Le résumé de l\'incident est requis', 400);
}
// Vérifier que l'événement existe
$event = \ESI_CRVI_AGENDA\models\CRVI_Event_Model::load((int) $data['event_id']);
if (!$event) {
return Api_Helper::json_error('Événement introuvable', 404);
}
// Vérifier que le bénéficiaire existe
$beneficiaire = \ESI_CRVI_AGENDA\models\CRVI_Beneficiaire_Model::load((int) $data['beneficiaire_id']);
if (!$beneficiaire) {
return Api_Helper::json_error('Bénéficiaire introuvable', 404);
}
// Créer l'incident
$result = CRVI_Incident_Model::create($data);
if (is_wp_error($result)) {
return Api_Helper::json_error($result->get_error_message(), $result->get_error_data()['status'] ?? 500);
}
// Charger l'incident créé pour le retourner
$incident = CRVI_Incident_Model::load($result);
return Api_Helper::json_success([
'id' => $incident->id,
'beneficiaire_id' => $incident->beneficiaire_id,
'event_id' => $incident->event_id,
'resume_incident' => $incident->resume_incident,
'commentaire_incident' => $incident->commentaire_incident,
'message' => 'Incident créé avec succès'
]);
}
/**
* Récupérer un incident par ID
* @param \WP_REST_Request $request
* @return \WP_REST_Response|\WP_Error
*/
public static function get_incident($request) {
$id = (int) $request['id'];
$incident = CRVI_Incident_Model::load($id);
if (!$incident) {
return Api_Helper::json_error('Incident introuvable', 404);
}
return Api_Helper::json_success([
'id' => $incident->id,
'beneficiaire_id' => $incident->beneficiaire_id,
'event_id' => $incident->event_id,
'resume_incident' => $incident->resume_incident,
'commentaire_incident' => $incident->commentaire_incident,
]);
}
/**
* Récupérer tous les incidents pour un événement
* @param \WP_REST_Request $request
* @return \WP_REST_Response|\WP_Error
*/
public static function get_incidents_by_event($request) {
$event_id = (int) $request['event_id'];
if (!$event_id) {
return Api_Helper::json_error('ID de l\'événement requis', 400);
}
$incidents = CRVI_Incident_Model::get_by_event($event_id);
$result = [];
foreach ($incidents as $incident) {
$result[] = [
'id' => $incident->id,
'beneficiaire_id' => $incident->beneficiaire_id,
'event_id' => $incident->event_id,
'resume_incident' => $incident->resume_incident,
'commentaire_incident' => $incident->commentaire_incident,
];
}
return Api_Helper::json_success($result);
}
/**
* Vérifie si l'utilisateur peut voir les incidents
* @return bool
*/
public static function can_view() {
return current_user_can('read');
}
}

View File

@ -0,0 +1,466 @@
<?php
declare(strict_types=1);
namespace ESI_CRVI_AGENDA\controllers;
use ESI_CRVI_AGENDA\models\CRVI_Intervenant_Model;
use ESI_CRVI_AGENDA\helpers\Api_Helper;
class CRVI_Intervenant_Controller {
public static function register_cpt() {
\register_post_type('intervenant', [
'label' => 'Intervenants',
'labels' => [
'name' => 'Intervenants',
'singular_name' => 'Intervenant',
'add_new' => 'Ajouter un intervenant',
'add_new_item' => 'Ajouter un nouvel intervenant',
'edit_item' => 'Modifier l\'intervenant',
'new_item' => 'Nouvel intervenant',
'view_item' => 'Voir l\'intervenant',
'search_items' => 'Rechercher un intervenant',
'not_found' => 'Aucun intervenant trouvé',
'not_found_in_trash' => 'Aucun intervenant dans la corbeille',
],
'public' => true,
'show_in_menu' => true,
'menu_position' => 22,
'menu_icon' => 'dashicons-businessman',
'supports' => ['title'],
'has_archive' => false,
'show_in_rest' => true,
]);
self::register_taxonomy();
\register_taxonomy_for_object_type('departement', 'intervenant');
\register_taxonomy_for_object_type('type_intervention', 'intervenant');
}
public static function register_taxonomy() {
\register_taxonomy('departement', 'intervenant', [
'label' => 'Départements',
'labels' => [
'name' => 'Départements',
'singular_name' => 'Département',
],
'show_in_rest' => true,
'hierarchical' => true,
'rewrite' => ['slug' => 'departement'],
'show_admin_column' => true,
'query_var' => true,
'public' => true,
'show_in_menu' => true,
]);
\register_taxonomy('type_intervention', 'intervenant', [
'label' => 'Types d\'intervention',
'labels' => [
'name' => 'Types d\'intervention',
'singular_name' => 'Type d\'intervention',
],
'show_in_rest' => true,
'hierarchical' => true,
'rewrite' => ['slug' => 'type-intervention'],
'show_admin_column' => true,
'query_var' => true,
'public' => true,
'show_in_menu' => true,
]);
}
/**
* Retourne les intervenants disponibles à une date donnée.
* @param string $date au format Y-m-d
* @param string|null $departement (optionnel)
* @param string|null $specialisation (optionnel)
* @return array Liste des intervenants disponibles (WP_Post)
*/
public static function filtrer_disponibles($date, $departement = null, $specialisation = null) {
if (empty($date)) {
return [];
}
$timestamp = strtotime($date);
$jour = strtolower(date('l', $timestamp)); // ex: 'monday'
$jours_fr = [
'monday' => 'lundi',
'tuesday' => 'mardi',
'wednesday' => 'mercredi',
'thursday' => 'jeudi',
'friday' => 'vendredi',
'saturday' => 'samedi',
'sunday' => 'dimanche',
];
$jour_semaine = $jours_fr[$jour] ?? '';
if (!$jour_semaine) return [];
// 1. Récupérer les intervenants (filtrage taxonomie si besoin)
$tax_query = [];
if ($departement) {
$tax_query[] = [
'taxonomy' => 'departement',
'field' => 'slug',
'terms' => $departement,
];
}
if ($specialisation) {
$tax_query[] = [
'taxonomy' => 'type_intervention',
'field' => 'slug',
'terms' => $specialisation,
];
}
$args = [
'post_type' => 'intervenant',
'numberposts' => -1,
];
if (!empty($tax_query)) {
$args['tax_query'] = $tax_query;
}
$intervenants = get_posts($args);
$disponibles = [];
foreach ($intervenants as $intervenant) {
// Vérifier jours de disponibilité
$jours = get_field('jours_de_disponibilite', $intervenant->ID);
if (!is_array($jours) || !in_array($jour_semaine, $jours, true)) {
continue;
}
// Vérifier absences ponctuelles
$absences = get_field('indisponibilitee_ponctuelle', $intervenant->ID);
$est_absent = false;
if (is_array($absences)) {
foreach ($absences as $absence) {
$debut = isset($absence['debut']) ? strtotime($absence['debut']) : null;
$fin = isset($absence['fin']) ? strtotime($absence['fin']) : null;
if ($debut && $fin && $timestamp >= $debut && $timestamp <= $fin) {
$est_absent = true;
break;
}
}
}
if ($est_absent) continue;
$disponibles[] = $intervenant;
}
return $disponibles;
}
public static function register_routes() {
register_rest_route('crvi/v1', '/intervenants', [
[
'methods' => 'GET',
'callback' => [self::class, 'get_items'],
'permission_callback' => '__return_true',
],
[
'methods' => 'POST',
'callback' => [self::class, 'create_item'],
'permission_callback' => [self::class, 'can_edit'],
],
]);
register_rest_route('crvi/v1', '/intervenants/import', [
[
'methods' => 'POST',
'callback' => [self::class, 'import_csv'],
'permission_callback' => [self::class, 'can_edit'],
'args' => [
'file' => [
'required' => true,
'description' => 'Fichier CSV à importer',
'type' => 'file',
],
],
],
]);
register_rest_route('crvi/v1', '/intervenants/(?P<id>\\d+)', [
[
'methods' => 'GET',
'callback' => [self::class, 'get_item'],
'permission_callback' => '__return_true',
],
[
'methods' => 'PUT,PATCH',
'callback' => [self::class, 'update_item'],
'permission_callback' => [self::class, 'can_edit'],
],
[
'methods' => 'DELETE',
'callback' => [self::class, 'delete_item'],
'permission_callback' => [self::class, 'can_delete'],
],
]);
}
public static function get_items($request) {
$items = CRVI_Intervenant_Model::all();
return Api_Helper::json_success($items);
}
public static function get_item($request) {
$id = (int) $request['id'];
$item = CRVI_Intervenant_Model::load($id);
if (!$item) {
return Api_Helper::json_error('Intervenant introuvable', 404);
}
return Api_Helper::json_success($item);
}
public static function create_item($request) {
$data = $request->get_json_params();
$validation = self::validate_intervenant_data($data);
if (is_wp_error($validation)) {
return Api_Helper::json_error($validation->get_error_message(), 400);
}
$model = new CRVI_Intervenant_Model();
$result = $model->create($data);
if (is_wp_error($result)) {
return $result;
}
return Api_Helper::json_success([
'id' => $result,
'nom' => $data['nom'],
'prenom' => $data['prenom'] ?? '',
'message' => 'Intervenant créé avec succès'
]);
}
public static function update_item($request) {
$id = (int) $request['id'];
$model = new CRVI_Intervenant_Model();
$result = $model->update($id, $request->get_json_params());
if (is_wp_error($result)) {
return $result;
}
return Api_Helper::json_success(['id' => $id]);
}
public static function delete_item($request) {
$id = (int) $request['id'];
$model = new CRVI_Intervenant_Model();
$result = $model->delete($id);
if (is_wp_error($result)) {
return $result;
}
return Api_Helper::json_success(['id' => $id, 'deleted' => true]);
}
/**
* Import CSV d'intervenants via REST (aligné sur Traducteur_Controller)
* @param WP_REST_Request $request
* @return array
*/
public static function import_csv($request) {
if (!current_user_can('edit_posts')) {
return Api_Helper::json_error('Non autorisé', 403);
}
$file = $request->get_file_params()['file'] ?? null;
if (!$file || !is_uploaded_file($file['tmp_name'])) {
return Api_Helper::json_error('Fichier CSV manquant ou invalide', 400);
}
$handle = fopen($file['tmp_name'], 'r');
if (!$handle) {
return Api_Helper::json_error('Impossible d\'ouvrir le fichier', 400);
}
$header = fgetcsv($handle, 0, ',');
$created = $updated = $errors = 0;
while (($row = fgetcsv($handle, 0, ',')) !== false) {
$data = array_combine($header, $row);
$data = array_combine(
array_map(function($k) { return sanitize_title($k); }, array_keys($data)),
array_map('trim', array_values($data))
);
// Conversion titres -> IDs pour departements_ids et types_intervention_ids
foreach ([
'departements_ids' => 'departement',
'types_intervention_ids' => 'type_intervention',
] as $csv_key => $cpt) {
if (!empty($data[$csv_key])) {
$titles = explode('|', $data[$csv_key]);
$ids = [];
foreach ($titles as $title) {
$title = trim($title);
if (!$title) continue;
$slug = sanitize_title($title);
$posts = get_posts([
'post_type' => $cpt,
'name' => $slug,
'posts_per_page' => 1,
'post_status' => 'publish',
'fields' => 'ids',
]);
if (!empty($posts)) {
$ids[] = $posts[0];
}
}
// Adapter la clé pour le modèle (departements, specialisations)
if ($csv_key === 'departements_ids') {
$data['departements'] = $ids;
} elseif ($csv_key === 'types_intervention_ids') {
$data['specialisations'] = $ids;
}
}
}
$result = CRVI_Intervenant_Model::create($data, false);
if (is_numeric($result)) $created++;
else $errors++;
}
fclose($handle);
$msg = "Créés: $created, Modifiés: $updated, Erreurs: $errors";
return Api_Helper::json_success(['message' => $msg]);
}
// Permissions personnalisées
public static function can_edit() {
$user = wp_get_current_user();
// Intervenant : lecture seule (GET autorisé via __return_true)
if (in_array('intervenant', (array)$user->roles, true)) {
return false;
}
// Admins ou autres rôles ayant edit_posts : accès autorisé
return current_user_can('edit_posts');
}
public static function can_delete() {
return current_user_can('delete_posts');
}
// Handler pour l'import CSV via formulaire admin
public static function import_csv_admin() {
if (!current_user_can('edit_users')) {
wp_die('Non autorisé');
}
check_admin_referer('crvi_import_intervenant');
if (empty($_FILES['import_csv']['tmp_name'])) {
wp_redirect(admin_url('admin.php?page=crvi_agenda&import=error&msg=Fichier manquant'));
exit;
}
$file = $_FILES['import_csv']['tmp_name'];
$handle = fopen($file, 'r');
if (!$handle) {
wp_redirect(admin_url('admin.php?page=crvi_agenda&import=error&msg=Impossible d\'ouvrir le fichier'));
exit;
}
$header = fgetcsv($handle, 0, ',');
$created = $updated = $errors = 0;
while (($row = fgetcsv($handle, 0, ',')) !== false) {
$data = array_combine($header, $row);
$data = array_combine(
array_map(function($k) { return sanitize_title($k); }, array_keys($data)),
array_map('trim', array_values($data))
);
// Conversion titres -> IDs pour departements_ids et types_intervention_ids
foreach ([
'departements_ids' => 'departement',
'types_intervention_ids' => 'type_intervention',
] as $csv_key => $cpt) {
if (!empty($data[$csv_key])) {
$titles = explode('|', $data[$csv_key]);
$ids = [];
foreach ($titles as $title) {
$title = trim($title);
if (!$title) continue;
$slug = sanitize_title($title);
$posts = get_posts([
'post_type' => $cpt,
'name' => $slug,
'posts_per_page' => 1,
'post_status' => 'publish',
'fields' => 'ids',
]);
if (!empty($posts)) {
$ids[] = $posts[0];
} else {
// Fallback LIKE sur le slug
global $wpdb;
$like = '%' . $wpdb->esc_like($slug) . '%';
$sql = $wpdb->prepare(
"SELECT ID FROM $wpdb->posts WHERE post_type = %s AND post_status = 'publish' AND post_name LIKE %s LIMIT 1",
$cpt, $like
);
$like_id = $wpdb->get_var($sql);
if ($like_id) {
$ids[] = (int)$like_id;
}
}
}
if ($csv_key === 'departements_ids') {
$data['departements'] = $ids;
} elseif ($csv_key === 'types_intervention_ids') {
$data['specialisations'] = $ids;
}
}
}
$result = CRVI_Intervenant_Model::create($data, false);
if (is_numeric($result)) $created++;
else $errors++;
}
fclose($handle);
$msg = "Créés: $created, Modifiés: $updated, Erreurs: $errors";
wp_redirect(admin_url('admin.php?page=crvi_agenda&import=success&msg=' . urlencode($msg)));
exit;
}
/**
* Parse un champ répéteur au format pipe/point-virgule/deux-points.
* @param string $value
* @return array
*/
function parse_repeater_field(string $value): array {
if (empty($value)) return [];
$items = explode('|', $value);
$result = [];
foreach ($items as $item) {
$fields = explode(';', $item);
$assoc = [];
foreach ($fields as $field) {
[$key, $val] = array_pad(explode(':', $field, 2), 2, null);
if ($key !== null) {
$assoc[trim($key)] = trim($val ?? '');
}
}
if ($assoc) $result[] = $assoc;
}
return $result;
}
/**
* Permet à un intervenant de modifier uniquement ses propres événements (liés à son intervenant_id).
* @param int|null $event_intervenant_id L'ID de l'intervenant dans l'événement
* @return bool
*/
public static function can_edit_own_event($event_intervenant_id = null) {
$user = wp_get_current_user();
// Admins ou rôles ayant edit_posts : accès total
if (current_user_can('edit_posts')) {
return true;
}
// Intervenant : peut modifier uniquement ses propres événements
if (in_array('intervenant', (array)$user->roles, true)) {
// L'ID de l'intervenant dans l'événement doit correspondre à l'ID de l'utilisateur connecté
if ($event_intervenant_id && (int)$event_intervenant_id === (int)$user->ID) {
return true;
}
return false;
}
return false;
}
public static function validate_intervenant_data($data) {
if (empty($data['nom'])) {
return new \WP_Error('missing_nom', 'Le nom est obligatoire');
}
if (!empty($data['email']) && !is_email($data['email'])) {
return new \WP_Error('invalid_email', 'L\'email n\'est pas valide');
}
return true;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,358 @@
<?php
declare(strict_types=1);
namespace ESI_CRVI_AGENDA\controllers;
use ESI_CRVI_AGENDA\models\CRVI_Local_Model;
use ESI_CRVI_AGENDA\helpers\Api_Helper;
class CRVI_Local_Controller {
public static function register_cpt() {
register_post_type('local', [
'label' => 'Locaux',
'labels' => [
'name' => 'Locaux',
'singular_name' => 'Local',
'add_new' => 'Ajouter un local',
'add_new_item' => 'Ajouter un nouveau local',
'edit_item' => 'Modifier le local',
'new_item' => 'Nouveau local',
'view_item' => 'Voir le local',
'search_items' => 'Rechercher un local',
'not_found' => 'Aucun local trouvé',
'not_found_in_trash' => 'Aucun local dans la corbeille',
],
'public' => true,
'show_in_menu' => true,
'menu_position' => 23,
'menu_icon' => 'dashicons-building',
'supports' => ['title'],
'has_archive' => false,
'show_in_rest' => true,
]);
}
public static function register_routes() {
register_rest_route('crvi/v1', '/locaux', [
[
'methods' => 'GET',
'callback' => [self::class, 'get_items'],
'permission_callback' => '__return_true',
],
[
'methods' => 'POST',
'callback' => [self::class, 'create_item'],
'permission_callback' => [self::class, 'can_edit'],
],
]);
register_rest_route('crvi/v1', '/locaux/disponibles', [
[
'methods' => 'GET',
'callback' => [self::class, 'filtrer_disponibles'],
'permission_callback' => '__return_true',
'args' => [
'date_debut' => [
'required' => false,
'type' => 'string',
'description' => 'Date de début (YYYY-MM-DD)',
],
'date_fin' => [
'required' => false,
'type' => 'string',
'description' => 'Date de fin (YYYY-MM-DD)',
],
'heure_debut' => [
'required' => false,
'type' => 'string',
'description' => 'Heure de début (HH:MM)',
],
'heure_fin' => [
'required' => false,
'type' => 'string',
'description' => 'Heure de fin (HH:MM)',
],
],
],
]);
register_rest_route('crvi/v1', '/locaux/import', [
[
'methods' => 'POST',
'callback' => [self::class, 'import_csv'],
'permission_callback' => [self::class, 'can_edit'],
'args' => [
'file' => [
'required' => true,
'description' => 'Fichier CSV à importer',
'type' => 'file',
],
],
],
]);
register_rest_route('crvi/v1', '/locaux/(?P<id>\\d+)', [
[
'methods' => 'GET',
'callback' => [self::class, 'get_item'],
'permission_callback' => '__return_true',
],
[
'methods' => 'PUT,PATCH',
'callback' => [self::class, 'update_item'],
'permission_callback' => [self::class, 'can_edit'],
],
[
'methods' => 'DELETE',
'callback' => [self::class, 'delete_item'],
'permission_callback' => [self::class, 'can_delete'],
],
]);
}
public static function get_items($request) {
$items = CRVI_Local_Model::all();
return Api_Helper::json_success($items);
}
public static function get_item($request) {
$id = (int) $request['id'];
$item = CRVI_Local_Model::load($id);
if (!$item) {
return Api_Helper::json_error('Local introuvable', 404);
}
return Api_Helper::json_success($item);
}
/**
* Filtre les locaux disponibles selon les critères fournis
* @param WP_REST_Request $request
* @return WP_REST_Response
*/
public static function filtrer_disponibles($request) {
$date_debut = $request->get_param('date_debut');
$date_fin = $request->get_param('date_fin');
$heure_debut = $request->get_param('heure_debut');
$heure_fin = $request->get_param('heure_fin');
// Récupérer tous les locaux
$tous_locaux = CRVI_Local_Model::all();
// Si aucun critère de temps n'est fourni, retourner tous les locaux
if (empty($date_debut) && empty($date_fin) && empty($heure_debut) && empty($heure_fin)) {
return Api_Helper::json_success($tous_locaux);
}
// Filtrer les locaux disponibles selon les critères
$locaux_disponibles = [];
foreach ($tous_locaux as $local) {
$disponible = true;
// Vérifier la disponibilité selon les critères fournis
if ($date_debut && $date_fin) {
// Vérifier s'il y a des conflits d'événements dans cette période
$conflits = self::verifier_conflits_local($local['id'], $date_debut, $date_fin, $heure_debut, $heure_fin);
if (!empty($conflits)) {
$disponible = false;
}
}
if ($disponible) {
$locaux_disponibles[] = $local;
}
}
return Api_Helper::json_success($locaux_disponibles);
}
/**
* Vérifie s'il y a des conflits d'événements pour un local donné
* @param int $local_id
* @param string $date_debut
* @param string $date_fin
* @param string $heure_debut
* @param string $heure_fin
* @return array
*/
private static function verifier_conflits_local($local_id, $date_debut, $date_fin, $heure_debut = null, $heure_fin = null) {
global $wpdb;
$table_events = $wpdb->prefix . 'crvi_agenda';
$where_conditions = ['local_id = %d'];
$where_values = [$local_id];
// Ajouter les conditions de date
if ($date_debut && $date_fin) {
$where_conditions[] = '(
(date_rdv BETWEEN %s AND %s) OR
(date_fin BETWEEN %s AND %s) OR
(%s BETWEEN date_rdv AND date_fin) OR
(%s BETWEEN date_rdv AND date_fin)
)';
$where_values = array_merge($where_values, [$date_debut, $date_fin, $date_debut, $date_fin, $date_debut, $date_fin]);
}
// Ajouter les conditions d'heure si fournies
if ($heure_debut && $heure_fin) {
$where_conditions[] = '(
(heure_rdv BETWEEN %s AND %s) OR
(heure_fin BETWEEN %s AND %s) OR
(%s BETWEEN heure_rdv AND heure_fin) OR
(%s BETWEEN heure_rdv AND heure_fin)
)';
$where_values = array_merge($where_values, [$heure_debut, $heure_fin, $heure_debut, $heure_fin, $heure_debut, $heure_fin]);
}
$where_clause = implode(' AND ', $where_conditions);
$query = $wpdb->prepare(
"SELECT * FROM {$table_events} WHERE {$where_clause}",
$where_values
);
return $wpdb->get_results($query, ARRAY_A);
}
public static function create($request) {
$data = $request->get_json_params();
$model = new CRVI_Local_Model();
$validation = self::validate_local_data($data);
if (is_wp_error($validation)) {
return Api_Helper::json_error($validation->get_error_message(), 400);
}
$result = $model->create($data);
if (is_wp_error($result)) {
return $result;
}
return Api_Helper::json_success([
'id' => $result,
'nom' => $data['nom'],
'message' => 'Local créé avec succès'
]);
}
public static function update_item($request) {
$id = (int) $request['id'];
$model = new CRVI_Local_Model();
$result = $model->update($id, $request->get_json_params());
if (is_wp_error($result)) {
return $result;
}
return Api_Helper::json_success(['id' => $id]);
}
public static function delete_item($request) {
$id = (int) $request['id'];
$model = new CRVI_Local_Model();
$result = $model->delete($id);
if (is_wp_error($result)) {
return $result;
}
return Api_Helper::json_success(['id' => $id, 'deleted' => true]);
}
/**
* Import CSV de locaux via REST
* @param WP_REST_Request $request
* @return array
*/
public static function import_csv($request) {
if (!current_user_can('edit_posts')) {
return Api_Helper::json_error('Non autorisé', 403);
}
$file = $request->get_file_params()['file'] ?? null;
if (!$file || !is_uploaded_file($file['tmp_name'])) {
return Api_Helper::json_error('Fichier CSV manquant ou invalide', 400);
}
$handle = fopen($file['tmp_name'], 'r');
if (!$handle) {
return Api_Helper::json_error('Impossible d\'ouvrir le fichier', 400);
}
$header = fgetcsv($handle, 0, ',');
$results = [];
$row_num = 1;
while (($row = fgetcsv($handle, 0, ',')) !== false) {
$row_num++;
$data = array_combine($header, $row);
$model = new CRVI_Local_Model();
$result = $model->create($data);
$results[] = [
'ligne' => $row_num,
'data' => $data,
'resultat' => $result
];
}
fclose($handle);
return Api_Helper::json_success(['import' => $results]);
}
// Permissions personnalisées
public static function can_edit() {
return current_user_can('edit_posts');
}
public static function can_delete() {
return current_user_can('delete_posts');
}
// Handler pour l'import CSV via formulaire admin
public static function import_csv_admin() {
if (!current_user_can('edit_posts')) {
wp_die('Non autorisé');
}
check_admin_referer('crvi_import_local');
if (empty($_FILES['import_csv']['tmp_name'])) {
wp_redirect(admin_url('admin.php?page=crvi_agenda&import=error&msg=Fichier manquant'));
exit;
}
$file = $_FILES['import_csv']['tmp_name'];
$handle = fopen($file, 'r');
if (!$handle) {
wp_redirect(admin_url('admin.php?page=crvi_agenda&import=error&msg=Impossible d\'ouvrir le fichier'));
exit;
}
$header = fgetcsv($handle, 0, ',');
$created = $updated = $errors = 0;
while (($row = fgetcsv($handle, 0, ',')) !== false) {
$data = array_combine($header, $row);
$data = array_combine(
array_map(function($k) { return sanitize_title($k); }, array_keys($data)),
array_map('trim', array_values($data))
);
$model = new CRVI_Local_Model();
$result = $model->create($data);
if (is_numeric($result)) {
$created++;
} elseif (is_wp_error($result) && $result->get_error_code() === '409') {
// Nom déjà utilisé - on pourrait mettre à jour au lieu d'ignorer
$errors++;
} else {
$errors++;
}
}
fclose($handle);
$msg = "Créés: $created, Erreurs: $errors";
wp_redirect(admin_url('admin.php?page=crvi_agenda&import=success&msg=' . urlencode($msg)));
exit;
}
public static function validate_local_data($data) {
if (empty($data['nom'])) {
return new \WP_Error('missing_nom', 'Le nom du local est obligatoire');
}
if (!empty($data['capacite']) && !is_numeric($data['capacite'])) {
return new \WP_Error('invalid_capacite', 'La capacité doit être un nombre');
}
return true;
}
}

View File

@ -0,0 +1,561 @@
<?php
namespace ESI_CRVI_AGENDA\controllers;
use ESI_CRVI_AGENDA\controllers\CRVI_Plugin;
include_once plugin_dir_path(__FILE__) . '../config.php';
class CRVI_Notifications_Controller {
public function __construct() {
add_action('init', [$this, 'init']);
}
public function init() {
/* add_action('wp_ajax_crvi_notifications', [$this, 'crvi_notifications']); */
add_action('wp_ajax_crvi_send_sms_debug', [self::class, 'ajax_send_sms_debug']);
add_action('wp_ajax_nopriv_crvi_send_sms_debug', [self::class, 'ajax_send_sms_debug']);
}
/**
* Crée la table de log des SMS si elle n'existe pas
* Schéma basé sur DOC-2/wp_crvi_sms_log.sql
*/
public static function create_sms_log_table() {
global $wpdb;
$table_name = $wpdb->prefix . 'crvi_sms_log';
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table_name} (
id int(11) NOT NULL AUTO_INCREMENT,
numsms varchar(255) NOT NULL,
msgsms text NOT NULL,
status varchar(50) NOT NULL,
date_sms datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
sujet varchar(50) NOT NULL,
id_event int(11) NOT NULL,
full_log longtext NOT NULL,
PRIMARY KEY (id)
) {$charset_collate};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta($sql);
}
/**
* AJAX: Envoi d'un SMS de debug via admin-ajax.php
* action: crvi_send_sms_debug
* champs attendus:
* - nonce: string
* - phone: string (optionnel, défaut 0485500723)
* - message: string (si présent => envoi direct via send_sms)
* - OU champs de datas pour send_sms_rdv_cloture (langue, type, date, time, name, gender, intervenant)
* - id_event: int (optionnel pour log)
* - sujet: string (optionnel pour log)
*/
public static function ajax_send_sms_debug() {
// Vérifier que c'est une requête AJAX valide
if (!defined('DOING_AJAX') || !DOING_AJAX) {
wp_send_json_error(['message' => 'Requête invalide'], 400);
return;
}
// Vérif nonce
$nonce = isset($_REQUEST['nonce']) ? sanitize_text_field(wp_unslash($_REQUEST['nonce'])) : '';
if (empty($nonce) || !wp_verify_nonce($nonce, 'crvi_sms')) {
wp_send_json_error(['message' => 'Nonce invalide'], 403);
return;
}
// Vérif permissions (réservé aux utilisateurs pouvant éditer)
if (!is_user_logged_in() || !current_user_can('edit_posts')) {
wp_send_json_error(['message' => 'Non autorisé'], 403);
return;
}
$phone = isset($_REQUEST['phone']) ? sanitize_text_field(wp_unslash($_REQUEST['phone'])) : '0485500723';
$message = isset($_REQUEST['message']) ? sanitize_text_field(wp_unslash($_REQUEST['message'])) : null;
$id_event = isset($_REQUEST['id_event']) ? (int) $_REQUEST['id_event'] : 0;
$sujet = isset($_REQUEST['sujet']) ? sanitize_text_field(wp_unslash($_REQUEST['sujet'])) : 'debug';
try {
if ($message) {
$result = self::sms_send($phone, $message, ['sujet' => $sujet, 'id_event' => $id_event]);
if ($result === true) {
wp_send_json_success(['sent' => true, 'via' => 'send_sms']);
} else {
// $result contient maintenant des détails sur l'erreur
$error_details = is_array($result) ? $result : ['message' => 'Échec d\'envoi (send_sms)', 'reason' => 'unknown'];
wp_send_json_error($error_details);
}
}
// Construction des datas pour le template de clôture par défaut
$datas = [
'phone' => $phone,
'langue' => isset($_REQUEST['langue']) ? sanitize_text_field(wp_unslash($_REQUEST['langue'])) : 'fr',
'type' => isset($_REQUEST['type']) ? sanitize_text_field(wp_unslash($_REQUEST['type'])) : '',
'date' => isset($_REQUEST['date']) ? sanitize_text_field(wp_unslash($_REQUEST['date'])) : '',
'time' => isset($_REQUEST['time']) ? sanitize_text_field(wp_unslash($_REQUEST['time'])) : '',
'name' => isset($_REQUEST['name']) ? sanitize_text_field(wp_unslash($_REQUEST['name'])) : '',
'gender' => isset($_REQUEST['gender']) ? sanitize_text_field(wp_unslash($_REQUEST['gender'])) : '',
'intervenant' => isset($_REQUEST['intervenant']) ? sanitize_text_field(wp_unslash($_REQUEST['intervenant'])) : '',
];
$result = self::send_sms_rdv_cloture($datas);
if ($result === true) {
wp_send_json_success(['sent' => true, 'via' => 'send_sms_rdv_cloture', 'datas' => $datas]);
} else {
// $result contient maintenant des détails sur l'erreur
$error_details = is_array($result) ? $result : ['message' => 'Échec d\'envoi (send_sms_rdv_cloture)', 'reason' => 'unknown', 'datas' => $datas];
wp_send_json_error($error_details);
}
} catch (\Throwable $e) {
wp_send_json_error(['message' => 'Exception: ' . $e->getMessage()], 500);
}
}
public static function send_rdv_cloture_automatique($datas) {
$template_path = plugin_dir_path(__FILE__) . '../../templates/email/rdv-cloture-email-template.php';
// Vérifier que le template existe
if (!file_exists($template_path)) {
error_log('[CRVI_AGENDA] Template email introuvable : ' . $template_path);
return false;
}
// Exécuter le template PHP avec output buffering pour capturer le HTML généré
ob_start();
// Passer les données au template (la variable $datas sera disponible dans le template)
include $template_path;
$body = ob_get_clean();
$subject = 'Rendez-vous clôturés';
$to = 'test@test.com';
$headers = ['Content-Type: text/html; charset=UTF-8'];
$result = wp_mail($to, $subject, $body, $headers);
if (!$result) {
error_log('[CRVI_AGENDA] Échec d\'envoi de l\'email de clôture automatique');
}
return $result;
}
/**
* SMS de clôture de rendez-vous
* Datas attendues : phone, name, gender, date, time, type, langue, intervenant
*/
public static function send_sms_rdv_cloture($datas) {
$langue = $datas['langue'] ?? 'fr';
$type = $datas['type'] ?? '';
$date = $datas['date'] ?? '';
$time = $datas['time'] ?? '';
$name = $datas['name'] ?? '';
$gender = $datas['gender'] ?? '';
$phone = $datas['phone'] ?? '';
$intervenant = $datas['intervenant'] ?? '';
// 1. Récupérer le texte du SMS depuis les options ACF,
// en fonction de la langue du rendez-vous.
$sms_text = '';
// Priorité au répéteur "texte_sms" (texte + langue)
$texte_sms_rows = get_field('texte_sms', 'option');
if (!empty($texte_sms_rows) && is_array($texte_sms_rows)) {
foreach ($texte_sms_rows as $row) {
$row_langue_id = $row['langue'] ?? null;
if ($row_langue_id) {
$term = get_term($row_langue_id);
if ($term && !is_wp_error($term) && $term->slug === $langue) {
$sms_text = $row['texte'] ?? '';
break;
}
}
}
}
// Fallback : texte_fr simple si aucun texte n'a été trouvé
if (!$sms_text) {
$sms_text = get_field('texte_fr', 'option');
}
// Fallback final: modèle par défaut si rien en ACF
if (!$sms_text) {
$sms_text = "Bonjour %titre% %nom%,\n\nVous avez rendez-vous le %date% à %heure%,\n\nLe crvi";
}
if (!$sms_text) {
// Rien de configuré : on ne tente pas d'envoyer
$error_msg = '[CRVI_AGENDA] Aucun texte SMS configuré pour la langue "' . $langue . '".';
error_log($error_msg);
return ['message' => 'Échec d\'envoi (send_sms_rdv_cloture)', 'reason' => 'no_sms_text', 'details' => 'Aucun texte SMS configuré pour la langue "' . $langue . '"'];
}
// 2. Déterminer le titre selon le genre (h/f/i) et la langue
$titres = [
'fr' => ['h' => 'Mr', 'f' => 'Mme', 'i' => ''],
'ar' => ['h' => 'السيد', 'f' => 'السيدة', 'i' => ''],
];
$titre = $titres[$langue][$gender] ?? ($titres['fr'][$gender] ?? '');
// 3. Remplacer les placeholders dans le texte
$replacements = [
'%titre%' => $titre,
'%nom%' => $name,
'%date%' => $date,
'%heure%' => $time,
'%intervenant%' => $intervenant,
];
$sms_text_final = strtr($sms_text, $replacements);
if (empty($phone)) {
$error_msg = '[CRVI_AGENDA] Numéro de téléphone vide, SMS non envoyé.';
error_log($error_msg);
// Log en base le skip pour traçabilité
self::log_sms([
'numsms' => '',
'msgsms' => $sms_text_final,
'status' => 'skipped_no_phone',
'sujet' => isset($datas['sujet']) ? (string) $datas['sujet'] : 'rdv_cloture',
'id_event' => isset($datas['id_event']) ? (int) $datas['id_event'] : 0,
'full_log' => 'No phone'
]);
return ['message' => 'Échec d\'envoi (send_sms_rdv_cloture)', 'reason' => 'empty_phone', 'details' => 'Numéro de téléphone vide'];
}
// 4. Envoi via OVH en utilisant login/mot de passe définis dans les options ACF
$options = [];
if (isset($datas['sujet'])) {
$options['sujet'] = (string) $datas['sujet'];
}
if (isset($datas['id_event'])) {
$options['id_event'] = (int) $datas['id_event'];
}
$result = self::sms_send($phone, $sms_text_final, $options);
// Si sms_send retourne true, retourner true, sinon retourner les détails de l'erreur
return ($result === true) ? true : $result;
}
/**
* Envoi générique d'un SMS via OVH HTTP2SMS
* Utilise les options ACF "option_sms" (login, mot_de_passe, quota_sms).
*/
public static function send_sms($tel, $message, $options = []) {
// Compatibilité ancien appel: si $options n'est pas un array, ignorer
if (!is_array($options)) {
$options = [];
}
$sujet = isset($options['sujet']) ? (string) $options['sujet'] : '';
$id_event = isset($options['id_event']) ? (int) $options['id_event'] : 0;
// Options SMS stockées dans ACF > Options > Option > Option sms
$option_sms = get_field('option_sms', 'option');
$login = $option_sms['login'] ?? '';
$password = $option_sms['mot_de_passe'] ?? '';
// $quota_sms = isset($option_sms['quota_sms']) ? (int) $option_sms['quota_sms'] : 0; // non utilisé ici, mais disponible
// smsAccount peut maintenant être configuré dans ACF (champ texte "compte_ovh" en options).
// Si le champ est vide ou non défini, on conserve le fallback sur la constante OVH_SMS_ACCOUNT.
$smsAccount_acf = '';
if (function_exists('get_field')) {
// Champ texte simple en options : compte_ovh
$smsAccount_acf = (string) get_field('compte_ovh', 'option');
// Si rien n'est défini à la racine, on tente éventuellement dans le groupe option_sms
if (!$smsAccount_acf && is_array($option_sms) && !empty($option_sms['compte_ovh'])) {
$smsAccount_acf = (string) $option_sms['compte_ovh'];
}
}
if (empty($login) || empty($password)) {
$error_msg = '[CRVI_AGENDA] SMS non envoyé : login ou mot de passe OVH non configuré dans ACF.';
error_log($error_msg);
return ['message' => 'Échec d\'envoi (send_sms)', 'reason' => 'missing_credentials', 'details' => 'Login ou mot de passe OVH non configuré dans ACF'];
}
// Normalisation du numéro (reprise de l'algorithme existant)
$tel = str_replace(['+', ' ', '-', '/'], '', $tel);
if (substr($tel, 0, 2) === '00') {
$to_sms = $tel; // pas de changement
} elseif (substr($tel, 0, 2) === '32' || substr($tel, 0, 2) === '33') {
$to_sms = '00' . $tel;
} else {
$to_sms = '0032' . substr($tel, 1);
}
$text_sms = urlencode(utf8_decode($message));
// Récupérer smsAccount et from depuis ACF / config.php, avec valeurs par défaut
// Priorité au champ texte ACF "compte_ovh" (options), puis fallback sur la constante.
$smsAccount = $smsAccount_acf ?: (\defined('OVH_SMS_ACCOUNT') ? \constant('OVH_SMS_ACCOUNT') : 'sms-xxxxxxx');
$from = \defined('OVH_SMS_FROM') ? \constant('OVH_SMS_FROM') : '0032496390437';
$url = "https://www.ovh.com/cgi-bin/sms/http2sms.cgi?smsAccount=" . rawurlencode($smsAccount)
. "&login=" . rawurlencode($login)
. "&password=" . rawurlencode($password)
. "&from=" . rawurlencode($from)
. "&to=" . rawurlencode($to_sms)
. "&contentType=text/xml"
. "&message={$text_sms}";
$ret = @file($url);
$raw_response = ($ret !== false && isset($ret[0])) ? $ret[0] : '';
$xml = $raw_response ? @simplexml_load_string($raw_response) : false;
$success = false;
$status_str = 'failed';
$error_details = [];
if ($xml && isset($xml->status)) {
$status_code = (int) $xml->status;
$success = ($status_code === 100);
$status_str = $success ? 'success' : ('failed(' . $status_code . ')');
if (!$success) {
$error_details = [
'message' => 'Échec d\'envoi (send_sms)',
'reason' => 'ovh_api_error',
'status_code' => $status_code,
'status_message' => (string) ($xml->message ?? 'Unknown error'),
'raw_response' => $raw_response
];
error_log('[CRVI_AGENDA] Réponse OVH HTTP2SMS: statut ' . $status_code . ' - ' . (string) ($xml->message ?? 'Unknown error'));
}
} else {
$error_details = [
'message' => 'Échec d\'envoi (send_sms)',
'reason' => 'invalid_response',
'details' => 'Réponse OVH HTTP2SMS invalide ou vide',
'raw_response' => $raw_response
];
error_log('[CRVI_AGENDA] Réponse OVH HTTP2SMS invalide ou vide. Raw response: ' . $raw_response);
}
// Mettre à jour le quota ACF si l'envoi a réussi, en se basant sur la réponse API (creditLeft)
if ($success && $xml && isset($xml->creditLeft) && function_exists('get_field') && function_exists('update_field')) {
$creditLeftVal = (int) $xml->creditLeft;
$option_sms = get_field('option_sms', 'option');
if (is_array($option_sms)) {
$option_sms['quota_sms'] = $creditLeftVal;
// Mettre à jour le groupe complet pour rester compatible ACF
update_field('option_sms', $option_sms, 'option');
}
}
// Log en base
self::log_sms([
'numsms' => $to_sms,
'msgsms' => $message,
'status' => $status_str,
'sujet' => $sujet,
'id_event' => $id_event,
'full_log' => $raw_response
]);
// Retourner true si succès, sinon retourner les détails de l'erreur
if ($success) {
return true;
} else {
return $error_details;
}
}
/**
* Alias demandé: utiliser sms_send comme point d'entrée standard.
* Garde la même signature et délègue à send_sms.
*/
public static function sms_send($tel, $message, $options = []) {
// Prise en charge d'un appel "cron" avec un array de données d'événement
// Exemple: sms_send(['phone'=>'...', 'name'=>'...', 'date'=>'...', 'time'=>'...', 'langue'=>'fr', 'sujet'=>'...', 'id_event'=>123], null);
if (is_array($tel)) {
$datas = $tel;
return self::send_sms_rdv_cloture($datas);
}
return self::send_sms($tel, $message, $options);
}
/**
* Insère une ligne de log dans la table wp_crvi_sms_log
*/
private static function log_sms(array $fields) {
global $wpdb;
$table = $wpdb->prefix . 'crvi_sms_log';
$data = [
'numsms' => isset($fields['numsms']) ? (string) $fields['numsms'] : '',
'msgsms' => isset($fields['msgsms']) ? (string) $fields['msgsms'] : '',
'status' => isset($fields['status']) ? (string) $fields['status'] : '',
'sujet' => isset($fields['sujet']) ? (string) $fields['sujet'] : '',
'id_event' => isset($fields['id_event']) ? (int) $fields['id_event'] : 0,
'full_log' => isset($fields['full_log']) ? (string) $fields['full_log'] : '',
];
// date_sms a une valeur par défaut CURRENT_TIMESTAMP côté DB
// Sécuriser l'insertion via $wpdb->insert (prépare sous le capot)
$wpdb->insert($table, $data, ['%s','%s','%s','%s','%d','%s']);
}
/**
* Envoie des SMS de rappel pour les événements individuels dans X jours
*
* @param int $days Nombre de jours avant l'événement (1, 2 ou 3)
* @return array Rapport détaillé avec statistiques d'envoi
*/
public static function send_sms_reminder($days = 3) {
global $wpdb;
// Valider le paramètre days
if (!in_array($days, [1, 2, 3])) {
return [
'error' => 'Le paramètre days doit être 1, 2 ou 3',
'received' => $days
];
}
$table_name = $wpdb->prefix . 'crvi_agenda';
// Calculer la date cible (aujourd'hui + X jours)
$target_date = date('Y-m-d', strtotime("+{$days} days"));
// Récupérer les événements individuels à la date cible avec statut 'prevu'
$sql = $wpdb->prepare(
"SELECT * FROM $table_name
WHERE date_rdv = %s
AND type = 'individuel'
AND statut = 'prevu'
AND is_deleted = 0
AND id_beneficiaire IS NOT NULL
ORDER BY heure_rdv ASC",
$target_date
);
$events = $wpdb->get_results($sql, ARRAY_A);
if (empty($events)) {
return [
'success' => true,
'count' => 0,
'date' => $target_date,
'days' => $days,
'message' => "Aucun événement individuel trouvé pour dans {$days} jour(s) ({$target_date})"
];
}
$results = [
'success' => true,
'date' => $target_date,
'days' => $days,
'total_events' => count($events),
'sms_sent' => 0,
'sms_failed' => 0,
'sms_skipped' => 0,
'details' => []
];
// Traiter chaque événement
foreach ($events as $event) {
$event_result = [
'event_id' => (int) $event['id'],
'date' => $event['date_rdv'],
'heure' => $event['heure_rdv'],
'status' => 'skipped',
'reason' => ''
];
// Charger le bénéficiaire
if (empty($event['id_beneficiaire'])) {
$event_result['reason'] = 'Pas de bénéficiaire associé';
$results['sms_skipped']++;
$results['details'][] = $event_result;
continue;
}
$beneficiaire = \ESI_CRVI_AGENDA\models\CRVI_Beneficiaire_Model::load($event['id_beneficiaire']);
if (!$beneficiaire) {
$event_result['reason'] = 'Bénéficiaire introuvable';
$results['sms_skipped']++;
$results['details'][] = $event_result;
continue;
}
// Vérifier que le bénéficiaire a un téléphone
$telephone = $beneficiaire->telephone ?? '';
if (empty($telephone)) {
$event_result['reason'] = 'Pas de numéro de téléphone pour le bénéficiaire';
$results['sms_skipped']++;
$results['details'][] = $event_result;
continue;
}
// Récupérer le genre du bénéficiaire depuis ACF
$genre = '';
if (function_exists('get_field')) {
$genre = get_field('genre', $beneficiaire->id) ?? '';
}
// Récupérer la langue de l'événement
$langue = $event['langue'] ?? 'fr';
// Si la langue est un ID de taxonomy, récupérer le slug
if (is_numeric($langue) && function_exists('get_term')) {
$langue_term = get_term((int) $langue, 'langue');
if ($langue_term && !is_wp_error($langue_term)) {
$langue = $langue_term->slug ?? 'fr';
}
}
// Récupérer le nom de l'intervenant
$intervenant_nom = '';
if (!empty($event['id_intervenant'])) {
$intervenant = \ESI_CRVI_AGENDA\models\CRVI_Intervenant_Model::load($event['id_intervenant'], ['id', 'nom', 'prenom']);
if ($intervenant) {
$intervenant_nom = trim(($intervenant->nom ?? '') . ' ' . ($intervenant->prenom ?? ''));
}
}
// Formater la date et l'heure
$date_formatted = date('d/m/Y', strtotime($event['date_rdv']));
$heure_formatted = date('H:i', strtotime($event['heure_rdv']));
// Préparer les données pour l'envoi de SMS
$sms_data = [
'phone' => $telephone,
'name' => trim(($beneficiaire->nom ?? '') . ' ' . ($beneficiaire->prenom ?? '')),
'gender' => $genre, // h, f, ou i
'date' => $date_formatted,
'time' => $heure_formatted,
'type' => $event['type'],
'langue' => $langue,
'intervenant' => $intervenant_nom,
'sujet' => 'rappel_rdv',
'id_event' => (int) $event['id']
];
// Envoyer le SMS
$sms_result = self::send_sms_rdv_cloture($sms_data);
if ($sms_result === true) {
$event_result['status'] = 'sent';
$event_result['beneficiaire'] = $sms_data['name'];
$event_result['telephone'] = $telephone;
$results['sms_sent']++;
} else {
$event_result['status'] = 'failed';
$event_result['reason'] = is_array($sms_result) ? ($sms_result['message'] ?? 'Erreur inconnue') : 'Erreur lors de l\'envoi';
$event_result['beneficiaire'] = $sms_data['name'];
$event_result['telephone'] = $telephone;
$results['sms_failed']++;
}
$results['details'][] = $event_result;
}
return $results;
}
}

View File

@ -0,0 +1,301 @@
<?php
namespace ESI_CRVI_AGENDA\controllers;
use ESI_CRVI_AGENDA\controllers\CRVI_Local_Controller;
use ESI_CRVI_AGENDA\controllers\CRVI_Beneficiaire_Controller;
use ESI_CRVI_AGENDA\controllers\CRVI_Intervenant_Controller;
use ESI_CRVI_AGENDA\controllers\CRVI_Traducteur_Controller;
use ESI_CRVI_AGENDA\controllers\CRVI_Departement_Controller;
use ESI_CRVI_AGENDA\controllers\CRVI_Type_Intervention_Controller;
use ESI_CRVI_AGENDA\models\CRVI_Event_Model;
use ESI_CRVI_AGENDA\controllers\CRVI_Event_Controller;
use ESI_CRVI_AGENDA\controllers\CRVI_Entity_Controller;
use ESI_CRVI_AGENDA\views\CRVI_Main_View;
use ESI_CRVI_AGENDA\views\CRVI_Agenda_View;
class CRVI_Plugin {
public static function init() {
// Initialisation du plugin (CPT, hooks, etc.)
/* add_action('init', [self::class, 'register_cpt']); */
/* self::register_cpt(); */
self::load_actions();
self::load_filters();
self::load_shortcodes();
self::load_frontend_assets();
self::load_admin_assets();
}
public static function activate() {
// Code d'activation (création de tables, etc.)
// create table
CRVI_Event_Model::create_table();
}
public static function deactivate() {
// Code de désactivation (nettoyage, etc.)
}
public static function register_cpt() {
CRVI_Local_Controller::register_cpt();
CRVI_Beneficiaire_Controller::register_cpt();
/* CRVI_Intervenant_Controller::register_cpt(); */
CRVI_Traducteur_Controller::register_cpt();
//register departement et type_intervention
CRVI_Departement_Controller::register_cpt();
CRVI_Type_Intervention_Controller::register_cpt();
}
public function register_routes() {
// Vérification et enregistrement des routes avec gestion d'erreur
/* $controllers = [
'CRVI_Local_Controller',
'CRVI_Beneficiaire_Controller',
'CRVI_Intervenant_Controller',
'CRVI_Traducteur_Controller',
'CRVI_Departement_Controller',
'CRVI_Event_Controller',
'CRVI_Entity_Controller'
];
foreach ($controllers as $controller) {
if (class_exists($controller)) {
try {
$controller::register_routes();
} catch (\Exception $e) {
error_log("Erreur lors de l'enregistrement des routes pour {$controller}: " . $e->getMessage()."\n");
}
} else {
error_log("Classe {$controller} non trouvée lors de l'enregistrement des routes\n");
}
} */
CRVI_Local_Controller::register_routes();
CRVI_Beneficiaire_Controller::register_routes();
CRVI_Traducteur_Controller::register_routes();
CRVI_Intervenant_Controller::register_routes();
CRVI_Departement_Controller::register_routes();
CRVI_Event_Controller::register_routes();
CRVI_Entity_Controller::register_routes();
}
public function load_actions() {
add_action('init', [self::class, 'register_cpt']);
add_action('init', [self::class, 'register_routes']); // Lancer register_routes en même temps que register_cpt
add_action('admin_menu', [self::class, 'add_admin_menu']);
add_action('admin_post_crvi_import_beneficiaire', [\ESI_CRVI_AGENDA\controllers\CRVI_Beneficiaire_Controller::class, 'import_csv_admin']);
add_action('admin_post_crvi_import_local', [\ESI_CRVI_AGENDA\controllers\CRVI_Local_Controller::class, 'import_csv_admin']);
add_action('admin_post_crvi_import_traducteur', [\ESI_CRVI_AGENDA\controllers\CRVI_Traducteur_Controller::class, 'import_csv_admin']);
add_action('admin_post_crvi_import_intervenant', [\ESI_CRVI_AGENDA\controllers\CRVI_Intervenant_Controller::class, 'import_csv_admin']);
add_action('admin_post_crvi_import_departement', [\ESI_CRVI_AGENDA\controllers\CRVI_Departement_Controller::class, 'import_csv_admin']);
add_action('admin_post_crvi_import_type_intervention', [\ESI_CRVI_AGENDA\controllers\CRVI_Type_Intervention_Controller::class, 'import_csv_admin']);
// Enqueue assets conditionnellement
add_action('admin_enqueue_scripts', [self::class, 'enqueue_admin_assets']);
add_action('wp_enqueue_scripts', [self::class, 'enqueue_front_assets']);
}
/**
* Ajoute la page d'admin Agenda CRVI avec sous-menus Hub, Stats, Agenda
*/
public static function add_admin_menu() {
// Menu principal : Agenda CRVI (affiche le Hub)
add_menu_page(
__('Agenda CRVI', 'esi_crvi_agenda'),
__('Agenda CRVI', 'esi_crvi_agenda'),
'manage_options',
'crvi_agenda',
[CRVI_Main_View::class, 'render_hub_admin_page'],
'dashicons-calendar-alt',
6
);
// Sous-menu : Hub (même callback que le menu principal)
add_submenu_page(
'crvi_agenda',
__('Hub', 'esi_crvi_agenda'),
__('Hub', 'esi_crvi_agenda'),
'manage_options',
'crvi_agenda',
[CRVI_Main_View::class, 'render_hub_admin_page']
);
// Sous-menu : Stats
add_submenu_page(
'crvi_agenda',
__('Stats', 'esi_crvi_agenda'),
__('Stats', 'esi_crvi_agenda'),
'manage_options',
'crvi_agenda_stats',
[self::class, 'render_stats_page']
);
// Sous-menu : Agenda
add_submenu_page(
'crvi_agenda',
__('Agenda', 'esi_crvi_agenda'),
__('Agenda', 'esi_crvi_agenda'),
'manage_options',
'crvi_agenda_hub',
[CRVI_Agenda_View::class, 'render_agenda_page']
);
}
/**
* Affiche la page d'admin des stats
*/
public static function render_stats_page() {
echo '<div class="wrap"><h1>' . esc_html__('Statistiques Agenda', 'esi_crvi_agenda') . '</h1>';
$template = plugin_dir_path(__FILE__) . '../../templates/admin/agenda-stats-form.php';
if (file_exists($template)) {
include $template;
} else {
echo '<p style="color:red">Template agenda-stats-form.php introuvable.</p>';
}
// Traitement du formulaire (debug)
if ($_SERVER['REQUEST_METHOD'] === 'POST' && current_user_can('manage_options')) {
$entite = sanitize_text_field($_POST['entite'] ?? 'global');
$annee = isset($_POST['annee']) ? intval($_POST['annee']) : null;
$date_debut = sanitize_text_field($_POST['date_debut'] ?? '');
$date_fin = sanitize_text_field($_POST['date_fin'] ?? '');
echo '<div class="notice notice-info"><p>';
printf('Debug : entité = %s, année = %s, date_debut = %s, date_fin = %s', esc_html($entite), esc_html((string)$annee), esc_html($date_debut), esc_html($date_fin));
echo '</p></div>';
}
echo '</div>';
}
public function load_filters() {
/* add_filter('rest_endpoints', [self::class, 'register_routes']); */
add_filter('wp_script_attributes', [self::class, 'custom_script_tag'], 10, 2);
}
public function load_shortcodes() {
/* add_shortcode('crvi_agenda', [self::class, 'crvi_agenda_shortcode']); */
}
public function load_frontend_assets() {
self::enqueue_admin_assets();
self::enqueue_front_assets();
}
public function load_admin_assets() {
}
public function add_pages() {
//hub admin
}
/**
* Charge les assets uniquement sur les pages admin CRVI
*/
public static function enqueue_admin_assets() {
// Les slugs des pages admin CRVI
$crvi_pages = [
'crvi_agenda',
'crvi_agenda_stats',
'crvi_agenda_hub',
];
$page = $_GET['page'] ?? '';
if (in_array($page, $crvi_pages, true)) {
// Charger Select2 via WordPress
wp_enqueue_style('select2', 'https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/css/select2.min.css', [], '4.0.13');
wp_enqueue_script('select2', 'https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/js/select2.full.min.js', ['jquery'], '4.0.13', true);
// Bundle CSS principal (inclut Bootstrap, Toastr, FullCalendar)
wp_enqueue_style('crvi_main_css', ESI_CRVI_AGENDA_URL . 'assets/js/dist/crvi_main_css.min.css', [], '1.0.0');
// Script principal du plugin (inclut Bootstrap, FullCalendar, Toastr)
wp_enqueue_script('crvi_main_agenda', ESI_CRVI_AGENDA_URL . 'assets/js/dist/crvi_main.min.js', ['jquery', 'select2'], '1.0.0', true);
// Transmettre les permissions utilisateur au JavaScript (seulement si l'utilisateur est connecté)
if (did_action('init') && is_user_logged_in()) {
$permissions = \ESI_CRVI_AGENDA\helpers\Api_Helper::get_user_permissions();
wp_localize_script('crvi_main_agenda', 'crviPermissions', $permissions);
} else {
// Permissions par défaut si l'utilisateur n'est pas encore chargé
wp_localize_script('crvi_main_agenda', 'crviPermissions', [
'can_create' => false,
'can_edit' => false,
'can_delete' => false,
'can_close' => false,
'can_view' => false,
'user_roles' => [],
'user_id' => 0,
]);
}
}
}
/**
* Charge les assets uniquement sur la page publique crvi_agenda_interne
*/
public static function enqueue_front_assets() {
if (is_page('crvi_agenda_interne')) {
// Charger Select2 via WordPress
wp_enqueue_style('select2', 'https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/css/select2.min.css', [], '4.0.13');
wp_enqueue_script('select2', 'https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/js/select2.full.min.js', ['jquery'], '4.0.13', true);
// Bundle CSS principal (inclut Bootstrap, Toastr, FullCalendar)
wp_enqueue_style('crvi_main_css', ESI_CRVI_AGENDA_URL . 'assets/js/dist/crvi_main_css.min.css', [], '1.0.0');
// Script principal du plugin (inclut Bootstrap, FullCalendar, Toastr)
wp_enqueue_script('crvi_main_agenda', ESI_CRVI_AGENDA_URL . 'assets/js/dist/crvi_main.min.js', ['jquery', 'select2'], '1.0.0', true);
// Transmettre les permissions utilisateur au JavaScript (seulement si l'utilisateur est connecté)
if (did_action('init') && is_user_logged_in()) {
$permissions = \ESI_CRVI_AGENDA\helpers\Api_Helper::get_user_permissions();
wp_localize_script('crvi_main_agenda', 'crviPermissions', $permissions);
} else {
// Permissions par défaut si l'utilisateur n'est pas encore chargé
wp_localize_script('crvi_main_agenda', 'crviPermissions', [
'can_create' => false,
'can_edit' => false,
'can_delete' => false,
'can_close' => false,
'can_view' => false,
'user_roles' => [],
'user_id' => 0,
]);
}
}
}
/**
* crvi+script loader
*/
public function custom_script_tag(array $attr) {
if('crvi_main_agenda-js' !== $attr['id']) {
return $attr;
}
// Le script est maintenant compilé avec toutes les dépendances, pas besoin de type module
// $attr['type'] = 'module';
return $attr;
}
public static function write_log($message, $level = 'INFO') {
$message = self::format_message_for_log($message);
$log_entry = sprintf("[%s] [%s] %s\n", date('Y-m-d H:i:s'), $level, $message);
error_log($log_entry);
}
private static function format_message_for_log($message) {
if (is_array($message)) {
return print_r($message, true);
} elseif (is_object($message)) {
return print_r($message, true);
} elseif (is_bool($message)) {
return $message ? 'TRUE' : 'FALSE';
} elseif (is_numeric($message)) {
return (string) $message;
} elseif (is_null($message)) {
return 'NULL';
} else {
return var_export($message, true);
}
}
}

1220
app/controllers/Plugin.php Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,265 @@
<?php
declare(strict_types=1);
namespace ESI_CRVI_AGENDA\controllers;
use ESI_CRVI_AGENDA\models\CRVI_Traducteur_Model;
use ESI_CRVI_AGENDA\helpers\Api_Helper;
class CRVI_Traducteur_Controller {
public static function register_cpt() {
\register_post_type('traducteur', [
'label' => 'Traducteurs',
'labels' => [
'name' => 'Traducteurs',
'singular_name' => 'Traducteur',
'add_new' => 'Ajouter un traducteur',
'add_new_item' => 'Ajouter un nouveau traducteur',
'edit_item' => 'Modifier le traducteur',
'new_item' => 'Nouveau traducteur',
'view_item' => 'Voir le traducteur',
'search_items' => 'Rechercher un traducteur',
'not_found' => 'Aucun traducteur trouvé',
'not_found_in_trash' => 'Aucun traducteur dans la corbeille',
],
'public' => true,
'show_in_menu' => true,
'menu_position' => 24,
'menu_icon' => 'dashicons-translation',
'supports' => ['title'],
'has_archive' => false,
'show_in_rest' => true,
]);
// Déclaration de la taxonomie 'langue' (si pas déjà déclarée)
self::register_taxonomy();
// Association de la taxonomie 'langue' au CPT 'traducteur'
\register_taxonomy_for_object_type('langue', 'traducteur');
\register_taxonomy_for_object_type('organisme', 'traducteur');
}
public static function register_taxonomy() {
\register_taxonomy('langue', ['traducteur'], [
'label' => 'Langues',
'labels' => [
'name' => 'Langues',
'singular_name' => 'Langue',
'search_items' => 'Rechercher des langues',
'all_items' => 'Toutes les langues',
'edit_item' => 'Modifier la langue',
'update_item' => 'Mettre à jour la langue',
'add_new_item' => 'Ajouter une nouvelle langue',
'new_item_name' => 'Nom de la nouvelle langue',
'menu_name' => 'Langues',
],
'public' => true,
'show_ui' => true,
'show_in_menu' => true,
'show_in_nav_menus' => true,
'show_tagcloud' => false,
'show_in_quick_edit' => true,
'show_admin_column' => true,
'hierarchical' => false, // comme une étiquette
'show_in_rest' => true,
'rewrite' => [ 'slug' => 'langue' ],
]);
\register_taxonomy('organisme', ['traducteur'], [
'label' => 'Organismes',
'labels' => [
'name' => 'Organismes',
'singular_name' => 'Organisme',
],
'public' => true,
'show_ui' => true,
'show_in_menu' => true,
'show_in_nav_menus' => true,
'show_tagcloud' => false,
'show_in_quick_edit' => true,
'show_admin_column' => true,
'hierarchical' => false, // comme une étiquette
'show_in_rest' => true,
'rewrite' => [ 'slug' => 'organisme' ],
]);
}
public static function get_traducteur_by_langue($langue) {
$langue_obj = \get_term_by('slug', $langue, 'langue');
if (!$langue_obj) {
return null;
}
$traducteurs = \get_posts([
'post_type' => 'traducteur',
'tax_query' => [
[
'taxonomy' => 'langue',
'field' => 'slug',
'terms' => $langue, // ou un tableau de slugs
],
],
]);
return $traducteurs;
}
/**
* Retourne les traducteurs disponibles à une date donnée et pour une langue donnée.
* @param string $date au format Y-m-d
* @param string $langue_slug (slug de la langue)
* @return array Liste des traducteurs disponibles (CRVI_Traducteur_Model)
*/
public static function filtrer_disponibles($date, $langue_slug) {
return CRVI_Traducteur_Model::filtrer_disponibles($date, $langue_slug);
}
public static function register_routes() {
\register_rest_route('crvi/v1', '/traducteurs', [
[
'methods' => 'GET',
'callback' => [self::class, 'get_items'],
'permission_callback' => '__return_true',
],
[
'methods' => 'POST',
'callback' => [self::class, 'create_item'],
'permission_callback' => [self::class, 'can_edit'],
],
]);
\register_rest_route('crvi/v1', '/traducteurs/(?P<id>\\d+)', [
[
'methods' => 'GET',
'callback' => [self::class, 'get_item'],
'permission_callback' => '__return_true',
],
[
'methods' => 'PUT,PATCH',
'callback' => [self::class, 'update_item'],
'permission_callback' => [self::class, 'can_edit'],
],
[
'methods' => 'DELETE',
'callback' => [self::class, 'delete_item'],
'permission_callback' => [self::class, 'can_edit'],
],
]);
}
public static function get_item($request) {
$id = (int) $request['id'];
$traducteur = CRVI_Traducteur_Model::load($id);
if (!$traducteur) {
return [
'success' => false,
'code' => 404,
'message' => 'Traducteur introuvable',
'data' => null
];
}
return [
'success' => true,
'data' => $traducteur
];
}
public static function create_item($request) {
$data = $request->get_json_params();
$validation = self::validate_traducteur_data($data);
if (is_wp_error($validation)) {
return Api_Helper::json_error($validation->get_error_message(), 400);
}
$result = CRVI_Traducteur_Model::create($data, true);
if (!is_numeric($result)) {
return $result;
}
return Api_Helper::json_success([
'id' => $result,
'nom' => $data['nom'],
'message' => 'Traducteur créé avec succès'
]);
}
public static function update_item($request) {
$id = (int) $request['id'];
$data = $request->get_json_params();
$traducteur = new CRVI_Traducteur_Model();
$result = $traducteur->update($id, $data);
if ($result !== true) {
return $result;
}
return [
'success' => true,
'data' => [ 'id' => $id, 'message' => 'Traducteur modifié avec succès' ]
];
}
public static function delete_item($request) {
$id = (int) $request['id'];
$traducteur = new CRVI_Traducteur_Model();
$result = $traducteur->delete($id);
if ($result !== true) {
return $result;
}
return [
'success' => true,
'data' => [ 'id' => $id, 'message' => 'Traducteur supprimé avec succès' ]
];
}
public static function can_edit($request = null) {
return current_user_can('edit_posts');
}
// Handler pour l'import CSV via formulaire admin
public static function import_csv_admin() {
if (!current_user_can('edit_posts')) {
wp_die('Non autorisé');
}
check_admin_referer('crvi_import_traducteur');
if (empty($_FILES['import_csv']['tmp_name'])) {
wp_redirect(admin_url('admin.php?page=crvi_agenda&import=error&msg=Fichier manquant'));
exit;
}
$file = $_FILES['import_csv']['tmp_name'];
$handle = fopen($file, 'r');
if (!$handle) {
wp_redirect(admin_url('admin.php?page=crvi_agenda&import=error&msg=Impossible d\'ouvrir le fichier'));
exit;
}
$header = fgetcsv($handle, 0, ',');
$created = $updated = $errors = 0;
while (($row = fgetcsv($handle, 0, ',')) !== false) {
$data = array_combine($header, $row);
$data = array_combine(
array_map(function($k) { return sanitize_title($k); }, array_keys($data)),
array_map('trim', array_values($data))
);
$result = CRVI_Traducteur_Model::create($data, true);
if (is_numeric($result)) $created++;
else $errors++;
}
fclose($handle);
$msg = "Créés: $created, Modifiés: $updated, Erreurs: $errors";
wp_redirect(admin_url('admin.php?page=crvi_agenda&import=success&msg=' . urlencode($msg)));
exit;
}
/**
* Validation des données de traducteur
*/
public static function validate_traducteur_data($data) {
if (empty($data['nom'])) {
return new \WP_Error('missing_nom', 'Le nom est obligatoire');
}
if (!empty($data['email']) && !is_email($data['email'])) {
return new \WP_Error('invalid_email', 'L\'email n\'est pas valide');
}
return true;
}
}

View File

@ -0,0 +1,496 @@
<?php
declare(strict_types=1);
namespace ESI_CRVI_AGENDA\controllers;
/**
* Contrôleur pour le CPT traduction_langue
* Gère les capacités de traduction par langue, jour et période
*/
class CRVI_TraductionLangue_Controller {
/**
* Enregistre le CPT traduction_langue et le groupe ACF associé
*/
public static function register_cpt() {
// Enregistrement du CPT traduction_langue
\register_post_type('traduction_langue', [
'label' => 'Capacités de traduction',
'labels' => [
'name' => 'Capacités de traduction',
'singular_name' => 'Capacité de traduction',
'add_new' => 'Ajouter une capacité',
'add_new_item' => 'Ajouter une nouvelle capacité de traduction',
'edit_item' => 'Modifier la capacité de traduction',
'new_item' => 'Nouvelle capacité de traduction',
'view_item' => 'Voir la capacité de traduction',
'search_items' => 'Rechercher une capacité',
'not_found' => 'Aucune capacité trouvée',
'not_found_in_trash' => 'Aucune capacité dans la corbeille',
],
'public' => false, // CPT non public
'show_ui' => true, // UI active dans l'admin
'show_in_menu' => false, // Menu masqué
'hierarchical' => true, // CPT hiérarchique (pour parent/enfant)
'supports' => ['title'],
'has_archive' => false,
'show_in_rest' => true, // Support REST API
'rewrite' => false, // Pas de rewrite car non public
]);
// Association de la taxonomie 'langue' au CPT 'traduction_langue'
// La taxonomie existe déjà (déclarée dans Traducteur_Controller)
if (taxonomy_exists('langue')) {
\register_taxonomy_for_object_type('langue', 'traduction_langue');
}
// Création du groupe ACF capacite_traduction
/* self::register_acf_field_group(); */
// Enregistrement des validations ACF
\add_filter('acf/validate_value', [self::class, 'validate_acf_fields'], 10, 4);
// Enregistrement du hook de suppression en cascade
\add_action('before_delete_post', [self::class, 'handle_cascade_delete'], 10, 2);
// Enregistrement du hook de verrouillage si événements existent
\add_filter('acf/validate_save_post', [self::class, 'validate_save_post_with_events'], 10, 1);
}
/**
* Enregistre le groupe ACF capacite_traduction avec tous les champs nécessaires
*/
private static function register_acf_field_group() {
// Vérifier que ACF est actif
if (!function_exists('acf_add_local_field_group')) {
return;
}
// Création du groupe ACF avec tous les champs
\acf_add_local_field_group([
'key' => 'group_capacite_traduction',
'title' => 'Capacité de traduction',
'fields' => [
// Champ: langue (taxonomy)
[
'key' => 'field_capacite_langue',
'label' => 'Langue',
'name' => 'langue',
'type' => 'taxonomy',
'instructions' => 'Sélectionnez la langue pour cette capacité',
'required' => 1,
'taxonomy' => 'langue',
'field_type' => 'select',
'allow_null' => 0,
'return_format' => 'id',
],
// Champ: jour (lundi → dimanche)
[
'key' => 'field_capacite_jour',
'label' => 'Jour',
'name' => 'jour',
'type' => 'select',
'instructions' => 'Sélectionnez le jour de la semaine',
'required' => 1,
'choices' => [
'lundi' => 'Lundi',
'mardi' => 'Mardi',
'mercredi' => 'Mercredi',
'jeudi' => 'Jeudi',
'vendredi' => 'Vendredi',
'samedi' => 'Samedi',
'dimanche' => 'Dimanche',
],
'default_value' => '',
'allow_null' => 0,
'return_format' => 'value',
],
// Champ: periode (matin / apres_midi / journee)
[
'key' => 'field_capacite_periode',
'label' => 'Période',
'name' => 'periode',
'type' => 'select',
'instructions' => 'Sélectionnez la période de la journée',
'required' => 1,
'choices' => [
'matin' => 'Matin',
'apres_midi' => 'Après-midi',
'journee' => 'Journée',
],
'default_value' => '',
'allow_null' => 0,
'return_format' => 'value',
],
// Champ: limite (number)
[
'key' => 'field_capacite_limite',
'label' => 'Limite',
'name' => 'limite',
'type' => 'number',
'instructions' => 'Nombre maximum de créneaux disponibles',
'required' => 1,
'default_value' => 0,
'min' => 0,
'step' => 1,
],
// Champ: limite_par (semaine / mois)
[
'key' => 'field_capacite_limite_par',
'label' => 'Limite par',
'name' => 'limite_par',
'type' => 'select',
'instructions' => 'Période de calcul de la limite',
'required' => 1,
'choices' => [
'semaine' => 'Semaine',
'mois' => 'Mois',
],
'default_value' => 'semaine',
'allow_null' => 0,
'return_format' => 'value',
],
// Champ: actif (true/false)
[
'key' => 'field_capacite_actif',
'label' => 'Actif',
'name' => 'actif',
'type' => 'true_false',
'instructions' => 'Activez ou désactivez cette capacité de traduction',
'required' => 0,
'default_value' => 1,
'ui' => 1,
'ui_on_text' => 'Oui',
'ui_off_text' => 'Non',
],
],
'location' => [
[
[
'param' => 'post_type',
'operator' => '==',
'value' => 'traduction_langue',
],
],
],
'menu_order' => 0,
'position' => 'normal',
'style' => 'default',
'label_placement' => 'top',
'instruction_placement' => 'label',
'hide_on_screen' => '',
'active' => true,
'description' => 'Configuration des capacités de traduction par langue, jour et période',
]);
}
/**
* Valide les champs ACF avant la sauvegarde
*
* @param bool|string $valid État de validation (true ou message d'erreur)
* @param mixed $value Valeur du champ
* @param array $field Configuration du champ ACF
* @param string $input Nom de l'input HTML
* @return bool|string
*/
public static function validate_acf_fields($valid, $value, $field, $input) {
// Ne valider que si c'est déjà valide et que c'est un champ de notre groupe
if (!$valid || !isset($field['key']) || strpos($field['key'], 'field_capacite_') !== 0) {
return $valid;
}
// Récupérer le post_id depuis $_POST
$post_id = isset($_POST['post_ID']) ? (int) $_POST['post_ID'] : 0;
// Ne pas valider pour les nouveaux posts
if (!$post_id || $post_id === 0) {
return $valid;
}
// Vérifier que c'est bien un post traduction_langue
$post = \get_post($post_id);
if (!$post || $post->post_type !== 'traduction_langue') {
return $valid;
}
// Pas de validation spécifique pour le moment
return $valid;
}
/**
* Gère la suppression des capacités de traduction
* - Bloque la suppression si des événements existent
*
* @param int $post_id ID du post à supprimer
* @param \WP_Post $post Objet du post
*/
public static function handle_cascade_delete($post_id, $post) {
// Vérifier que c'est bien un post traduction_langue
if (!$post || $post->post_type !== 'traduction_langue') {
return;
}
// Vérifier si la capacité a des événements associés avec vérification précise
$has_events = self::check_events_for_capacite($post_id);
if ($has_events) {
// Bloquer la suppression avec un message d'erreur
\wp_die(
'<h1>Suppression impossible</h1>' .
'<p>Cette capacité de traduction ne peut pas être supprimée car elle a des événements associés.</p>' .
'<p>Vous devez d\'abord supprimer ou réassigner les événements liés à cette capacité.</p>' .
'<p><a href="' . \admin_url('edit.php?post_type=traduction_langue') . '">← Retour à la liste</a></p>',
'Suppression bloquée',
[
'response' => 403,
'back_link' => true,
]
);
}
// Invalider le cache des capacités
\ESI_CRVI_AGENDA\models\CRVI_TraductionLangue_Model::invalidate_cache($post_id);
}
/**
* Vérifie si une capacité a des événements associés avec vérification précise
* Utilise la même logique que countUsed() mais retourne un booléen
*
* @param int $capacite_id ID de la capacité
* @return bool True si la capacité a des événements
*/
public static function check_events_for_capacite($capacite_id): bool {
$capacite = \ESI_CRVI_AGENDA\models\CRVI_TraductionLangue_Model::load($capacite_id);
if (!$capacite) {
return false;
}
// Récupérer les langues (peut être un tableau)
$langue_ids = $capacite->langue;
if (empty($langue_ids)) {
return false;
}
// S'assurer que c'est un tableau
if (!is_array($langue_ids)) {
$langue_ids = [$langue_ids];
}
// Récupérer les slugs de toutes les langues
$langue_slugs = [];
foreach ($langue_ids as $langue_id) {
$langue_term = \get_term($langue_id, 'langue');
if ($langue_term && !\is_wp_error($langue_term)) {
$langue_slugs[] = $langue_term->slug;
}
}
if (empty($langue_slugs)) {
return false;
}
// Mapper les jours en format MySQL DAYOFWEEK
$jours_map = [
'dimanche' => 1,
'lundi' => 2,
'mardi' => 3,
'mercredi' => 4,
'jeudi' => 5,
'vendredi' => 6,
'samedi' => 7,
];
// Requête optimisée avec EXISTS
global $wpdb;
$table_name = $wpdb->prefix . 'crvi_agenda';
// Construire la requête WHERE avec support multi-langues
$langue_placeholders = implode(',', array_fill(0, count($langue_slugs), '%s'));
$where = [
"langue IN ($langue_placeholders)",
"is_deleted = 0",
"statut != 'annule'",
"statut != 'brouillon'",
];
$values = $langue_slugs;
// Filtrer par jour de la semaine si défini
if (!empty($capacite->jour) && isset($jours_map[$capacite->jour])) {
$where[] = "DAYOFWEEK(date_rdv) = %d";
$values[] = $jours_map[$capacite->jour];
}
// Filtrer par période si défini
if (!empty($capacite->periode)) {
if ($capacite->periode === 'matin') {
$where[] = "heure_rdv < '12:00:00'";
} elseif ($capacite->periode === 'apres_midi') {
$where[] = "heure_rdv >= '12:00:00'";
}
// Si période = 'journee', on ne filtre pas par heure
}
$where_sql = 'WHERE ' . implode(' AND ', $where);
$sql = "SELECT EXISTS(SELECT 1 FROM {$table_name} {$where_sql} LIMIT 1)";
$prepared_sql = $wpdb->prepare($sql, $values);
$exists = (int) $wpdb->get_var($prepared_sql);
return $exists === 1;
}
/**
* Valide la sauvegarde d'un post traduction_langue
* Empêche toute modification si des événements existent déjà
*
* @param int $post_id ID du post en cours de sauvegarde
*/
public static function validate_save_post_with_events($post_id) {
// Vérifier que c'est bien un post traduction_langue
$post = \get_post($post_id);
if (!$post || $post->post_type !== 'traduction_langue') {
return;
}
// Ignorer pour les nouveaux posts (auto-draft)
if ($post->post_status === 'auto-draft') {
return;
}
// Ignorer si c'est une création (le post n'existait pas avant)
// On vérifie si le post a été créé récemment (moins de 2 minutes)
$post_date = strtotime($post->post_date);
$current_time = current_time('timestamp');
$is_new_post = ($current_time - $post_date) < 120; // 2 minutes
if ($is_new_post) {
return;
}
// Vérifier si la capacité a des événements associés
$has_events = self::check_events_for_capacite($post_id);
if (!$has_events) {
return; // Pas d'événements, on autorise la modification
}
// Si des événements existent, vérifier si des champs structurants ont été modifiés
$fields_to_check = ['langue', 'jour', 'periode', 'limite_par'];
$has_changes = false;
$changed_fields = [];
foreach ($fields_to_check as $field_name) {
// Récupérer la valeur actuelle en BDD
$current_value = \get_field($field_name, $post_id);
// Récupérer la nouvelle valeur depuis $_POST
$new_value = null;
if ($field_name === 'langue') {
// Pour le champ ACF langue (peut être multiple)
$field_key = 'field_capacite_langue';
if (isset($_POST['acf'][$field_key])) {
$new_value = $_POST['acf'][$field_key];
}
// Normaliser les valeurs pour comparaison (toujours en tableau)
if (!is_array($current_value)) {
$current_value = !empty($current_value) ? [$current_value] : [];
}
if (!is_array($new_value)) {
$new_value = !empty($new_value) ? [$new_value] : [];
}
// Trier pour comparaison correcte
sort($current_value);
sort($new_value);
} else {
// Pour les champs ACF standards
$field_key = 'field_capacite_' . $field_name;
if (isset($_POST['acf'][$field_key])) {
$new_value = $_POST['acf'][$field_key];
}
}
// Comparer les valeurs
if ($new_value !== null && $new_value != $current_value) {
$has_changes = true;
$changed_fields[] = $field_name;
}
}
// Si des modifications ont été détectées sur les champs structurants
if ($has_changes) {
$fields_labels = [
'langue' => 'Langue',
'jour' => 'Jour',
'periode' => 'Période',
'limite_par' => 'Limite par'
];
$changed_labels = array_map(function($field) use ($fields_labels) {
return $fields_labels[$field] ?? $field;
}, $changed_fields);
$error_message = sprintf(
'Impossible de modifier cette capacité de traduction car elle est utilisée par des événements existants. ' .
'Champs modifiés : %s. ' .
'Vous devez d\'abord supprimer ou réassigner les événements liés.',
implode(', ', $changed_labels)
);
\acf_add_validation_error('', $error_message);
}
}
/**
* Enregistre la page admin personnalisée pour les capacités de traduction
*/
public static function register_admin_page() {
\add_menu_page(
'Capacités de traduction', // Titre de la page
'Capacités traduction', // Titre du menu
'edit_posts', // Capability requise
'traduction-langues', // Slug de la page
[self::class, 'render_admin_page'], // Fonction de rendu
'dashicons-translation', // Icône
30 // Position dans le menu
);
}
/**
* Affiche la page admin personnalisée
*/
public static function render_admin_page() {
// Récupérer toutes les capacités
$parent_capacites = \get_posts([
'post_type' => 'traduction_langue',
'posts_per_page' => -1,
'post_status' => 'any',
'orderby' => 'title',
'order' => 'ASC',
]);
// Charger les scripts et styles
\wp_enqueue_style(
'traduction-langue-list',
\plugins_url('assets/js/dist/traduction-langue-list.min.css', dirname(__DIR__, 1)),
[],
'1.0.0'
);
\wp_enqueue_script(
'traduction-langue-list',
\plugins_url('assets/js/dist/traduction-langue-list.min.js', dirname(__DIR__, 1)),
['jquery'],
'1.0.0',
true
);
// Afficher le template
include dirname(__DIR__, 2) . '/templates/admin/traduction-langue-list.php';
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace ESI_CRVI_AGENDA\controllers;
class CRVI_Type_Intervention_Controller {
public static function register_cpt() {
register_post_type('type_intervention', [
'label' => "Types d'intervention",
'labels' => [
'name' => "Types d'intervention",
'singular_name' => "Type d'intervention",
'add_new' => "Ajouter un type d'intervention",
'add_new_item' => "Ajouter un nouveau type d'intervention",
'edit_item' => "Modifier le type d'intervention",
'new_item' => "Nouveau type d'intervention",
'view_item' => "Voir le type d'intervention",
'search_items' => "Rechercher un type d'intervention",
'not_found' => "Aucun type d'intervention trouvé",
'not_found_in_trash' => "Aucun type d'intervention dans la corbeille",
],
'public' => false,
'show_ui' => true,
'show_in_menu' => true,
'menu_position' => 26,
'supports' => ['title'],
'capability_type' => 'post',
]);
}
}

88
app/crons/actions.php Normal file
View File

@ -0,0 +1,88 @@
<?php
/**
* Fichier d'actions pour les crons du plugin ESI_crvi_agenda
*
* Ce fichier fait uniquement le routage vers les méthodes appropriées
* dans les classes Event_Model et Notifications_Controller.
*
* Actions disponibles via paramètres GET :
* - action=get_today_incomplete : Récupère tous les événements d'aujourd'hui non complétés
* - action=send_sms_reminder : Envoie des SMS de rappel pour les événements dans X jours
* Paramètres additionnels :
* - days : nombre de jours avant l'événement (1, 2 ou 3, défaut: 3)
*
* Usage :
* /wp-content/plugins/ESI_crvi_agenda/app/crons/actions.php?key=K7mP9xQ2vN4rT8&action=get_today_incomplete
* /wp-content/plugins/ESI_crvi_agenda/app/crons/actions.php?key=K7mP9xQ2vN4rT8&action=send_sms_reminder&days=3
*
* Sécurité : Une clé secrète est requise via le paramètre GET 'key'
*/
define('WP_USE_THEMES', false);
// Clé secrète pour sécuriser l'accès aux actions cron
define('CRON_ACTIONS_SECRET_KEY', 'K7mP9xQ2vN4rT8');
// Vérification de la clé secrète via paramètre GET
if (!isset($_GET['key']) || $_GET['key'] !== CRON_ACTIONS_SECRET_KEY) {
http_response_code(403);
header('Content-Type: application/json');
die(json_encode(['error' => 'Accès refusé. Clé secrète requise.']));
}
// Déterminer le chemin du plugin
$plugin_dir = dirname(dirname(__DIR__));
$wp_load_path = $plugin_dir . '/../../../../wp-load.php';
// Vérifier que wp-load.php existe
if (!file_exists($wp_load_path)) {
http_response_code(500);
header('Content-Type: application/json');
die(json_encode(['error' => 'Erreur : wp-load.php introuvable.']));
}
require_once $wp_load_path;
// Charger les classes nécessaires
require_once dirname(__DIR__) . '/models/Event_Model.php';
require_once dirname(__DIR__) . '/controllers/Notifications_Controller.php';
use ESI_CRVI_AGENDA\models\CRVI_Event_Model;
use ESI_CRVI_AGENDA\controllers\CRVI_Notifications_Controller;
// Récupérer l'action demandée
$action = isset($_GET['action']) ? sanitize_text_field($_GET['action']) : '';
// Définir le header JSON
header('Content-Type: application/json; charset=utf-8');
// Router vers l'action appropriée
switch ($action) {
case 'get_today_incomplete':
$result = CRVI_Event_Model::get_today_incomplete_events();
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
break;
case 'send_sms_reminder':
$days = isset($_GET['days']) ? (int) $_GET['days'] : 3;
$result = CRVI_Notifications_Controller::send_sms_reminder($days);
// Si c'est une erreur de validation, retourner un code 400
if (isset($result['error'])) {
http_response_code(400);
}
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
break;
default:
http_response_code(400);
echo json_encode([
'error' => 'Action non reconnue',
'available_actions' => [
'get_today_incomplete' => 'Récupère tous les événements d\'aujourd\'hui non complétés',
'send_sms_reminder' => 'Envoie des SMS de rappel pour les événements dans X jours (paramètre: days=1|2|3)'
]
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
break;
}

View File

@ -0,0 +1,44 @@
<?php
/**
* Autoloader pour les crons du plugin ESI_crvi_agenda
*
* Sécurité : Vérification d'une clé secrète via paramètre GET
* Usage : /wp-content/plugins/ESI_crvi_agenda/app/crons/crons_autoloader.php?key=VOTRE_CLE_SECRETE
*/
// Clé secrète pour sécuriser l'accès aux crons
/* define('CRON_SECRET_KEY', 'votre_cle_secrete_changez_moi_123456789');
// Vérification de la clé secrète via paramètre GET
if (!isset($_GET['key']) || $_GET['key'] !== CRON_SECRET_KEY) {
http_response_code(403);
die('Accès refusé. Clé secrète requise.');
} */
define('WP_USE_THEMES', false);
// Déterminer le chemin du plugin
$plugin_dir = dirname(dirname(__DIR__));
$wp_load_path = $plugin_dir . '/../../../../wp-load.php';
// Vérifier que wp-load.php existe
if (!file_exists($wp_load_path)) {
http_response_code(500);
die('Erreur : wp-load.php introuvable.');
}
require_once $wp_load_path;
// Charger gold-init.php si la classe GOLD n'existe pas
// Le fichier gold-init.php devrait être dans le répertoire app/
$gold_init_path = dirname(__DIR__) . '/gold-init.php';
if (!class_exists('GOLD') && file_exists($gold_init_path)) {
require_once $gold_init_path;
}
// Initialiser GOLD si disponible
if (class_exists('GOLD')) {
/** @var GOLD $GOLD */
$GOLD = GOLD::instance();
$GOLD->init();
}

2
app/factory.php Normal file
View File

@ -0,0 +1,2 @@
<?php
// Factory pour CRVI Agenda

563
app/helpers/Api_Helper.php Normal file
View File

@ -0,0 +1,563 @@
<?php
declare(strict_types=1);
namespace ESI_CRVI_AGENDA\helpers;
use WP_Error;
class Api_Helper {
/**
* Retourne une erreur API standardisée (format JSON).
* @param string $message
* @param int $code
* @return WP_Error
*/
public static function json_error(string $message, int $code = 400): WP_Error {
return new WP_Error('api_error', $message, ['status' => $code]);
}
/**
* Retourne une réponse API standardisée pour succès.
* @param mixed $data
* @return array
*/
public static function json_success($data): array {
return [
'success' => true,
'data' => $data,
];
}
/**
* Vérifie si l'utilisateur est prêt à être vérifié (WordPress initialisé + utilisateur connecté).
* @return bool
*/
public static function is_user_ready(): bool {
return did_action('init') && is_user_logged_in();
}
/**
* Vérifie si l'utilisateur courant a un rôle donné.
* @param string|array $roles
* @return bool
*/
public static function check_role($roles): bool {
// S'assurer que WordPress est complètement chargé
if (!did_action('init')) {
return false;
}
// Vérifier si l'utilisateur est connecté
if (!is_user_logged_in()) {
return false;
}
// Récupérer l'utilisateur courant
$user = wp_get_current_user();
// Vérifier que l'utilisateur est valide
if (!$user || !$user->exists()) {
return false;
}
// Vérifier que les rôles sont bien définis
if (empty($user->roles) || !is_array($user->roles)) {
return false;
}
if (is_array($roles)) {
foreach ($roles as $role) {
if (in_array($role, $user->roles, true)) {
return true;
}
}
return false;
} else {
return in_array($roles, $user->roles, true);
}
}
/**
* Récupère toutes les langues de la taxonomie 'langue_beneficiaire'.
* @param bool $simple_list Si true, retourne un tableau simple [id => nom]
* @return array
*/
public static function get_languages($simple_list = false): array {
$terms = get_terms([
'taxonomy' => 'langue_beneficiaire',
'hide_empty' => false,
'orderby' => 'name',
'order' => 'ASC',
]);
if (is_wp_error($terms) || empty($terms)) {
return [];
}
if ($simple_list) {
$languages = [];
foreach ($terms as $term) {
$languages[] = [
'id' => $term->term_id,
'nom' => $term->name,
'slug' => $term->slug,
];
}
return $languages;
}
return $terms;
}
/**
* Récupère une langue spécifique par son slug.
* @param string $slug
* @return array|null
*/
public static function get_language_by_slug($slug): ?array {
$term = get_term_by('slug', $slug, 'langue_beneficiaire');
if (!$term || is_wp_error($term)) {
return null;
}
return [
'id' => $term->term_id,
'nom' => $term->name,
'slug' => $term->slug,
];
}
/**
* Vérifie si l'utilisateur peut créer des événements.
* @return bool
*/
public static function can_create_events(): bool {
// Les admins ont tous les droits
if (self::check_role(['administrator', 'editor', 'crvi_manager'])) {
return true;
}
// Les intervenants peuvent créer des événements pour eux-mêmes
if (self::check_role(['intervenant'])) {
return true;
}
return false;
}
/**
* Vérifie si l'utilisateur peut modifier des événements.
* @param int|null $event_id ID de l'événement (optionnel)
* @return bool
*/
public static function can_edit_events($event_id = null): bool {
// Les admins ont tous les droits
if (self::check_role(['administrator', 'editor', 'crvi_manager'])) {
return true;
}
// Les intervenants peuvent éditer leurs propres événements
if (self::check_role(['intervenant'])) {
// Si un event_id est fourni, vérifier que l'événement appartient à l'intervenant
if ($event_id) {
$model = new \ESI_CRVI_AGENDA\models\CRVI_Event_Model();
$event = $model->get_details($event_id);
$current_user_id = get_current_user_id();
// Vérifier si l'utilisateur est l'intervenant assigné à cet événement
if ($event && isset($event->id_intervenant) && $event->id_intervenant == $current_user_id) {
return true;
}
return false;
}
// Sans event_id spécifique, autoriser (le filtre se fera au niveau de l'événement)
return true;
}
return false;
}
/**
* Vérifie si l'utilisateur peut supprimer des événements.
* @param int|null $event_id ID de l'événement (optionnel)
* @return bool
*/
public static function can_delete_events($event_id = null): bool {
return self::check_role(['administrator', 'editor']);
}
/**
* Vérifie si l'utilisateur peut clôturer des événements.
* @param int|null $event_id ID de l'événement (optionnel)
* @return bool
*/
public static function can_close_events($event_id = null): bool {
return self::check_role(['administrator', 'editor', 'crvi_manager']);
}
/**
* Vérifie si l'utilisateur peut voir les événements.
* @return bool
*/
public static function can_view_events(): bool {
// Les admins et viewers ont accès
if (self::check_role(['administrator', 'editor', 'crvi_manager', 'crvi_viewer'])) {
return true;
}
// Les intervenants peuvent voir leurs événements et ceux de leurs collègues
if (self::check_role(['intervenant'])) {
return true;
}
return false;
}
/**
* Retourne toutes les permissions de l'utilisateur courant pour l'agenda.
* @return array
*/
public static function get_user_permissions(): array {
// Vérifier si l'utilisateur est connecté et chargé
if (!is_user_logged_in()) {
return [
'can_create' => false,
'can_edit' => false,
'can_delete' => false,
'can_close' => false,
'can_view' => false,
'user_roles' => [],
'user_id' => 0,
];
}
$user = wp_get_current_user();
$user_roles = $user && $user->exists() ? $user->roles : [];
$user_id = get_current_user_id();
return [
'can_create' => self::can_create_events(),
'can_edit' => self::can_edit_events(),
'can_delete' => self::can_delete_events(),
'can_close' => self::can_close_events(),
'can_view' => self::can_view_events(),
'user_roles' => $user_roles,
'user_id' => $user_id,
];
}
/**
* Valide les données d'un événement.
* @param array $data Données de l'événement
* @param string $action 'create' ou 'update'
* @return array ['valid' => bool, 'errors' => array]
*/
public static function validate_event_data($data, $action = 'create'): array {
$errors = [];
$required_fields = [
'date_rdv' => 'Date de rendez-vous',
'heure_rdv' => 'Heure de rendez-vous',
'id_beneficiaire' => 'Bénéficiaire',
'id_intervenant' => 'Intervenant',
'id_local' => 'Local',
];
// Vérifier les champs requis
foreach ($required_fields as $field => $label) {
if (empty($data[$field])) {
$errors[] = "Le champ '$label' est requis.";
}
}
// Validation spécifique pour la création
if ($action === 'create') {
// Vérifier que la date n'est pas dans le passé
if (!empty($data['date_rdv'])) {
$date_rdv = strtotime($data['date_rdv']);
$today = strtotime('today');
if ($date_rdv < $today) {
$errors[] = "La date de rendez-vous ne peut pas être dans le passé.";
}
}
}
// Validation de l'heure
if (!empty($data['heure_rdv'])) {
if (!preg_match('/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/', $data['heure_rdv'])) {
$errors[] = "Le format de l'heure n'est pas valide (HH:MM).";
}
}
// Validation de la date
if (!empty($data['date_rdv'])) {
$date = \DateTime::createFromFormat('Y-m-d', $data['date_rdv']);
if (!$date || $date->format('Y-m-d') !== $data['date_rdv']) {
$errors[] = "Le format de la date n'est pas valide (YYYY-MM-DD).";
}
}
// Validation des IDs (doivent être des entiers positifs)
$id_fields = ['id_beneficiaire', 'id_intervenant', 'id_local', 'id_traducteur'];
foreach ($id_fields as $field) {
if (!empty($data[$field]) && (!is_numeric($data[$field]) || (int)$data[$field] <= 0)) {
$errors[] = "L'ID du champ '$field' doit être un nombre entier positif.";
}
}
// Validation de cohérence des dates/heures
if (!empty($data['date_fin']) && !empty($data['date_rdv'])) {
if ($data['date_fin'] < $data['date_rdv']) {
$errors[] = "La date de fin doit être après ou égale à la date de début";
} elseif ($data['date_fin'] === $data['date_rdv'] && !empty($data['heure_fin']) && !empty($data['heure_rdv'])) {
// Si même jour, vérifier que l'heure de fin est après l'heure de début
if ($data['heure_fin'] <= $data['heure_rdv']) {
$errors[] = "L'heure de fin doit être après l'heure de début pour un événement sur la même journée";
}
}
}
return [
'valid' => empty($errors),
'errors' => $errors,
];
}
/**
* Valide les données de disponibilité.
* @param array $data Données de disponibilité
* @return array ['valid' => bool, 'errors' => array]
*/
public static function validate_availability_data($data): array {
$errors = [];
// Vérifier que au moins une date est fournie
if (empty($data['date']) && empty($data['date_rdv'])) {
$errors[] = "Une date doit être fournie pour vérifier les disponibilités.";
}
// Validation de la date si fournie
$date = $data['date'] ?? $data['date_rdv'] ?? null;
if ($date) {
$date_obj = \DateTime::createFromFormat('Y-m-d', $date);
if (!$date_obj || $date_obj->format('Y-m-d') !== $date) {
$errors[] = "Le format de la date n'est pas valide (YYYY-MM-DD).";
}
}
// Validation de l'heure si fournie
$heure = $data['heure'] ?? $data['heure_rdv'] ?? null;
if ($heure && !preg_match('/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/', $heure)) {
$errors[] = "Le format de l'heure n'est pas valide (HH:MM).";
}
return [
'valid' => empty($errors),
'errors' => $errors,
];
}
/**
* Vérifie les conflits de disponibilité.
* @param string $date Date au format Y-m-d
* @param string $heure Heure au format H:i
* @param int $intervenant_id ID de l'intervenant
* @param int $traducteur_id ID du traducteur
* @param int $local_id ID du local
* @param int|null $exclude_event_id ID de l'événement à exclure (pour l'édition)
* @return array ['has_conflicts' => bool, 'conflicts' => array]
*/
public static function check_availability_conflicts($date, $heure, $intervenant_id, $traducteur_id, $local_id, $exclude_event_id = null): array {
global $wpdb;
$conflicts = [];
// Vérifier les conflits pour l'intervenant
if ($intervenant_id) {
$intervenant_conflicts = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}crvi_agenda
WHERE date_rdv = %s
AND heure_rdv = %s
AND id_intervenant = %d
AND id != %d",
$date, $heure, $intervenant_id, $exclude_event_id ?? 0
));
if (!empty($intervenant_conflicts)) {
$conflicts['intervenant'] = $intervenant_conflicts;
}
}
// Vérifier les conflits pour le traducteur
if ($traducteur_id) {
$traducteur_conflicts = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}crvi_agenda
WHERE date_rdv = %s
AND heure_rdv = %s
AND id_traducteur = %d
AND id != %d",
$date, $heure, $traducteur_id, $exclude_event_id ?? 0
));
if (!empty($traducteur_conflicts)) {
$conflicts['traducteur'] = $traducteur_conflicts;
}
}
// Vérifier les conflits pour le local
if ($local_id) {
$local_conflicts = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}crvi_agenda
WHERE date_rdv = %s
AND heure_rdv = %s
AND id_local = %d
AND id != %d",
$date, $heure, $local_id, $exclude_event_id ?? 0
));
if (!empty($local_conflicts)) {
$conflicts['local'] = $local_conflicts;
}
}
return [
'has_conflicts' => !empty($conflicts),
'conflicts' => $conflicts,
];
}
/**
* Récupère les options d'un champ ACF pour créer des éléments HTML dynamiques.
* Basé sur get_field_object() d'Advanced Custom Fields.
*
* @param string $field_name Nom ou clé du champ ACF
* @param int|false $post_id ID du post (optionnel, défaut: post courant)
* @param bool $format_value Si true, applique le formatage (défaut: true)
* @param bool $load_value Si true, charge la valeur du champ (défaut: true)
* @param bool $escape_html Si true, échappe le HTML (défaut: false)
* @return array|null Retourne un tableau avec les options formatées ou null si erreur
*/
public static function get_acf_field_options($field_name, $post_id = false, $format_value = true, $load_value = true, $escape_html = false): ?array {
// Vérifier que ACF est disponible
if (!function_exists('get_field_object')) {
return null;
}
// Récupérer l'objet champ ACF
$field_object = get_field_object($field_name, $post_id, $format_value, $load_value, $escape_html);
if (!$field_object || is_wp_error($field_object)) {
return null;
}
$result = [
'field_info' => [
'name' => $field_object['name'] ?? '',
'label' => $field_object['label'] ?? '',
'type' => $field_object['type'] ?? '',
'key' => $field_object['key'] ?? '',
'required' => (bool)($field_object['required'] ?? false),
'instructions' => $field_object['instructions'] ?? '',
],
'options' => [],
'current_value' => $field_object['value'] ?? null,
'html_attributes' => [
'id' => $field_object['id'] ?? '',
'class' => $field_object['class'] ?? '',
]
];
// Traiter selon le type de champ
switch ($field_object['type']) {
case 'select':
case 'checkbox':
case 'radio':
if (isset($field_object['choices']) && is_array($field_object['choices'])) {
$result['options'] = [];
foreach ($field_object['choices'] as $value => $label) {
$result['options'][] = [
'value' => $value,
'label' => $label,
'selected' => $field_object['value'] == $value
];
}
}
break;
case 'true_false':
$result['options'] = [
[
'value' => '1',
'label' => $field_object['ui_on_text'] ?? 'Oui',
'selected' => (bool)$field_object['value']
],
[
'value' => '0',
'label' => $field_object['ui_off_text'] ?? 'Non',
'selected' => !(bool)$field_object['value']
]
];
break;
case 'page_link':
case 'post_object':
if (isset($field_object['choices']) && is_array($field_object['choices'])) {
$result['options'] = [];
foreach ($field_object['choices'] as $value => $label) {
$result['options'][] = [
'value' => $value,
'label' => $label,
'selected' => $field_object['value'] == $value
];
}
}
break;
case 'user':
if (isset($field_object['choices']) && is_array($field_object['choices'])) {
$result['options'] = [];
foreach ($field_object['choices'] as $value => $label) {
$result['options'][] = [
'value' => $value,
'label' => $label,
'selected' => $field_object['value'] == $value
];
}
}
break;
case 'taxonomy':
if (isset($field_object['choices']) && is_array($field_object['choices'])) {
$result['options'] = [];
foreach ($field_object['choices'] as $value => $label) {
$result['options'][] = [
'value' => $value,
'label' => $label,
'selected' => $field_object['value'] == $value
];
}
}
break;
case 'repeater':
// Pour les repeaters, on retourne la structure mais pas les options
$result['is_repeater'] = true;
$result['sub_fields'] = isset($field_object['sub_fields']) ? $field_object['sub_fields'] : [];
break;
case 'flexible_content':
// Pour le contenu flexible, on retourne les layouts disponibles
$result['is_flexible'] = true;
$result['layouts'] = isset($field_object['layouts']) ? $field_object['layouts'] : [];
break;
default:
// Pour les autres types (text, textarea, etc.), on retourne juste les infos de base
$result['is_simple_field'] = true;
break;
}
return $result;
}
}

View File

@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
namespace ESI_CRVI_AGENDA\helpers;
use ESI_CRVI_AGENDA\models\CRVI_Event_Model;
use ESI_CRVI_AGENDA\models\CRVI_Departement_Model;
use ESI_CRVI_AGENDA\models\CRVI_Type_Intervention_Model;
use ESI_CRVI_AGENDA\models\CRVI_Traducteur_Model;
use ESI_CRVI_AGENDA\models\CRVI_Intervenant_Model;
class Stats_Helper
{
/**
* Traite les paramètres du formulaire et retourne un tableau structuré pour l'export CSV.
* @param array $params
* @return array [success, code, message, csv: [header, rows]]
*/
public static function handle_form(array $params = []): array
{
// Validation des paramètres
$entite = $params['entite'] ?? 'global';
$annee = isset($params['annee']) ? intval($params['annee']) : null;
$date_debut = $params['date_debut'] ?? '';
$date_fin = $params['date_fin'] ?? '';
$attributes = [];
// Détermination de la période
if ($annee) {
$begin = $annee . '-01-01';
$end = $annee . '-12-31';
} elseif ($date_debut && $date_fin) {
$begin = $date_debut;
$end = $date_fin;
} else {
return [
'success' => false,
'code' => 'invalid_params',
'message' => __('Veuillez renseigner une année ou une plage de dates.', 'esi_crvi_agenda'),
'csv' => null
];
}
// Dispatch selon l'entité
switch ($entite) {
case 'global':
$result = self::events_stats($begin, $end, $attributes);
break;
// case 'intervenant':
// $result = self::intervenant_stats($begin, $end, $attributes);
// break;
// case 'departement':
// $result = self::departement_stats($begin, $end, $attributes);
// break;
// case 'type_intervention':
// $result = self::type_intervention_stats($begin, $end, $attributes);
// break;
// case 'traducteur':
// $result = self::traducteur_stats($begin, $end, $attributes);
// break;
default:
return [
'success' => false,
'code' => 'invalid_params',
'message' => __('Entité non reconnue.', 'esi_crvi_agenda'),
'csv' => null
];
}
return $result;
}
/**
* Exemple : Statistiques globales sur les événements.
* @param string $begin
* @param string $end
* @param array $attributes
* @return array
*/
public static function events_stats(string $begin, string $end, array $attributes = []): array
{
$event_model = new CRVI_Event_Model();
$events = $event_model->get_events_by('date_rdv', $begin, $attributes, $begin, $end);
if (empty($events)) {
return [
'success' => false,
'code' => 'no_data',
'message' => __('Aucune donnée trouvée pour la période sélectionnée.', 'esi_crvi_agenda'),
'csv' => null
];
}
// Exemple d'en-tête et de lignes (à adapter selon besoins réels)
$header = ['ID', 'Date', 'Heure', 'Type', 'Statut', 'Département', 'Type intervention', 'Langue'];
$rows = [];
foreach ($events as $event) {
$rows[] = [
$event['id'] ?? '',
$event['date_rdv'] ?? '',
$event['heure_rdv'] ?? '',
$event['type'] ?? '',
$event['statut'] ?? '',
$event['departement'] ?? '',
$event['type_intervention'] ?? '',
$event['langue'] ?? '',
];
}
return [
'success' => true,
'code' => 'success',
'message' => __('Export CSV généré avec succès.', 'esi_crvi_agenda'),
'csv' => [
'header' => $header,
'rows' => $rows
]
];
}
// À compléter : intervenant_stats, departement_stats, etc.
//intervenant_stats
/* public static function intervenant_stats(string $begin, string $end, array $attributes = []): array
{
$intervenants = CRVI_Intervenant_Model::all();
$event_model = new CRVI_Event_Model();
$header = [__('Intervenant', 'esi_crvi_agenda'), __('Nombre d\'événements', 'esi_crvi_agenda')];
$rows = [];
foreach ($intervenants as $intervenant) {
$count = count($event_model->get_events_by('id_intervenant', (string)$intervenant->ID, $attributes, $begin, $end));
if ($count > 0) {
$rows[] = [
get_the_title($intervenant->ID),
$count
];
}
}
if (empty($rows)) {
return [
'success' => false,
'code' => 'no_data',
'message' => __('Aucune donnée trouvée pour la période sélectionnée.', 'esi_crvi_agenda'),
'csv' => null
];
}
return [
'success' => true,
'code' => 'success',
'message' => __('Export CSV généré avec succès.', 'esi_crvi_agenda'),
'csv' => [
'header' => $header,
'rows' => $rows
]
];
} */
//departement_stats
public static function departement_stats(string $begin, string $end, array $attributes = []): array
{
// Récupérer tous les départements (CPT)
$departements = \ESI_CRVI_AGENDA\models\CRVI_Departement_Model::all();
$event_model = new \ESI_CRVI_AGENDA\models\CRVI_Event_Model();
$header = [__('Département', 'esi_crvi_agenda'), __('Nombre d\'événements', 'esi_crvi_agenda')];
$rows = [];
foreach ($departements as $departement) {
$count = count($event_model->get_events_by('departement', (string)$departement->ID, $attributes, $begin, $end));
if ($count > 0) {
$rows[] = [
get_the_title($departement->ID),
$count
];
}
}
if (empty($rows)) {
return [
'success' => false,
'code' => 'no_data',
'message' => __('Aucune donnée trouvée pour la période sélectionnée.', 'esi_crvi_agenda'),
'csv' => null
];
}
return [
'success' => true,
'code' => 'success',
'message' => __('Export CSV généré avec succès.', 'esi_crvi_agenda'),
'csv' => [
'header' => $header,
'rows' => $rows
]
];
}
//type_intervention_stats
public static function type_intervention_stats(string $begin, string $end, array $attributes = []): array
{
// Récupérer tous les types d'intervention (CPT)
$types = \ESI_CRVI_AGENDA\models\CRVI_Type_Intervention_Model::all();
$event_model = new \ESI_CRVI_AGENDA\models\CRVI_Event_Model();
$header = [__('Type d\'intervention', 'esi_crvi_agenda'), __('Nombre d\'événements', 'esi_crvi_agenda')];
$rows = [];
foreach ($types as $type) {
$count = count($event_model->get_events_by('type_intervention', (string)$type->ID, $attributes, $begin, $end));
if ($count > 0) {
$rows[] = [
get_the_title($type->ID),
$count
];
}
}
if (empty($rows)) {
return [
'success' => false,
'code' => 'no_data',
'message' => __('Aucune donnée trouvée pour la période sélectionnée.', 'esi_crvi_agenda'),
'csv' => null
];
}
return [
'success' => true,
'code' => 'success',
'message' => __('Export CSV généré avec succès.', 'esi_crvi_agenda'),
'csv' => [
'header' => $header,
'rows' => $rows
]
];
}
}

2
app/hooks.php Normal file
View File

@ -0,0 +1,2 @@
<?php
// Hooks personnalisés pour CRVI Agenda

2
app/install.php Normal file
View File

@ -0,0 +1,2 @@
<?php
// Installation et désinstallation du plugin CRVI Agenda

1
app/libraries/.gitkeep Normal file
View File

@ -0,0 +1 @@

View File

@ -0,0 +1,2 @@
<?php
// Factory pour CRVI Agenda

2
app/libraries/hooks.php Normal file
View File

@ -0,0 +1,2 @@
<?php
// Hooks personnalisés pour CRVI Agenda

0
app/logs/debug.txt Normal file
View File

1
app/models/.gitkeep Normal file
View File

@ -0,0 +1 @@

View File

@ -0,0 +1,654 @@
<?php
declare(strict_types=1);
namespace ESI_CRVI_AGENDA\models;
use Carbon\Carbon;
use ESI_CRVI_AGENDA\helpers\Api_Helper;
class CRVI_Beneficiaire_Model extends Main_Model {
public $id;
public $nom;
public $prenom;
public $email;
public $telephone;
public $langues_parlees;
public $commentaire;
public $statut;
/**
* Carte des codes de langue vers le nom de la langue (Europe continentale + Afrique du Nord)
*/
public static $lg_map = [
'fr' => 'Français',
'de' => 'Allemand',
'it' => 'Italien',
'es' => 'Espagnol',
'pt' => 'Portugais',
'nl' => 'Néerlandais',
'en' => 'Anglais',
'ru' => 'Russe',
'pl' => 'Polonais',
'ro' => 'Roumain',
'el' => 'Grec',
'tr' => 'Turc',
'ar' => 'Arabe',
'kab' => 'Kabyle',
'ber' => 'Berbère',
'tzm' => 'Tamazight',
'da' => 'Danois',
'sv' => 'Suédois',
'no' => 'Norvégien',
'fi' => 'Finnois',
'cs' => 'Tchèque',
'sk' => 'Slovaque',
'hu' => 'Hongrois',
'bg' => 'Bulgare',
'hr' => 'Croate',
'sr' => 'Serbe',
'bs' => 'Bosnien',
'sq' => 'Albanais',
'mk' => 'Macédonien',
'sl' => 'Slovène',
'he' => 'Hébreu',
'lt' => 'Lituanien',
'lv' => 'Letton',
'et' => 'Estonien',
];
/**
* Schéma des champs ACF : nom => type (ex : group, repeater, taxonomy, text...)
*/
public static $acf_schema = [
'nom' => 'text',
'prenom' => 'text',
'email' => 'email',
'telephone' => 'text',
'code_postal' => 'number',
'date_de_naissance' => 'date_picker',
'langues_parlees' => 'taxonomy',
'genre' => 'radio',
'commentaire' => 'textarea',
'statut' => 'select',
];
public function __construct($data = []) {
foreach ($data as $key => $value) {
if (property_exists($this, $key)) {
$this->$key = $value;
}
}
}
public function load_previous_benef_events($id) {
global $wpdb;
$table_name = $wpdb->prefix . 'crvi_agenda';
$events = $wpdb->get_results($wpdb->prepare("SELECT * FROM $table_name WHERE id_beneficiaire = %d", $id));
return $events;
}
public function benef_previous_events($events) {
$previous_events = [];
if(!empty($events)) {
foreach($events as $event) {
$event = CRVI_Event_Model::load($event->id);
$event->get_details($event->id);
$traducteur = CRVI_Traducteur_Model::load($event->id_traducteur);
$local = CRVI_Local_Model::load($event->id_local);
$previous_events['date'] = Carbon::parse($event->date)->format('d/m/Y');
$previous_events['heure'] = Carbon::parse($event->heure)->format('H:i');
$previous_events['local'] = $local->nom;
$previous_events['traducteur'] = $traducteur->nom.' '.$traducteur->prenom;
$previous_events['statut'] = $event->statut;
$previous_events['commentaire'] = $event->commentaire;
}
}
}
public function benef_previous_events_list($events) {
$previous_events = [];
$event_count = 0;
if(!empty($events)) {
foreach($events as $event) {
$previous_events[$event_count]['date'] = Carbon::parse($event->date)->format('d/m/Y');
$previous_events[$event_count]['heure'] = Carbon::parse($event->heure)->format('H:i');
$previous_events[$event_count]['local'] = $event->local;
$previous_events[$event_count]['traducteur'] = $event->traducteur;
$previous_events[$event_count]['statut'] = $event->statut;
$previous_events[$event_count]['commentaire'] = $event->commentaire;
}
$event_count++;
}
return $previous_events;
}
public function benef_previous_non_attended_events($events, $count = true) {
$previous_events = [];
$event_count = 0;
if(!empty($events)) {
foreach($events as $event) {
$statut = $event->statut;
if($statut == 'non_tenu') {
$previous_events[$event_count]['date'] = Carbon::parse($event->date)->format('d/m/Y');
$previous_events[$event_count]['heure'] = Carbon::parse($event->heure)->format('H:i');
$previous_events[$event_count]['local'] = $event->local;
$previous_events[$event_count]['traducteur'] = $event->traducteur;
$previous_events[$event_count]['statut'] = $event->statut;
}
}
$event_count++;
}
return $previous_events;
}
/**
* Récupère les absences d'un bénéficiaire
* @param int $beneficiaire_id
* @return array|WP_Error
*/
public function get_absences(int $beneficiaire_id) {
global $wpdb;
$table_name = $wpdb->prefix . 'crvi_agenda';
// Vérifier si la table existe
$table_exists = $wpdb->get_var("SHOW TABLES LIKE '$table_name'");
if (!$table_exists) {
return new \WP_Error('table_not_found', 'Table agenda non trouvée');
}
// Récupérer les événements où le bénéficiaire était absent
$query = $wpdb->prepare(
"SELECT id, date_rdv, heure_rdv, statut, commentaire
FROM $table_name
WHERE id_beneficiaire = %d
AND statut IN ('non_tenu', 'absent', 'annule')
ORDER BY date_rdv DESC, heure_rdv DESC
LIMIT 10",
$beneficiaire_id
);
$absences = $wpdb->get_results($query, ARRAY_A);
if ($wpdb->last_error) {
return new \WP_Error('db_error', 'Erreur lors de la récupération des absences: ' . $wpdb->last_error);
}
return $absences;
}
public static function load($id, $fields = []) {
// Charger depuis CPT/meta
$beneficiaire = get_post($id);
if (!$beneficiaire) {
return null;
}
// Si des champs spécifiques sont demandés, ne charger que ceux-ci
if (!empty($fields)) {
$data = [];
foreach ($fields as $field) {
if ($field === 'id') {
$data['id'] = $beneficiaire->ID;
} elseif (property_exists(self::class, $field)) {
$data[$field] = get_field($field, $beneficiaire->ID);
}
}
return new self($data);
}
// Sinon, charger tous les champs par défaut
return new self([
'id' => $beneficiaire->ID,
'nom' => get_field('nom', $beneficiaire->ID),
'prenom' => get_field('prenom', $beneficiaire->ID),
'email' => get_field('email', $beneficiaire->ID),
'telephone' => get_field('telephone', $beneficiaire->ID),
'langues_parlees' => get_field('langues_parlees', $beneficiaire->ID),
'commentaire' => get_field('commentaire', $beneficiaire->ID),
'statut' => get_field('statut', $beneficiaire->ID),
]);
}
/**
* Récupère tous les rendez-vous liés à un bénéficiaire
* @param int $beneficiaire_id
* @return array
*/
public static function get_rendezvous_by_beneficiaire(int $beneficiaire_id): array {
$args = [
'post_type' => 'crvi_agenda',
'numberposts' => -1,
'meta_query' => [
[
'key' => 'id_beneficiaire',
'value' => $beneficiaire_id,
'compare' => '=',
],
],
];
$rdvs = get_posts($args);
$result = [];
foreach ($rdvs as $rdv) {
$date = get_field('date', $rdv->ID);
$heure = get_field('heure', $rdv->ID);
$departements = get_field('departements', $rdv->ID); // à adapter selon structure
$departement_nom =get_term_by('id', $departements, 'departement');
$departement_nom = $departement_nom->name;
$type_intervention = get_field('type_intervention', $rdv->ID);
$type_intervention_nom =get_term_by('id', $type_intervention, 'type_intervention');
$type_intervention_nom = $type_intervention_nom->name;
$intervenant_id = get_field('id_intervenant', $rdv->ID);
$langue = get_field('langue', $rdv->ID);
$result[] = [
'id' => $rdv->ID,
'date' => $date,
'heure' => $heure,
'departements' => $departement_nom,
'type_intervention' => $type_intervention_nom,
'intervenant_id' => $intervenant_id,
'langue' => $langue,
];
}
return $result;
}
/**
* Récupère les intervenants liés à un bénéficiaire, avec le nombre de rdv
* @param int $beneficiaire_id
* @return array
*/
public static function get_intervenants_by_beneficiaire(int $beneficiaire_id): array {
$rdvs = self::get_rendezvous_by_beneficiaire($beneficiaire_id);
$intervenants = [];
foreach ($rdvs as $rdv) {
$intervenant_id = $rdv['intervenant_id'];
if (!$intervenant_id) continue;
if (!isset($intervenants[$intervenant_id])) {
$nom = get_field('nom', $intervenant_id);
$prenom = get_field('prenom', $intervenant_id);
$intervenants[$intervenant_id] = [
'id' => $intervenant_id,
'nom' => $nom,
'prenom' => $prenom,
'nb_rdv' => 1,
];
} else {
$intervenants[$intervenant_id]['nb_rdv']++;
}
}
return array_values($intervenants);
}
public function get_relations() {
$benef_id = $this->id;
$rdvs = self::get_rendezvous_by_beneficiaire($benef_id);
$futurs = [];
$passe = [];
$now = Carbon::now();
foreach ($rdvs as $rdv) {
// On suppose que la date est au format d/m/Y ou Y-m-d
$date = $rdv['date'];
$heure = $rdv['heure'] ?? '00:00';
$dt = null;
if ($date) {
$dt = Carbon::createFromFormat('d/m/Y H:i', $date.' '.$heure);
if (!$dt) {
$dt = Carbon::createFromFormat('Y-m-d H:i', $date.' '.$heure);
}
}
$rdv_info = [
'date' => $date,
'heure' => $heure,
'departements' => $rdv['departements'],
'type_intervention' => $rdv['type_intervention'],
'intervenant' => $rdv['intervenant_id'] ? [
'id' => $rdv['intervenant_id'],
'nom' => get_field('nom', $rdv['intervenant_id']),
'prenom' => get_field('prenom', $rdv['intervenant_id']),
] : null,
'langue' => $rdv['langue'],
];
if ($dt && $dt->greaterThanOrEqualTo($now)) {
$futurs[] = $rdv_info;
} else {
$passe[] = $rdv_info;
}
}
$intervenants = self::get_intervenants_by_beneficiaire($benef_id);
return [
'rendezvous' => [
'futurs' => $futurs,
'passe' => $passe,
],
'intervenants' => $intervenants,
];
}
public function get_all_beneficiaires($attributes = []) {
$args = [
'post_type' => 'beneficiaire',
'numberposts' => -1,
];
// Si des critères sont fournis, construire le meta_query
if (!empty($attributes)) {
$meta_query = ['relation' => 'AND'];
foreach ($attributes as $key => $value) {
$meta_query[] = [
'key' => $key,
'value' => $value,
'compare' => '=',
];
}
$args['meta_query'] = $meta_query;
}
$beneficiaires = get_posts($args);
$result = [];
foreach ($beneficiaires as $beneficiaire) {
$result[] = new self([
'id' => $beneficiaire->ID,
'nom' => get_field('nom', $beneficiaire->ID),
'prenom' => get_field('prenom', $beneficiaire->ID),
'email' => get_field('email', $beneficiaire->ID),
'telephone' => get_field('telephone', $beneficiaire->ID),
'langues_parlees' => get_field('langues_parlees', $beneficiaire->ID),
'commentaire' => get_field('commentaire', $beneficiaire->ID),
'statut' => get_field('statut', $beneficiaire->ID),
]);
}
return $result;
}
/**
* Créer ou mettre à jour un bénéficiaire (upsert par email).
* Utilisable en REST (retour structuré) ou import CSV (retour simple).
* @param array $data
* @param bool $as_rest
* @return array|string|false
*/
public static function create(array $data, bool $as_rest = false) {
// 1. Vérifier les droits (admin ou opérateur)
if (!current_user_can('edit_posts')) {
if ($as_rest) {
return [
'success' => false,
'code' => 403,
'message' => 'Non autorisé',
'data' => null
];
}
return false;
}
// 2. Valider les données (ex : nom, prénom, email obligatoires)
if (empty($data['nom']) || empty($data['prenom']) || empty($data['email'])) {
if ($as_rest) {
return [
'success' => false,
'code' => 400,
'message' => 'Champs obligatoires manquants',
'data' => null
];
}
return false;
}
// 3. Vérifier si le bénéficiaire existe déjà (unicité email)
$existing = get_posts([
'post_type' => 'beneficiaire',
'meta_key' => 'email',
'meta_value' => $data['email'],
'post_status'=> 'any',
'numberposts'=> 1,
]);
if ($existing) {
// Mise à jour
$post_id = $existing[0]->ID ?? $existing[0];
wp_update_post([
'ID' => $post_id,
'post_title' => ($data['nom'] ?? get_post_meta($post_id, 'nom', true)) . ' ' . ($data['prenom'] ?? get_post_meta($post_id, 'prenom', true)),
]);
foreach ($data as $key => $value) {
if ($key === 'langues_parlees') {
// Logique spécifique : créer le terme avec le bon nom (lg_map) et slug
if (!taxonomy_exists('langue_beneficiaire')) {
register_taxonomy(
'langue_beneficiaire',
'beneficiaire',
[
'label' => 'Langues bénéficiaire',
'public' => false,
'hierarchical' => false,
'show_ui' => true,
'show_in_rest' => false,
]
);
}
$langue_ids = [];
$langues = is_array($value) ? $value : explode('|', $value);
foreach ($langues as $slug) {
$slug = trim($slug);
if (empty($slug)) continue;
$term = get_term_by('slug', $slug, 'langue_beneficiaire');
if (!$term) {
$nom = self::$lg_map[$slug] ?? $slug;
$result = wp_insert_term($nom, 'langue_beneficiaire', ['slug' => $slug]);
if (!is_wp_error($result) && isset($result['term_id'])) {
$langue_ids[] = $result['term_id'];
}
} else {
$langue_ids[] = $term->term_id;
}
}
if (!empty($langue_ids)) {
update_field('langues_parlees', $langue_ids, $post_id);
}
} elseif (isset(self::$acf_schema[$key])) {
self::set_acf_field($post_id, $key, $value, self::$acf_schema);
} else {
update_post_meta($post_id, $key, $value);
}
}
if ($as_rest) {
return [
'success' => true,
'code' => 200,
'message' => 'Bénéficiaire mis à jour',
'data' => [ 'id' => $post_id ]
];
}
return 'updated';
} else {
// Création
$post_id = wp_insert_post([
'post_type' => 'beneficiaire',
'post_title' => $data['nom'] . ' ' . $data['prenom'],
'post_status' => 'publish',
]);
if (is_wp_error($post_id)) {
if ($as_rest) {
return [
'success' => false,
'code' => 500,
'message' => 'Erreur lors de la création',
'data' => null
];
}
return false;
}
foreach ($data as $key => $value) {
if ($key === 'langues_parlees') {
// Logique spécifique : créer le terme avec le bon nom (lg_map) et slug
if (!taxonomy_exists('langue_beneficiaire')) {
register_taxonomy(
'langue_beneficiaire',
'beneficiaire',
[
'label' => 'Langues bénéficiaire',
'public' => false,
'hierarchical' => false,
'show_ui' => true,
'show_in_rest' => false,
]
);
}
$langue_ids = [];
$langues = is_array($value) ? $value : explode('|', $value);
foreach ($langues as $slug) {
$slug = trim($slug);
if (empty($slug)) continue;
$term = get_term_by('slug', $slug, 'langue_beneficiaire');
if (!$term) {
$nom = self::$lg_map[$slug] ?? $slug;
$result = wp_insert_term($nom, 'langue_beneficiaire', ['slug' => $slug]);
if (!is_wp_error($result) && isset($result['term_id'])) {
$langue_ids[] = $result['term_id'];
}
} else {
$langue_ids[] = $term->term_id;
}
}
if (!empty($langue_ids)) {
update_field('langue_beneficiaire', $langue_ids, $post_id);
}
} elseif (isset(self::$acf_schema[$key])) {
self::set_acf_field($post_id, $key, $value, self::$acf_schema);
} else {
update_post_meta($post_id, $key, $value);
}
}
if ($as_rest) {
return [
'success' => true,
'code' => 201,
'message' => 'Bénéficiaire créé',
'data' => [ 'id' => $post_id ]
];
}
return 'created';
}
}
/**
* Mettre à jour un bénéficiaire.
* @param int $id
* @param array $data
* @return bool|WP_Error
*/
public function update(int $id, array $data) {
// 1. Vérifier les droits
if (!current_user_can('edit_posts')) {
return Api_Helper::json_error('Non autorisé', 403);
}
// 2. Vérifier l'existence du post
$post = get_post($id);
if (!$post || $post->post_type !== 'beneficiaire') {
return Api_Helper::json_error('Bénéficiaire introuvable', 404);
}
// 3. Valider les données (ex : email unique si modifié)
if (!empty($data['email'])) {
$existing = get_posts([
'post_type' => 'beneficiaire',
'meta_key' => 'email',
'meta_value' => $data['email'],
'post_status'=> 'any',
'exclude' => [$id],
'numberposts'=> 1,
]);
if ($existing) {
return Api_Helper::json_error('Email déjà utilisé', 409);
}
}
// 4. Mettre à jour le post
$result = wp_update_post([
'ID' => $id,
'post_title' => ($data['nom'] ?? get_post_meta($id, 'nom', true)) . ' ' . ($data['prenom'] ?? get_post_meta($id, 'prenom', true)),
], true);
if (is_wp_error($result)) {
return $result;
}
// 5. Mettre à jour les champs personnalisés
foreach ($data as $key => $value) {
update_post_meta($id, $key, $value);
}
return true;
}
/**
* Supprimer un bénéficiaire (corbeille WordPress)
* @param int $id
* @return bool|WP_Error
*/
public function delete(int $id) {
// 1. Vérifier les droits (admin uniquement)
if (!current_user_can('delete_posts')) {
return Api_Helper::json_error('Non autorisé', 403);
}
// 2. Vérifier l'existence du post
$post = get_post($id);
if (!$post || $post->post_type !== 'beneficiaire') {
return Api_Helper::json_error('Bénéficiaire introuvable', 404);
}
// 3. Mettre à la corbeille
$result = wp_trash_post($id);
if (!$result) {
return Api_Helper::json_error('Erreur lors de la suppression', 500);
}
return true;
}
/**
* Calcule l'âge à partir d'une date de naissance (format d/m/Y ou Y-m-d) ou d'un ID de bénéficiaire
* @param string|int|null $date_naissance_ou_id
* @param string $champ_acf Nom du champ ACF pour la date de naissance (par défaut 'date_de_naissance')
* @return int|null
*/
public static function calculer_age($date_naissance_ou_id, string $champ_acf = 'date_de_naissance'): ?int {
$date_naissance = null;
// Si c'est un entier, on considère que c'est un ID de bénéficiaire
if (is_int($date_naissance_ou_id)) {
$date_naissance = get_field($champ_acf, $date_naissance_ou_id);
} elseif (is_string($date_naissance_ou_id)) {
$date_naissance = $date_naissance_ou_id;
}
if (empty($date_naissance)) {
return null;
}
// Gérer le format d/m/Y (ACF) ou Y-m-d
$formats = ['d/m/Y', 'Y-m-d'];
foreach ($formats as $format) {
$date = \DateTime::createFromFormat($format, $date_naissance);
if ($date !== false) {
$now = new \DateTime();
$age = $now->diff($date)->y;
return $age;
}
}
// Si aucun format ne correspond
return null;
}
}

View File

@ -0,0 +1,279 @@
<?php
declare(strict_types=1);
namespace ESI_CRVI_AGENDA\models;
/**
* Modèle pour la gestion des présences et des personnes
*/
class CRVI_Presence_Model {
/**
* Crée les tables nécessaires pour les présences
*/
public static function create_tables(): void {
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
// Table des personnes (pour les participants non-bénéficiaires)
$persons_table = $wpdb->prefix . 'crvi_agenda_persons';
$sql_persons = "CREATE TABLE IF NOT EXISTS `{$persons_table}` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`nom` varchar(255) NOT NULL,
`prenom` varchar(255) NOT NULL,
`langue` varchar(100) DEFAULT NULL,
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_nom_prenom` (`nom`, `prenom`)
) {$charset_collate};";
// Table des présences
$presence_table = $wpdb->prefix . 'crvi_agenda_presence';
$sql_presence = "CREATE TABLE IF NOT EXISTS `{$presence_table}` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`event_id` int(11) NOT NULL,
`person_id` int(11) DEFAULT NULL,
`beneficiaire_id` int(11) DEFAULT NULL,
`is_present` tinyint(1) NOT NULL DEFAULT 0,
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_event_id` (`event_id`),
KEY `idx_person_id` (`person_id`),
KEY `idx_beneficiaire_id` (`beneficiaire_id`)
) {$charset_collate};";
require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
dbDelta($sql_persons);
dbDelta($sql_presence);
}
/**
* Insère ou récupère une personne dans la table wp_crvi_agenda_persons
* Si une personne avec le même nom et prénom existe déjà, retourne son ID
* Sinon, crée une nouvelle entrée
*
* @param string $nom
* @param string $prenom
* @param string $langue
* @return int L'ID de la personne
*/
public static function save_person(string $nom, string $prenom, string $langue): int {
global $wpdb;
$table_name = $wpdb->prefix . 'crvi_agenda_persons';
// Nettoyer les données
$nom = sanitize_text_field(trim($nom));
$prenom = sanitize_text_field(trim($prenom));
$langue = sanitize_text_field(trim($langue));
if (empty($nom) || empty($prenom)) {
throw new \InvalidArgumentException('Le nom et le prénom sont requis');
}
// Vérifier si une personne avec le même nom et prénom existe déjà
$existing = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM $table_name WHERE nom = %s AND prenom = %s LIMIT 1",
$nom,
$prenom
));
if ($existing) {
// Mettre à jour la langue si elle est différente
$wpdb->update(
$table_name,
['langue' => $langue],
['id' => $existing],
['%s'],
['%d']
);
return (int) $existing;
}
// Créer une nouvelle personne
$result = $wpdb->insert(
$table_name,
[
'nom' => $nom,
'prenom' => $prenom,
'langue' => $langue
],
['%s', '%s', '%s']
);
if ($result === false) {
throw new \RuntimeException('Erreur lors de l\'insertion de la personne');
}
return (int) $wpdb->insert_id;
}
/**
* Enregistre ou met à jour une présence dans la table wp_crvi_agenda_presence
* Si une présence existe déjà pour cet event_id et person_id/beneficiaire_id, elle est mise à jour
* Sinon, une nouvelle entrée est créée
*
* @param int $event_id
* @param int|null $person_id ID de la personne (si ce n'est pas un bénéficiaire existant)
* @param bool $is_present
* @param int|null $beneficiaire_id ID du bénéficiaire (si c'est un bénéficiaire existant)
* @return int L'ID de la présence (nouvelle ou mise à jour)
*/
public static function save_presence(int $event_id, ?int $person_id, bool $is_present, ?int $beneficiaire_id = null): int {
global $wpdb;
$table_name = $wpdb->prefix . 'crvi_agenda_presence';
// Vérifier si la table a le champ beneficiaire_id, sinon l'ajouter
self::ensure_beneficiaire_id_column($table_name);
// Si beneficiaire_id est fourni, utiliser beneficiaire_id, sinon utiliser person_id
if ($beneficiaire_id !== null) {
// Vérifier si une présence existe déjà pour cet événement et ce bénéficiaire
$existing = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM $table_name WHERE event_id = %d AND beneficiaire_id = %d LIMIT 1",
$event_id,
$beneficiaire_id
));
if ($existing) {
// Mettre à jour la présence existante
$wpdb->update(
$table_name,
['is_present' => $is_present ? 1 : 0],
['id' => $existing],
['%d'],
['%d']
);
return (int) $existing;
}
// Créer une nouvelle présence avec beneficiaire_id
$result = $wpdb->insert(
$table_name,
[
'event_id' => $event_id,
'person_id' => 0, // 0 ou NULL pour indiquer que c'est un bénéficiaire
'beneficiaire_id' => $beneficiaire_id,
'is_present' => $is_present ? 1 : 0
],
['%d', '%d', '%d', '%d']
);
} else {
// Utiliser person_id (comportement original)
if ($person_id === null) {
throw new \InvalidArgumentException('person_id ou beneficiaire_id doit être fourni');
}
// Vérifier si une présence existe déjà pour cet événement et cette personne
$existing = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM $table_name WHERE event_id = %d AND person_id = %d AND (beneficiaire_id IS NULL OR beneficiaire_id = 0) LIMIT 1",
$event_id,
$person_id
));
if ($existing) {
// Mettre à jour la présence existante
$wpdb->update(
$table_name,
['is_present' => $is_present ? 1 : 0],
['id' => $existing],
['%d'],
['%d']
);
return (int) $existing;
}
// Créer une nouvelle présence avec person_id
$result = $wpdb->insert(
$table_name,
[
'event_id' => $event_id,
'person_id' => $person_id,
'beneficiaire_id' => null,
'is_present' => $is_present ? 1 : 0
],
['%d', '%d', '%d', '%d']
);
}
if ($result === false) {
throw new \RuntimeException('Erreur lors de l\'insertion de la présence');
}
return (int) $wpdb->insert_id;
}
/**
* Vérifie et ajoute la colonne beneficiaire_id si elle n'existe pas
* @param string $table_name
*/
private static function ensure_beneficiaire_id_column(string $table_name): void {
global $wpdb;
// Vérifier si la colonne existe (le nom de la table est sûr car il vient de $wpdb->prefix)
$column_exists = $wpdb->get_results(
"SHOW COLUMNS FROM `{$table_name}` LIKE 'beneficiaire_id'"
);
if (empty($column_exists)) {
// Ajouter la colonne beneficiaire_id
$wpdb->query("ALTER TABLE `{$table_name}` ADD COLUMN `beneficiaire_id` int(11) DEFAULT NULL AFTER `person_id`");
// Ajouter un index pour améliorer les performances
$wpdb->query("ALTER TABLE `{$table_name}` ADD INDEX `idx_beneficiaire_id` (`beneficiaire_id`)");
}
}
/**
* Récupère toutes les présences pour un événement donné
*
* @param int $event_id
* @return array Tableau associatif avec les données des présences et des personnes
*/
public static function get_presences_by_event(int $event_id): array {
global $wpdb;
$presence_table = $wpdb->prefix . 'crvi_agenda_presence';
$persons_table = $wpdb->prefix . 'crvi_agenda_persons';
$results = $wpdb->get_results($wpdb->prepare(
"SELECT
p.id as presence_id,
p.event_id,
p.person_id,
p.is_present,
per.nom,
per.prenom,
per.langue
FROM $presence_table p
INNER JOIN $persons_table per ON p.person_id = per.id
WHERE p.event_id = %d
ORDER BY per.nom, per.prenom",
$event_id
), ARRAY_A);
return $results ?: [];
}
/**
* Supprime toutes les présences pour un événement donné
*
* @param int $event_id
* @return bool True si succès, false sinon
*/
public static function delete_presences_by_event(int $event_id): bool {
global $wpdb;
$table_name = $wpdb->prefix . 'crvi_agenda_presence';
$result = $wpdb->delete(
$table_name,
['event_id' => $event_id],
['%d']
);
return $result !== false;
}
}

View File

@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace ESI_CRVI_AGENDA\models;
use ESI_CRVI_AGENDA\helpers\Api_Helper;
class CRVI_Departement_Model extends Main_Model
{
/**
* Schéma des champs ACF : nom => type (ex : group, repeater, taxonomy, text...)
*/
public static $acf_schema = [
'nom' => 'text',
'type_dinterventions' => 'repeater',
];
/**
* Charge un département par ID (CPT)
* @param int $id
* @param array $fields Champs spécifiques à charger (optionnel)
* @return self|null
*/
public static function load($id, $fields = [])
{
$post = get_post($id);
if (!$post || $post->post_type !== 'departement') {
return null;
}
// Si des champs spécifiques sont demandés, ne charger que ceux-ci
if (!empty($fields)) {
$data = [];
foreach ($fields as $field) {
if ($field === 'id') {
$data['id'] = $post->ID;
} elseif ($field === 'nom') {
$data['nom'] = $post->post_title;
} elseif (property_exists(self::class, $field)) {
$data[$field] = get_field($field, $post->ID);
}
}
return new self($data);
}
// Sinon, retourner le post complet (comportement par défaut)
return $post;
}
/**
* Retourne la liste de tous les départements (CPT)
*/
public static function all($simple_list = false)
{
$posts = get_posts([
'post_type' => 'departement',
'posts_per_page' => -1,
'post_status' => 'publish',
]);
if ($simple_list) {
$posts = array_map(function($post) {
return [
'id' => $post->ID,
'nom' => $post->post_title,
];
}, $posts);
}
return $posts;
}
//crete or update if exist
//check if nom is unique
public static function create(array $data, bool $as_rest = false) {
$is_update = false;
// 1. Vérifier les droits
if (!current_user_can('edit_posts')) {
if ($as_rest) {
return [
'success' => false,
'code' => 403,
'message' => 'Non autorisé',
'data' => null
];
}
return false;
}
// 2. Valider les données (nom obligatoire)
if (empty($data['nom'])) {
if ($as_rest) {
return [
'success' => false,
'code' => 400,
'message' => 'Le nom du département est obligatoire',
'data' => null
];
}
return false;
}
// 3. Vérifier si le département existe déjà (unicité nom)
$existing = get_posts([
'post_type' => 'departement',
'meta_key' => 'nom',
'meta_value' => $data['nom'],
'post_status'=> 'any',
'numberposts'=> 1,
]);
if ($existing) {
$is_update = true;
// Mise à jour
$post_id = $existing[0]->ID ?? $existing[0];
wp_update_post([
'ID' => $post_id,
'post_title' => $data['nom'],
]);
foreach ($data as $key => $value) {
if (isset(self::$acf_schema[$key])) {
self::set_acf_field($post_id, $key, $value, self::$acf_schema);
} else {
update_post_meta($post_id, $key, $value);
}
}
/* if ($as_rest) {
return [
'success' => true,
'code' => 200,
'message' => 'Département mis à jour',
'data' => [ 'id' => $post_id ]
];
} */
/* return 'updated'; */
} else {
// Création
$post_id = wp_insert_post([
'post_type' => 'departement',
'post_title' => $data['nom'],
'post_status' => 'publish',
]);
if (is_wp_error($post_id)) {
if ($as_rest) {
return [
'success' => false,
'code' => 500,
'message' => 'Erreur lors de la création',
'data' => null
];
}
return false;
}
foreach ($data as $key => $value) {
update_post_meta($post_id, $key, $value);
}
/* if ($as_rest) {
return [
'success' => true,
'code' => 201,
'message' => 'Département créé',
'data' => [ 'id' => $post_id ]
];
} */
/* return 'created'; */
}
// 4. Créer le type d'intervention associé
$type_interventions = $data['types-dintervention'] ?? [];
if (!is_array($type_interventions)) {
if (strstr($type_interventions, '|')) {
$type_interventions = explode('|', $type_interventions);
} else {
$type_interventions = [$type_interventions];
}
}
//delete all type_intervention_id meta
//acf type_dinterventions -> repeater
foreach ($type_interventions as $type_intervention) {
$type_intervention_id = CRVI_Type_Intervention_Model::create([
'post_title' => $type_intervention,
]);
$type_interventions_ar[] = [
'type' => $type_intervention,
'article_intervention' => $type_intervention_id
];
self::set_acf_field($post_id, 'type_dinterventions', $type_interventions_ar, self::$acf_schema);
}
if ($as_rest) {
return [
'success' => true,
'code' => 201,
'message' => 'Département créé',
'data' => [ 'id' => $post_id ]
];
}
return $is_update ? 'updated' : 'created';
}
}

1811
app/models/Event_Model.php Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,724 @@
<?php
declare(strict_types=1);
namespace ESI_CRVI_AGENDA\models;
/**
* Modèle pour la table crvi_event_permission
*
* Cette table stocke les DISPONIBILITÉS de l'intervenant pour un événement :
* - Jours de la semaine disponibles (lundi, mardi, etc. - PAS des dates spécifiques)
* - Plages horaires disponibles
* - Indisponibilités ponctuelles (dates spécifiques l'intervenant n'est pas disponible)
*
* OPTIMISATION : Permet de vérifier rapidement les disponibilités sans charger l'intervenant
*/
class Event_Permission_Model extends Main_Model {
public $id;
public $event_id;
public $jours_permis; // JSON array ["lundi", "mardi"] - JOURS DE LA SEMAINE de l'intervenant
public $plages_horaires; // JSON array [{"debut":"09:00","fin":"17:00"}] - Heures disponibles (intervenant)
public $indisponibilites; // JSON array - Indisponibilités ponctuelles de l'intervenant
public $traducteur_jours_permis; // JSON array - Jours de la semaine du traducteur (si présent)
public $traducteur_indisponibilites; // JSON array - Indisponibilités ponctuelles du traducteur (si présent)
public $local_indisponibilites; // JSON array - Indisponibilités ponctuelles du local
public $locaux_autories; // JSON array [1,2,3] (IDs de locaux)
public $departements_autories; // JSON array [1,2,3] (IDs de départements)
public $types_intervention_autories; // JSON array [1,2,3]
public $traducteurs_disponibles_par_langue; // JSON object {"fr": [1,2,3], "en": [4,5]} - IDs des traducteurs disponibles par langue
public $date_creation;
public $date_modification;
public $cree_par;
public $modifie_par;
/**
* Crée la table crvi_event_permission
*
* Stocke les disponibilités de l'intervenant pour chaque événement :
* - Jours de la semaine (lundi, mardi, etc.)
* - Plages horaires (heures disponibles)
* - Indisponibilités ponctuelles (dates spécifiques)
*/
public static function create_table() {
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$table_name = $wpdb->prefix . 'crvi_event_permission';
$charset_collate = $wpdb->get_charset_collate();
// Vérifier si la table existe déjà
if ($wpdb->get_var("SHOW TABLES LIKE '$table_name'") === $table_name) {
return; // La table existe déjà
}
$sql = "CREATE TABLE $table_name (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
event_id BIGINT(20) UNSIGNED NOT NULL,
jours_permis TEXT DEFAULT NULL,
plages_horaires TEXT DEFAULT NULL,
indisponibilites TEXT DEFAULT NULL,
-- Disponibilités traducteur (si présent)
traducteur_jours_permis TEXT DEFAULT NULL,
traducteur_indisponibilites TEXT DEFAULT NULL,
-- Disponibilités local
local_indisponibilites TEXT DEFAULT NULL,
locaux_autories TEXT DEFAULT NULL,
departements_autories TEXT DEFAULT NULL,
types_intervention_autories TEXT DEFAULT NULL,
traducteurs_disponibles_par_langue TEXT DEFAULT NULL COMMENT 'Présets traducteurs disponibles par langue (JSON)',
date_creation DATETIME NOT NULL,
date_modification DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
cree_par BIGINT(20) UNSIGNED DEFAULT NULL,
modifie_par BIGINT(20) UNSIGNED DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY event_id (event_id),
KEY event_id_index (event_id)
) $charset_collate;";
// Si la table existe déjà, ajouter les nouvelles colonnes si nécessaire
if ($wpdb->get_var("SHOW TABLES LIKE '$table_name'") === $table_name) {
// Ajouter les colonnes pour traducteur et local si elles n'existent pas
$columns_to_add = [
'traducteur_jours_permis' => 'ALTER TABLE ' . $table_name . ' ADD COLUMN traducteur_jours_permis TEXT DEFAULT NULL',
'traducteur_indisponibilites' => 'ALTER TABLE ' . $table_name . ' ADD COLUMN traducteur_indisponibilites TEXT DEFAULT NULL',
'local_indisponibilites' => 'ALTER TABLE ' . $table_name . ' ADD COLUMN local_indisponibilites TEXT DEFAULT NULL',
'traducteurs_disponibles_par_langue' => 'ALTER TABLE ' . $table_name . ' ADD COLUMN traducteurs_disponibles_par_langue TEXT DEFAULT NULL',
];
foreach ($columns_to_add as $column => $sql_add) {
$column_exists = $wpdb->get_results($wpdb->prepare(
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = %s",
DB_NAME, $table_name, $column
));
if (empty($column_exists)) {
$wpdb->query($sql_add);
}
}
return; // Table existe, colonnes ajoutées si nécessaire
}
dbDelta($sql);
}
/**
* Crée les disponibilités à partir de l'intervenant, traducteur et local lors de la création d'un événement
*
* Stocke :
* - Les jours de la semaine disponibles (intervenant, traducteur)
* - Les plages horaires disponibles (intervenant)
* - Les indisponibilités ponctuelles (intervenant, traducteur, local)
* - Les traducteurs disponibles par langue (preset pour optimisation)
*
* @param int $event_id ID de l'événement
* @param int $intervenant_id ID de l'intervenant
* @param array|null $plages_horaires Plages horaires spécifiques (optionnel, pour les permanences)
* @param int|null $traducteur_id ID du traducteur (optionnel)
* @param int|null $local_id ID du local (optionnel)
* @param string|null $langue_slug Slug de la langue de l'événement (pour précalculer traducteurs disponibles)
* @param string|null $date_rdv Date de l'événement (pour précalculer traducteurs disponibles)
* @return bool|WP_Error
*/
public static function create_from_intervenant($event_id, $intervenant_id, $plages_horaires = null, $traducteur_id = null, $local_id = null, $langue_slug = null, $date_rdv = null) {
global $wpdb;
$intervenant = CRVI_Intervenant_Model::load($intervenant_id);
if (!$intervenant) {
return new \WP_Error('intervenant_not_found', 'Intervenant introuvable', ['status' => 404]);
}
// Extraire les jours de la semaine disponibles de l'intervenant (lundi, mardi, etc. - PAS des dates)
$jours_disponibles = get_field('jours_de_disponibilite', 'user_' . $intervenant_id);
$jours_permis = is_array($jours_disponibles) ? json_encode($jours_disponibles) : json_encode([]);
// Extraire les indisponibilités ponctuelles de l'intervenant
$indisponibilites_acf = get_field('indisponibilitee_ponctuelle', 'user_' . $intervenant_id);
$indisponibilites = is_array($indisponibilites_acf) ? json_encode($indisponibilites_acf) : json_encode([]);
// Extraire les départements et types d'intervention
$departements_autories = !empty($intervenant->departements_ids)
? json_encode($intervenant->departements_ids)
: json_encode([]);
$types_intervention_autories = !empty($intervenant->types_intervention_ids)
? json_encode($intervenant->types_intervention_ids)
: json_encode([]);
// Plages horaires : si fournies, les utiliser, sinon null
$plages_horaires_json = null;
if ($plages_horaires !== null && is_array($plages_horaires)) {
$plages_horaires_json = json_encode($plages_horaires);
}
// Traducteur : jours et indisponibilités (si présent)
$traducteur_jours_permis = null;
$traducteur_indisponibilites = null;
if ($traducteur_id) {
$traducteur = CRVI_Traducteur_Model::load($traducteur_id);
if ($traducteur) {
// Jours de disponibilité du traducteur
$traducteur_jours = get_field('jours_de_disponibilite', $traducteur_id);
$traducteur_jours_permis = is_array($traducteur_jours) ? json_encode($traducteur_jours) : json_encode([]);
// Indisponibilités ponctuelles du traducteur
$traducteur_indispo_acf = get_field('indisponibilitee_ponctuelle', $traducteur_id);
$traducteur_indisponibilites = is_array($traducteur_indispo_acf) ? json_encode($traducteur_indispo_acf) : json_encode([]);
}
}
// Local : indisponibilités (si présent)
$local_indisponibilites = null;
if ($local_id) {
$local = CRVI_Local_Model::load($local_id);
if ($local) {
// Indisponibilités ponctuelles du local (si le champ existe)
$local_indispo_acf = get_field('indisponibilitee_ponctuelle', $local_id);
$local_indisponibilites = is_array($local_indispo_acf) ? json_encode($local_indispo_acf) : json_encode([]);
}
}
// Précalculer les traducteurs disponibles par langue (preset pour optimisation)
$traducteurs_disponibles_par_langue = null;
if ($langue_slug && $date_rdv) {
$traducteurs_disponibles_par_langue = self::precalculate_traducteurs_par_langue($date_rdv, $langue_slug);
}
// Créer l'entrée de permission
$table_name = $wpdb->prefix . 'crvi_event_permission';
$result = $wpdb->insert($table_name, [
'event_id' => $event_id,
'jours_permis' => $jours_permis,
'plages_horaires' => $plages_horaires_json,
'indisponibilites' => $indisponibilites,
'traducteur_jours_permis' => $traducteur_jours_permis,
'traducteur_indisponibilites' => $traducteur_indisponibilites,
'local_indisponibilites' => $local_indisponibilites,
'departements_autories' => $departements_autories,
'types_intervention_autories' => $types_intervention_autories,
'traducteurs_disponibles_par_langue' => $traducteurs_disponibles_par_langue,
'date_creation' => current_time('mysql'),
'cree_par' => get_current_user_id(),
], [
'%d', // event_id
'%s', // jours_permis
'%s', // plages_horaires
'%s', // indisponibilites
'%s', // traducteur_jours_permis
'%s', // traducteur_indisponibilites
'%s', // local_indisponibilites
'%s', // departements_autories
'%s', // types_intervention_autories
'%s', // traducteurs_disponibles_par_langue
'%s', // date_creation
'%d', // cree_par
]);
return $result !== false;
}
/**
* Précalcule les IDs des traducteurs disponibles par langue pour une date donnée
*
* Retourne un tableau JSON avec les IDs des traducteurs disponibles pour chaque langue :
* {"fr": [1,2,3], "en": [4,5], "es": [2,6]}
*
* @param string $date_rdv Date au format Y-m-d
* @param string|null $langue_slug Slug de la langue principale (si null, calcule pour toutes les langues)
* @return string|null JSON string ou null
*/
private static function precalculate_traducteurs_par_langue($date_rdv, $langue_slug = null) {
// Récupérer toutes les langues ou seulement celle demandée
$langues_to_check = [];
if ($langue_slug) {
$term = get_term_by('slug', $langue_slug, 'langue');
if ($term) {
$langues_to_check[] = [
'slug' => $langue_slug,
'term_id' => $term->term_id,
];
}
} else {
// Récupérer toutes les langues
$terms = get_terms([
'taxonomy' => 'langue',
'hide_empty' => false,
]);
foreach ($terms as $term) {
$langues_to_check[] = [
'slug' => $term->slug,
'term_id' => $term->term_id,
];
}
}
if (empty($langues_to_check)) {
return null;
}
// Pour chaque langue, récupérer les traducteurs disponibles
$result = [];
foreach ($langues_to_check as $langue_info) {
$traducteurs_disponibles = CRVI_Traducteur_Model::filtrer_disponibles(
$date_rdv,
$langue_info['slug']
);
// Extraire les IDs des traducteurs disponibles
$ids = [];
foreach ($traducteurs_disponibles as $traducteur) {
if (isset($traducteur->id)) {
$ids[] = (int)$traducteur->id;
}
}
if (!empty($ids)) {
$result[$langue_info['slug']] = $ids;
}
}
return !empty($result) ? json_encode($result) : null;
}
/**
* Crée les disponibilités pour une permanence à partir des jours et heures sélectionnés dans le formulaire
*
* Stocke directement les jours et plages horaires sélectionnés dans le formulaire de création de permanences,
* sans charger les disponibilités générales de l'intervenant.
*
* @param int $event_id ID de l'événement (permanence)
* @param int $intervenant_id ID de l'intervenant
* @param array $jours_permis Jours de la semaine sélectionnés (ex: ["lundi", "mardi"])
* @param array $plages_horaires Plages horaires sélectionnées (ex: [{"debut":"11:00","fin":"15:00"}])
* @return bool|WP_Error
*/
public static function create_for_permanence($event_id, $intervenant_id, $jours_permis = [], $plages_horaires = []) {
global $wpdb;
// Vérifier que l'intervenant existe
$intervenant = CRVI_Intervenant_Model::load($intervenant_id);
if (!$intervenant) {
return new \WP_Error('intervenant_not_found', 'Intervenant introuvable', ['status' => 404]);
}
// Convertir les jours et plages horaires en JSON
$jours_permis_json = is_array($jours_permis) ? json_encode($jours_permis) : json_encode([]);
$plages_horaires_json = is_array($plages_horaires) ? json_encode($plages_horaires) : json_encode([]);
// Charger les indisponibilités ponctuelles de l'intervenant (pour les vérifications)
$indisponibilites_acf = get_field('indisponibilitee_ponctuelle', 'user_' . $intervenant_id);
$indisponibilites = is_array($indisponibilites_acf) ? json_encode($indisponibilites_acf) : json_encode([]);
// Extraire les départements et types d'intervention
$departements_autories = !empty($intervenant->departements_ids)
? json_encode($intervenant->departements_ids)
: json_encode([]);
$types_intervention_autories = !empty($intervenant->types_intervention_ids)
? json_encode($intervenant->types_intervention_ids)
: json_encode([]);
// Créer l'entrée de permission avec les jours et heures sélectionnés
$table_name = $wpdb->prefix . 'crvi_event_permission';
$result = $wpdb->insert($table_name, [
'event_id' => $event_id,
'jours_permis' => $jours_permis_json,
'plages_horaires' => $plages_horaires_json,
'indisponibilites' => $indisponibilites,
'traducteur_jours_permis' => null,
'traducteur_indisponibilites' => null,
'local_indisponibilites' => null,
'departements_autories' => $departements_autories,
'types_intervention_autories' => $types_intervention_autories,
'date_creation' => current_time('mysql'),
'cree_par' => get_current_user_id(),
], [
'%d', // event_id
'%s', // jours_permis
'%s', // plages_horaires
'%s', // indisponibilites
'%s', // traducteur_jours_permis
'%s', // traducteur_indisponibilites
'%s', // local_indisponibilites
'%s', // departements_autories
'%s', // types_intervention_autories
'%s', // date_creation
'%d', // cree_par
]);
return $result !== false;
}
/**
* Vérifie rapidement si une date/heure est disponible selon les disponibilités stockées
*
* Vérifie :
* - Si le jour de la semaine de la date correspond aux jours disponibles
* - Si l'heure est dans les plages horaires autorisées
* - Si la date n'est pas dans une période d'indisponibilité
*
* OPTIMISÉ : Une seule requête SQL, pas de chargement de modèle Intervenant
*
* @param int $event_id ID de l'événement
* @param string $date Date au format Y-m-d
* @param string $heure Heure au format H:i (optionnel, pour vérifier les plages horaires)
* @return bool|null true=disponible, false=non disponible, null=pas de disponibilité définie (fallback requis)
*/
public static function is_date_heure_authorized($event_id, $date, $heure = null) {
global $wpdb;
$table_name = $wpdb->prefix . 'crvi_event_permission';
// Requête optimisée : une seule requête SQL pour toutes les disponibilités
// Récupère intervenant, traducteur et local en une seule fois (si présents dans la table)
$permission = $wpdb->get_row($wpdb->prepare(
"SELECT jours_permis, plages_horaires, indisponibilites,
traducteur_jours_permis, traducteur_indisponibilites,
local_indisponibilites
FROM $table_name
WHERE event_id = %d",
$event_id
), ARRAY_A);
if (!$permission) {
// Pas de permission définie, fallback vers l'intervenant
return null;
}
// Vérifier le jour de la semaine (lundi, mardi, etc. - pas une date spécifique)
$timestamp = strtotime($date);
if ($timestamp === false) {
return false;
}
$jour_semaine = (int)date('w', $timestamp); // 0=dimanche, 1=lundi, etc.
// Mapping jours français vers numériques (jours de la semaine)
$jours_mapping = [
'dimanche' => 0,
'lundi' => 1,
'mardi' => 2,
'mercredi' => 3,
'jeudi' => 4,
'vendredi' => 5,
'samedi' => 6,
];
// Vérifier si ce jour de la semaine est disponible
$jours_permis_json = $permission['jours_permis'] ?? '[]';
$jours_permis = json_decode($jours_permis_json, true) ?? [];
if (!empty($jours_permis)) {
$jours_permis_numeriques = [];
foreach ($jours_permis as $jour) {
$jour_lower = strtolower($jour);
if (isset($jours_mapping[$jour_lower])) {
$jours_permis_numeriques[] = $jours_mapping[$jour_lower];
}
}
// Vérifier si le jour de la semaine de la date est dans les jours disponibles
if (!in_array($jour_semaine, $jours_permis_numeriques, true)) {
return false; // Ce jour de la semaine n'est pas disponible
}
}
// Vérifier les indisponibilités ponctuelles
$indisponibilites_json = $permission['indisponibilites'] ?? '[]';
$indisponibilites = json_decode($indisponibilites_json, true) ?? [];
foreach ($indisponibilites as $indispo) {
$debut_str = $indispo['debut'] ?? $indispo['date_debut'] ?? '';
$fin_str = $indispo['fin'] ?? $indispo['date_fin'] ?? '';
if (empty($debut_str) || empty($fin_str)) {
continue;
}
// Convertir les dates ACF (format d/m/Y) en timestamp
$debut_timestamp = self::parse_acf_date($debut_str);
$fin_timestamp = self::parse_acf_date($fin_str);
if ($debut_timestamp && $fin_timestamp && $timestamp >= $debut_timestamp && $timestamp <= $fin_timestamp) {
return false; // Date dans une période d'indisponibilité
}
}
// Vérifier les plages horaires si une heure est fournie
if ($heure !== null && !empty($permission['plages_horaires'])) {
$plages_json = $permission['plages_horaires'];
$plages = json_decode($plages_json, true) ?? [];
if (!empty($plages)) {
$heure_timestamp = strtotime($heure);
$heure_autorisee = false;
foreach ($plages as $plage) {
$debut_plage = strtotime($plage['debut'] ?? '');
$fin_plage = strtotime($plage['fin'] ?? '');
if ($debut_plage && $fin_plage && $heure_timestamp >= $debut_plage && $heure_timestamp <= $fin_plage) {
$heure_autorisee = true;
break;
}
}
if (!$heure_autorisee) {
return false; // Heure non dans les plages autorisées
}
}
}
// Vérifier les disponibilités du traducteur (si présent dans les permissions stockées)
// OPTIMISATION : Si les données sont dans la table, on les utilise, sinon on laisse fallback
if (!empty($permission['traducteur_jours_permis']) || !empty($permission['traducteur_indisponibilites'])) {
// Vérifier le jour de la semaine pour le traducteur
$traducteur_jours_json = $permission['traducteur_jours_permis'] ?? '[]';
$traducteur_jours = json_decode($traducteur_jours_json, true) ?? [];
if (!empty($traducteur_jours)) {
$traducteur_jours_numeriques = [];
foreach ($traducteur_jours as $jour) {
$jour_lower = strtolower($jour);
if (isset($jours_mapping[$jour_lower])) {
$traducteur_jours_numeriques[] = $jours_mapping[$jour_lower];
}
}
if (!in_array($jour_semaine, $traducteur_jours_numeriques, true)) {
return false; // Ce jour de la semaine n'est pas disponible pour le traducteur
}
}
// Vérifier les indisponibilités ponctuelles du traducteur
$traducteur_indispo_json = $permission['traducteur_indisponibilites'] ?? '[]';
$traducteur_indispo = json_decode($traducteur_indispo_json, true) ?? [];
foreach ($traducteur_indispo as $indispo) {
$debut_str = $indispo['debut'] ?? $indispo['date_debut'] ?? '';
$fin_str = $indispo['fin'] ?? $indispo['date_fin'] ?? '';
if (empty($debut_str) || empty($fin_str)) {
continue;
}
$debut_timestamp = self::parse_acf_date($debut_str);
$fin_timestamp = self::parse_acf_date($fin_str);
if ($debut_timestamp && $fin_timestamp && $timestamp >= $debut_timestamp && $timestamp <= $fin_timestamp) {
return false; // Date dans une période d'indisponibilité du traducteur
}
}
}
// Si traducteur pas dans la table, on laisse le fallback dans update_event() le gérer
// Vérifier les disponibilités du local (si présent dans les permissions stockées)
// OPTIMISATION : Si les données sont dans la table, on les utilise, sinon on laisse fallback
if (!empty($permission['local_indisponibilites'])) {
$local_indispo_json = $permission['local_indisponibilites'] ?? '[]';
$local_indispo = json_decode($local_indispo_json, true) ?? [];
foreach ($local_indispo as $indispo) {
$debut_str = $indispo['debut'] ?? $indispo['date_debut'] ?? '';
$fin_str = $indispo['fin'] ?? $indispo['date_fin'] ?? '';
if (empty($debut_str) || empty($fin_str)) {
continue;
}
$debut_timestamp = self::parse_acf_date($debut_str);
$fin_timestamp = self::parse_acf_date($fin_str);
if ($debut_timestamp && $fin_timestamp && $timestamp >= $debut_timestamp && $timestamp <= $fin_timestamp) {
return false; // Date dans une période d'indisponibilité du local
}
}
}
// Si local pas dans la table, on laisse le fallback dans update_event() le gérer
return true; // Autorisé (intervenant, traducteur et local disponibles)
}
/**
* Parse une date ACF (format d/m/Y ou Y-m-d)
* @param string $date_str
* @return int|false Timestamp ou false
*/
private static function parse_acf_date($date_str) {
// Essayer le format ACF (d/m/Y)
$date = \DateTime::createFromFormat('d/m/Y', $date_str);
if ($date) {
return $date->getTimestamp();
}
// Essayer le format standard (Y-m-d)
$date = \DateTime::createFromFormat('Y-m-d', $date_str);
if ($date) {
return $date->getTimestamp();
}
// Fallback : strtotime
$timestamp = strtotime($date_str);
return $timestamp !== false ? $timestamp : false;
}
/**
* Met à jour les disponibilités d'un événement (ex: après modification de l'intervenant, traducteur ou local)
*
* Met à jour :
* - Les jours de la semaine disponibles (intervenant, traducteur)
* - Les indisponibilités ponctuelles (intervenant, traducteur, local)
* - Les départements/types d'intervention
* - Les traducteurs disponibles par langue (preset)
*
* Les plages horaires existantes sont conservées par défaut (utile pour les permanences)
*
* @param int $event_id
* @param int|null $intervenant_id Si null, utilise l'intervenant de l'événement
* @param bool $preserve_plages_horaires Si true, conserve les plages horaires existantes (défaut: true)
* @param int|null $traducteur_id ID du traducteur (si changé)
* @param int|null $local_id ID du local (si changé)
* @param string|null $langue_slug Slug de la langue de l'événement (pour précalculer traducteurs disponibles)
* @param string|null $date_rdv Date de l'événement (pour précalculer traducteurs disponibles)
* @return bool
*/
public static function update_from_intervenant($event_id, $intervenant_id = null, $preserve_plages_horaires = true, $traducteur_id = null, $local_id = null, $langue_slug = null, $date_rdv = null) {
global $wpdb;
// Si pas d'intervenant fourni, le récupérer depuis l'événement
if ($intervenant_id === null) {
$event = CRVI_Event_Model::load($event_id);
if (!$event || !$event->id_intervenant) {
return false;
}
$intervenant_id = $event->id_intervenant;
}
// Si traducteur/local non fournis, les récupérer depuis l'événement
if ($traducteur_id === null || $local_id === null) {
if (!$event) {
$event = CRVI_Event_Model::load($event_id);
}
if ($event) {
if ($traducteur_id === null) {
$traducteur_id = $event->id_traducteur ?? null;
}
if ($local_id === null) {
$local_id = $event->id_local ?? null;
}
}
}
$table_name = $wpdb->prefix . 'crvi_event_permission';
// Vérifier si la permission existe déjà
$existing = $wpdb->get_row($wpdb->prepare(
"SELECT id, plages_horaires, traducteur_jours_permis, traducteur_indisponibilites, local_indisponibilites
FROM $table_name WHERE event_id = %d",
$event_id
), ARRAY_A);
$intervenant = CRVI_Intervenant_Model::load($intervenant_id);
if (!$intervenant) {
return false;
}
// Récupérer les nouvelles données depuis l'intervenant
$jours_disponibles = get_field('jours_de_disponibilite', 'user_' . $intervenant_id);
$jours_permis = is_array($jours_disponibles) ? json_encode($jours_disponibles) : json_encode([]);
$indisponibilites_acf = get_field('indisponibilitee_ponctuelle', 'user_' . $intervenant_id);
$indisponibilites = is_array($indisponibilites_acf) ? json_encode($indisponibilites_acf) : json_encode([]);
$departements_autories = !empty($intervenant->departements_ids)
? json_encode($intervenant->departements_ids)
: json_encode([]);
$types_intervention_autories = !empty($intervenant->types_intervention_ids)
? json_encode($intervenant->types_intervention_ids)
: json_encode([]);
// Récupérer les disponibilités du traducteur si présent
$traducteur_jours_permis = null;
$traducteur_indisponibilites = null;
if ($traducteur_id) {
$traducteur = CRVI_Traducteur_Model::load($traducteur_id);
if ($traducteur) {
$traducteur_jours = get_field('jours_de_disponibilite', $traducteur_id);
$traducteur_jours_permis = is_array($traducteur_jours) ? json_encode($traducteur_jours) : json_encode([]);
$traducteur_indispo_acf = get_field('indisponibilitee_ponctuelle', $traducteur_id);
$traducteur_indisponibilites = is_array($traducteur_indispo_acf) ? json_encode($traducteur_indispo_acf) : json_encode([]);
}
}
// Récupérer les indisponibilités du local si présent
$local_indisponibilites = null;
if ($local_id) {
$local = CRVI_Local_Model::load($local_id);
if ($local) {
$local_indispo_acf = get_field('indisponibilitee_ponctuelle', $local_id);
$local_indisponibilites = is_array($local_indispo_acf) ? json_encode($local_indispo_acf) : json_encode([]);
}
}
// Précalculer les traducteurs disponibles par langue si langue et date fournies
$traducteurs_disponibles_par_langue = null;
if ($langue_slug && $date_rdv) {
$traducteurs_disponibles_par_langue = self::precalculate_traducteurs_par_langue($date_rdv, $langue_slug);
}
if ($existing) {
// Mettre à jour l'existant
$update_data = [
'jours_permis' => $jours_permis,
'indisponibilites' => $indisponibilites,
'traducteur_jours_permis' => $traducteur_jours_permis,
'traducteur_indisponibilites' => $traducteur_indisponibilites,
'local_indisponibilites' => $local_indisponibilites,
'departements_autories' => $departements_autories,
'types_intervention_autories' => $types_intervention_autories,
'traducteurs_disponibles_par_langue' => $traducteurs_disponibles_par_langue,
'date_modification' => current_time('mysql'),
'modifie_par' => get_current_user_id(),
];
// Conserver les plages horaires existantes si demandé
if ($preserve_plages_horaires && !empty($existing['plages_horaires'])) {
$update_data['plages_horaires'] = $existing['plages_horaires'];
$formats = ['%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d']; // Avec plages_horaires
} else {
$formats = ['%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d']; // Sans plages_horaires
}
$result = $wpdb->update(
$table_name,
$update_data,
['event_id' => $event_id],
$formats,
['%d'] // Format pour WHERE
);
return $result !== false;
} else {
// Créer une nouvelle entrée
return self::create_from_intervenant($event_id, $intervenant_id, null, $traducteur_id, $local_id, $langue_slug, $date_rdv) !== false;
}
}
/**
* Supprime les permissions d'un événement (lors de la suppression de l'événement)
* @param int $event_id
* @return bool
*/
public static function delete_by_event_id($event_id) {
global $wpdb;
$table_name = $wpdb->prefix . 'crvi_event_permission';
return $wpdb->delete($table_name, ['event_id' => $event_id], ['%d']) !== false;
}
}

View File

@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
namespace ESI_CRVI_AGENDA\models;
class CRVI_Incident_Model extends Main_Model {
public $id;
public $beneficiaire_id;
public $event_id;
public $resume_incident;
public $commentaire_incident;
public $created_at;
public function __construct($data = []) {
foreach ($data as $key => $value) {
if (property_exists($this, $key)) {
$this->$key = $value;
}
}
}
/**
* Charger un incident par ID
* @param int $id
* @return self|null
*/
public static function load($id) {
global $wpdb;
$table_name = $wpdb->prefix . 'crvi_agenda_incident';
$incident = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $table_name WHERE id = %d",
$id
));
if (!$incident) {
return null;
}
return new self([
'id' => $incident->id,
'beneficiaire_id' => $incident->beneficiaire_id,
'event_id' => $incident->event_id,
'resume_incident' => $incident->resume_incident,
'commentaire_incident' => $incident->commentaire_incident,
'created_at' => $incident->created_at ?? null,
]);
}
/**
* Créer un incident
* @param array $data
* @return int|WP_Error ID de l'incident créé ou erreur
*/
public static function create(array $data) {
global $wpdb;
$table_name = $wpdb->prefix . 'crvi_agenda_incident';
// Validation des champs requis
if (empty($data['event_id'])) {
return new \WP_Error('validation_error', 'L\'ID de l\'événement est requis', ['status' => 400]);
}
if (empty($data['beneficiaire_id'])) {
return new \WP_Error('validation_error', 'L\'ID du bénéficiaire est requis', ['status' => 400]);
}
if (empty($data['resume_incident'])) {
return new \WP_Error('validation_error', 'Le résumé de l\'incident est requis', ['status' => 400]);
}
// Préparer les données pour l'insertion
$insert_data = [
'beneficiaire_id' => (int) $data['beneficiaire_id'],
'event_id' => (int) $data['event_id'],
'resume_incident' => sanitize_text_field($data['resume_incident']),
'commentaire_incident' => isset($data['commentaire_incident']) ? sanitize_textarea_field($data['commentaire_incident']) : null,
];
// Insérer en base
$result = $wpdb->insert(
$table_name,
$insert_data,
[
'%d', // beneficiaire_id
'%d', // event_id
'%s', // resume_incident
'%s', // commentaire_incident
]
);
if ($result === false) {
return new \WP_Error('database_error', 'Erreur lors de l\'insertion de l\'incident en base de données', ['status' => 500]);
}
return $wpdb->insert_id;
}
/**
* Récupérer tous les incidents avec filtres optionnels
* @param array $filters Filtres optionnels (event_id, beneficiaire_id)
* @return array
*/
public static function get_all($filters = []) {
global $wpdb;
$table_name = $wpdb->prefix . 'crvi_agenda_incident';
$where_clauses = [];
$where_values = [];
// Construire les clauses WHERE selon les filtres
if (!empty($filters['event_id'])) {
$where_clauses[] = 'event_id = %d';
$where_values[] = (int) $filters['event_id'];
}
if (!empty($filters['beneficiaire_id'])) {
$where_clauses[] = 'beneficiaire_id = %d';
$where_values[] = (int) $filters['beneficiaire_id'];
}
// Construire la requête
$query = "SELECT * FROM $table_name";
if (!empty($where_clauses)) {
$query .= ' WHERE ' . implode(' AND ', $where_clauses);
}
$query .= ' ORDER BY id DESC';
// Exécuter la requête
if (!empty($where_values)) {
$incidents = $wpdb->get_results($wpdb->prepare($query, $where_values));
} else {
$incidents = $wpdb->get_results($query);
}
$result = [];
foreach ($incidents as $incident) {
$result[] = new self([
'id' => $incident->id,
'beneficiaire_id' => $incident->beneficiaire_id,
'event_id' => $incident->event_id,
'resume_incident' => $incident->resume_incident,
'commentaire_incident' => $incident->commentaire_incident,
'created_at' => $incident->created_at ?? null,
]);
}
return $result;
}
/**
* Récupérer tous les incidents pour un événement
* @param int $event_id
* @return array
*/
public static function get_by_event($event_id) {
global $wpdb;
$table_name = $wpdb->prefix . 'crvi_agenda_incident';
$incidents = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM $table_name WHERE event_id = %d ORDER BY id DESC",
$event_id
));
$result = [];
foreach ($incidents as $incident) {
$result[] = new self([
'id' => $incident->id,
'beneficiaire_id' => $incident->beneficiaire_id,
'event_id' => $incident->event_id,
'resume_incident' => $incident->resume_incident,
'commentaire_incident' => $incident->commentaire_incident,
'created_at' => $incident->created_at ?? null,
]);
}
return $result;
}
/**
* Récupérer tous les incidents pour un bénéficiaire
* @param int $beneficiaire_id
* @return array
*/
public static function get_by_beneficiaire($beneficiaire_id) {
global $wpdb;
$table_name = $wpdb->prefix . 'crvi_agenda_incident';
$incidents = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM $table_name WHERE beneficiaire_id = %d ORDER BY id DESC",
$beneficiaire_id
));
$result = [];
foreach ($incidents as $incident) {
$result[] = new self([
'id' => $incident->id,
'beneficiaire_id' => $incident->beneficiaire_id,
'event_id' => $incident->event_id,
'resume_incident' => $incident->resume_incident,
'commentaire_incident' => $incident->commentaire_incident,
'created_at' => $incident->created_at ?? null,
]);
}
return $result;
}
}

View File

@ -0,0 +1,542 @@
<?php
declare(strict_types=1);
namespace ESI_CRVI_AGENDA\models;
use WP_Error;
class CRVI_Intervenant_Model extends Main_Model {
public $id;
public $nom;
public $prenom;
public $email;
public $departements_ids; // array d'IDs CPT departement
public $types_intervention_ids; // array d'IDs CPT type_intervention
public $jours_de_disponibilite;
public $indisponibilites;
public $commentaires;
/**
* Schéma des champs ACF : nom => type (ex : group, repeater, taxonomy, text...)
*/
public static $acf_schema = [
'telephone' => 'text',
'departements' => 'taxonomy',
'specialisations' => 'taxonomy',
'commentaires' => 'textarea',
'jours_de_disponibilite' => 'checkbox',
'indisponibilitee_ponctuelle' => 'repeater',
];
public function __construct($data = []) {
foreach ($data as $key => $value) {
if (property_exists($this, $key)) {
$this->$key = $value;
}
}
}
public static function load($user_id, $fields = []) {
$user = get_user_by('id', $user_id);
if (!$user || !in_array('intervenant', (array) $user->roles, true)) {
return null;
}
// Fonction helper pour récupérer les champs ACF ou meta
$get_field_value = function($user_id, $field_name) {
if (function_exists('get_field')) {
return get_field($field_name, 'user_' . $user_id) ?: [];
} else {
return get_user_meta($user_id, $field_name, true) ?: [];
}
};
// Si des champs spécifiques sont demandés, ne charger que ceux-ci
if (!empty($fields)) {
$data = [];
foreach ($fields as $field) {
if ($field === 'id') {
$data['id'] = $user->ID;
} elseif ($field === 'nom') {
$data['nom'] = $user->last_name;
} elseif ($field === 'prenom') {
$data['prenom'] = $user->first_name;
} elseif ($field === 'email') {
$data['email'] = $user->user_email;
} elseif (property_exists(self::class, $field)) {
$data[$field] = $get_field_value($user->ID, $field);
}
}
return new self($data);
}
// Sinon, charger tous les champs par défaut
return new self([
'id' => $user->ID,
'nom' => $user->last_name,
'prenom' => $user->first_name,
'email' => $user->user_email,
'departements_ids' => $get_field_value($user->ID, 'departements_ids'),
'types_intervention_ids' => $get_field_value($user->ID, 'types_intervention_ids'),
'jours_de_disponibilite' => $get_field_value($user->ID, 'jours_de_disponibilite'),
'indisponibilites' => $get_field_value($user->ID, 'indisponibilites'),
'commentaires' => $get_field_value($user->ID, 'commentaires'),
]);
}
public static function get_intervenants($filters = [],$simple_list = false) {
$users = get_users([
'role' => 'intervenant',
'number' => -1,
'meta_query' => $filters,
]);
if ($simple_list) {
$users = array_map(function($user) {
return [
'id' => $user->ID,
'nom' => $user->last_name,
'prenom' => $user->first_name,
];
}, $users);
}
return $users;
}
/**
* Retourne les intervenants disponibles à une date donnée, avec filtres optionnels.
* @param string $date au format Y-m-d
* @param int|null $departement_id (ID CPT departement)
* @param int|null $type_intervention_id (ID CPT type_intervention)
* @param int|null $event_id ID de l'événement en cours d'édition (pour inclure l'intervenant actuel)
* @return array Liste des intervenants disponibles (CRVI_Intervenant_Model)
*/
public static function filtrer_disponibles($date, $departement_id = null, $type_intervention_id = null, $event_id = null) {
if (empty($date)) {
return [];
}
$meta_query = [
[
'key' => 'jours_de_disponibilite',
'compare' => 'EXISTS',
],
];
if ($departement_id) {
$meta_query[] = [
'key' => 'departements_ids',
'value' => $departement_id,
'compare' => 'LIKE',
];
}
if ($type_intervention_id) {
$meta_query[] = [
'key' => 'types_intervention_ids',
'value' => $type_intervention_id,
'compare' => 'LIKE',
];
}
$args = [
'role' => 'intervenant',
'number' => -1,
'meta_query' => $meta_query,
];
$users = get_users($args);
$disponibles = [];
foreach ($users as $user) {
$intervenant = self::load($user->ID);
if ($intervenant) {
$is_disponible = $intervenant->is_disponible($user->ID ,$date, $departement_id, $type_intervention_id);
// Vérifier les conflits d'événements existants
if ($is_disponible) {
$conflits = self::verifier_conflits_intervenant($intervenant->id, $date, $event_id);
if (!empty($conflits)) {
$is_disponible = false;
}
}
// Si c'est l'événement en cours d'édition, inclure l'intervenant même s'il est "pris"
if ($event_id && self::is_intervenant_of_event($intervenant->id, $event_id)) {
$is_disponible = true;
}
if ($is_disponible) {
$disponibles[] = $intervenant;
}
}
}
// error_log("CRVI_Intervenant_Model::filtrer_disponibles - Intervenants disponibles: " . count($disponibles));
return $disponibles;
}
/**
* Vérifie si un intervenant est associé à un événement donné
* @param int $intervenant_id
* @param int $event_id
* @return bool
*/
private static function is_intervenant_of_event($intervenant_id, $event_id) {
global $wpdb;
$table_name = $wpdb->prefix . 'crvi_agenda';
$result = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM $table_name WHERE id = %d AND id_intervenant = %d",
$event_id,
$intervenant_id
));
return !empty($result);
}
/**
* Vérifie si cet intervenant est disponible à une date donnée, avec filtres optionnels.
* @param string $date au format Y-m-d
* @param int|null $departement_id
* @param int|null $type_intervention_id
* @return bool
*/
public function is_disponible($intervenant_id, $date, $departement_id = null, $type_intervention_id = null) {
// Vérifier que la date n'est pas null ou vide
if (empty($date)) {
return false;
}
$timestamp = strtotime($date);
if ($timestamp === false) {
return false;
}
$jour = strtolower(date('l', $timestamp));
$jours_fr = [
'monday' => 'lundi',
'tuesday' => 'mardi',
'wednesday' => 'mercredi',
'thursday' => 'jeudi',
'friday' => 'vendredi',
'saturday' => 'samedi',
'sunday' => 'dimanche',
];
$jour_semaine = $jours_fr[$jour] ?? '';
$jours_disponibles = get_field('jours_de_disponibilite', 'user_' . $intervenant_id);
if (!is_array($jours_disponibles) || !in_array($jour_semaine, $jours_disponibles, true)) {
return false;
}
$indisponibilites = get_field('indisponibilitee_ponctuelle', 'user_' . $intervenant_id);
if (is_array($indisponibilites)) {
foreach ($indisponibilites as $absence) {
$debut = isset($absence['debut']) ? strtotime($absence['debut']) : null;
$fin = isset($absence['fin']) ? strtotime($absence['fin']) : null;
if ($debut && $fin && $timestamp >= $debut && $timestamp <= $fin) {
return false;
}
}
}
if ($departement_id && (!is_array($this->departements_ids) || !in_array($departement_id, $this->departements_ids))) {
return false;
}
if ($type_intervention_id && (!is_array($this->types_intervention_ids) || !in_array($type_intervention_id, $this->types_intervention_ids))) {
return false;
}
return true;
}
public function get_relations() {
// À adapter selon la nouvelle structure (ex : récupérer les rendez-vous liés via user_id)
return [];
}
/**
* Créer un intervenant (user avec rôle intervenant).
* @param array $data
* @return array|WP_Error
*/
public static function create(array $data, bool $as_rest = false) {
// Suppression du debug
// echo '<pre>';
// print_r($data);
// echo '</pre>';
// die();
if (!is_user_logged_in() || !get_current_user_id()) {
if ($as_rest) {
return [
'success' => false,
'code' => 401,
'message' => 'Authentification requise',
'data' => null
];
}
return new WP_Error('auth_required', 'Authentification requise', ['status' => 401]);
}
if (!current_user_can('edit_users')) {
if ($as_rest) {
return [
'success' => false,
'code' => 403,
'message' => 'Non autorisé',
'data' => null
];
}
return new WP_Error('not_allowed', 'Non autorisé', ['status' => 403]);
}
if (empty($data['nom']) || empty($data['prenom']) || empty($data['email'])) {
if ($as_rest) {
return [
'success' => false,
'code' => 400,
'message' => 'Champs obligatoires manquants',
'data' => null
];
}
return new WP_Error('missing_fields', 'Champs obligatoires manquants', ['status' => 400]);
}
$user = get_user_by('email', $data['email']);
/* echo '<pre>';
print_r($user);
echo '</pre>';
die(); */
if ($user && in_array('intervenant', (array)$user->roles, true)) {
// Mise à jour
$user_id = $user->ID;
$user_data = [
'ID' => $user_id,
'user_email' => $data['email'],
'first_name' => $data['prenom'],
'last_name' => $data['nom'],
];
$result = wp_update_user($user_data);
if (is_wp_error($result)) {
if ($as_rest) {
return [
'success' => false,
'code' => 500,
'message' => 'Erreur lors de la mise à jour',
'data' => json_encode($result)
];
}
return $result;
}
// Mettre à jour les champs ACF
foreach ($data as $key => $value) {
// Pour departements et types_intervention, on attend des IDs de posts CPT (séparés par pipe si string)
if ($key === 'departements') {
$ids = is_array($value) ? $value : explode('|', $value);
$ids = array_filter(array_map('intval', $ids));
\update_field('departements', $ids, 'user_' . $user_id);
} else if ($key === 'types_intervention' || $key === 'specialisations') {
$ids = is_array($value) ? $value : explode('|', $value);
$ids = array_filter(array_map('intval', $ids));
\update_field('specialisations', $ids, 'user_' . $user_id);
} else if (isset(self::$acf_schema[$key])) {
self::set_acf_field('user_' . $user_id, $key, $value, self::$acf_schema);
} else {
// Pour les autres champs non ACF, on ignore ou on peut loguer
}
}
if ($as_rest) {
return [
'success' => true,
'code' => 200,
'message' => 'Intervenant mis à jour',
'data' => [ 'user_id' => $user_id ]
];
}
return ['user_id' => $user_id, 'updated' => true];
} else if($user) {
// L'utilisateur existe mais n'a pas le rôle intervenant
if ($as_rest) {
return [
'success' => false,
'code' => 409,
'message' => 'Email déjà utilisé par un autre rôle',
'data' => null
];
}
return new WP_Error('email_exists', 'Email déjà utilisé par un autre rôle', ['status' => 409]);
}
echo 'vhvh';
die();
// Création
$user_id = wp_insert_user([
'user_login' => $data['email'],
'user_email' => $data['email'],
'first_name' => $data['prenom'],
'last_name' => $data['nom'],
'role' => 'intervenant',
'user_pass' => wp_generate_password(12, true),
]);
if (is_wp_error($user_id)) {
if ($as_rest) {
return [
'success' => false,
'code' => 500,
'message' => 'Erreur lors de la création',
'data' => null
];
}
return $user_id;
}
// Générer un mot de passe applicatif (Application Password)
if (!class_exists('WP_Application_Passwords') && file_exists(ABSPATH . 'wp-includes/class-wp-application-passwords.php')) {
require_once ABSPATH . 'wp-includes/class-wp-application-passwords.php';
}
if (class_exists('WP_Application_Passwords')) {
$app_pass_result = \WP_Application_Passwords::create_new_application_password($user_id, [
'name' => 'CRVI API',
]);
if (is_array($app_pass_result) && isset($app_pass_result[0], $app_pass_result[1]['new_password'])) {
$application_password = $app_pass_result[1]['new_password'];
} else {
$application_password = null;
}
} else {
$application_password = null;
}
// Stocker les champs ACF
foreach ($data as $key => $value) {
if ($key === 'departements') {
$ids = is_array($value) ? $value : explode('|', $value);
$ids = array_filter(array_map('intval', $ids));
\update_field('departements', $ids, 'user_' . $user_id);
} else if ($key === 'types_intervention' || $key === 'specialisations') {
$ids = is_array($value) ? $value : explode('|', $value);
$ids = array_filter(array_map('intval', $ids));
\update_field('specialisations', $ids, 'user_' . $user_id);
} else if (isset(self::$acf_schema[$key])) {
self::set_acf_field('user_' . $user_id, $key, $value, self::$acf_schema);
} else {
// Pour les autres champs non ACF, on ignore ou on peut loguer
}
}
if ($as_rest) {
return [
'success' => true,
'code' => 201,
'message' => 'Intervenant créé',
'data' => [ 'user_id' => $user_id, 'application_password' => $application_password ]
];
}
return ['user_id' => $user_id, 'created' => true, 'application_password' => $application_password];
}
/**
* Mettre à jour un intervenant (user).
* @param int $user_id
* @param array $data
* @return bool|WP_Error
*/
public static function update(int $user_id, array $data) {
if (!current_user_can('edit_users')) {
return new WP_Error('not_allowed', 'Non autorisé', ['status' => 403]);
}
$user = get_user_by('id', $user_id);
if (!$user || !in_array('intervenant', (array) $user->roles, true)) {
return new WP_Error('not_found', 'Intervenant introuvable', ['status' => 404]);
}
$user_data = ['ID' => $user_id];
if (!empty($data['email'])) {
if (email_exists($data['email']) && $data['email'] !== $user->user_email) {
return new WP_Error('email_exists', 'Email déjà utilisé', ['status' => 409]);
}
$user_data['user_email'] = $data['email'];
}
if (!empty($data['prenom'])) {
$user_data['first_name'] = $data['prenom'];
}
if (!empty($data['nom'])) {
$user_data['last_name'] = $data['nom'];
}
$user_update = wp_update_user($user_data);
if (is_wp_error($user_update)) {
return $user_update;
}
// Mettre à jour les meta
foreach (['departements_ids', 'types_intervention_ids', 'jours_de_disponibilite', 'indisponibilites', 'commentaires'] as $meta_key) {
if (isset($data[$meta_key])) {
\update_field($meta_key, $data[$meta_key], 'user_' . $user_id);
}
}
return true;
}
/**
* Supprimer un intervenant (user).
* @param int $user_id
* @return bool|WP_Error
*/
public function delete(int $user_id) {
if (!current_user_can('delete_users')) {
return new WP_Error('not_allowed', 'Non autorisé', ['status' => 403]);
}
$user = get_user_by('id', $user_id);
if (!$user || !in_array('intervenant', (array) $user->roles, true)) {
return new WP_Error('not_found', 'Intervenant introuvable', ['status' => 404]);
}
require_once ABSPATH . 'wp-admin/includes/user.php';
$result = wp_delete_user($user_id);
if (!$result) {
return new WP_Error('delete_failed', 'Erreur lors de la suppression', ['status' => 500]);
}
return true;
}
public static function all() {
$users = get_users([
'role' => 'intervenant',
'number' => -1,
]);
return $users;
}
/**
* Vérifie s'il y a des conflits d'événements pour un intervenant donné
* @param int $intervenant_id
* @param string $date
* @param int|null $event_id - ID de l'événement à exclure (pour l'édition)
* @return array
*/
private static function verifier_conflits_intervenant($intervenant_id, $date, $event_id = null) {
global $wpdb;
$table_events = $wpdb->prefix . 'crvi_agenda';
$where_conditions = ['id_intervenant = %d'];
$where_values = [$intervenant_id];
// Exclure l'événement en cours d'édition si spécifié
if ($event_id) {
$where_conditions[] = 'id != %d';
$where_values[] = $event_id;
}
// Ajouter la condition de date
$where_conditions[] = 'date_rdv = %s';
$where_values[] = $date;
// Exclure les événements annulés ou terminés
$where_conditions[] = 'statut NOT IN (%s, %s)';
$where_values[] = 'annule';
$where_values[] = 'termine';
$where_clause = implode(' AND ', $where_conditions);
$query = $wpdb->prepare(
"SELECT * FROM {$table_events} WHERE {$where_clause}",
$where_values
);
return $wpdb->get_results($query, ARRAY_A);
}
}

303
app/models/Local_Model.php Normal file
View File

@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
namespace ESI_CRVI_AGENDA\models;
use ESI_CRVI_AGENDA\helpers\Api_Helper;
class CRVI_Local_Model extends Main_Model {
public $id;
public $nom;
public $type_de_local;
public $capacite;
public $local_bloque;
public $commentaire;
/**
* Schéma des champs ACF : nom => type (ex : group, repeater, taxonomy, text...)
*/
public static $acf_schema = [
'nom' => 'text',
'type_de_local' => 'select',
'local_bloque' => 'checkbox',
'commentaire' => 'textarea',
];
public function __construct($data = []) {
foreach ($data as $key => $value) {
if (property_exists($this, $key)) {
$this->$key = $value;
}
}
}
public static function load($id, $fields = []) {
// Charger depuis CPT/meta
$local = get_post($id);
if (!$local) {
return null;
}
// Si des champs spécifiques sont demandés, ne charger que ceux-ci
if (!empty($fields)) {
$data = [];
foreach ($fields as $field) {
if ($field === 'id') {
$data['id'] = $local->ID;
} elseif (property_exists(self::class, $field)) {
$data[$field] = get_field($field, $local->ID);
}
}
return new self($data);
}
// Sinon, charger tous les champs par défaut
return new self([
'id' => $local->ID,
'nom' => get_field('nom', $local->ID),
'type_de_local' => get_field('type_de_local', $local->ID),
'capacite' => get_field('capacite', $local->ID),
'local_bloque' => get_field('local_bloque', $local->ID),
'commentaire' => get_field('commentaire', $local->ID),
]);
}
public function save() {
// À implémenter : sauvegarder dans CPT/meta
return true;
}
public function is_disponible($date) {
// À implémenter : logique de disponibilité
return true;
}
public function get_relations() {
// À implémenter : récupérer les rendez-vous associés, etc.
return [];
}
public static function get_locals($filters = [],$simple_list = false) {
$locals = get_posts([
'post_type' => 'local',
'posts_per_page' => -1,
'post_status' => 'publish',
'meta_query' => $filters,
]);
if ($simple_list) {
$locals = array_map(function($local) {
return [
'id' => $local->ID,
'nom' => $local->post_title,
];
}, $locals);
}
return $locals;
}
/**
* Retourne les locaux filtrés par type de rendez-vous
* @param string $type_rdv 'individuel' ou 'groupe'
* @return array Liste des locaux filtrés
*/
public static function get_locaux_par_type($type_rdv) {
$meta_query = [];
// Filtrer selon le type de rendez-vous
if ($type_rdv === 'individuel') {
// Pour les RDV individuels : locaux individuels ou mixtes
$meta_query[] = [
'relation' => 'OR',
[
'key' => 'type_de_local',
'value' => 'individuel',
'compare' => '='
],
[
'key' => 'type_de_local',
'value' => 'mixte',
'compare' => '='
]
];
} elseif ($type_rdv === 'groupe') {
// Pour les RDV de groupe : locaux de groupe ou mixtes
$meta_query[] = [
'relation' => 'OR',
[
'key' => 'type_de_local',
'value' => 'groupe',
'compare' => '='
],
[
'key' => 'type_de_local',
'value' => 'mixte',
'compare' => '='
]
];
}
// Exclure les locaux bloqués
$meta_query[] = [
'key' => 'local_bloque',
'value' => '1',
'compare' => '!='
];
$args = [
'post_type' => 'local',
'numberposts' => -1,
'post_status' => 'publish',
'meta_query' => $meta_query
];
$locals_posts = get_posts($args);
$locaux_filtres = [];
foreach ($locals_posts as $local_post) {
$type_local = get_post_meta($local_post->ID, 'type_de_local', true);
$capacite = get_post_meta($local_post->ID, 'capacite', true);
$locaux_filtres[] = [
'id' => $local_post->ID,
'nom' => $local_post->post_title,
'type_de_local' => $type_local,
'capacite' => $capacite,
];
}
return $locaux_filtres;
}
/**
* Créer un local.
* @param array $data
* @return int|WP_Error
*/
public function create(array $data) {
// Vérifier les permissions (désactivé temporairement pour les tests)
// if (!is_user_logged_in() || !get_current_user_id()) {
// return Api_Helper::json_error('Authentification requise', 401);
// }
// if (!current_user_can('edit_posts')) {
// return Api_Helper::json_error('Non autorisé', 403);
// }
// Vérifier que le nom est fourni
if (empty($data['nom'])) {
return Api_Helper::json_error('Le nom du local est obligatoire', 400);
}
// Vérifier l'unicité du nom
$existing = get_posts([
'post_type' => 'local',
'meta_key' => 'nom',
'meta_value' => $data['nom'],
'post_status'=> 'any',
'numberposts'=> 1,
]);
if ($existing) {
return Api_Helper::json_error('Un local avec ce nom existe déjà', 409);
}
$post_id = wp_insert_post([
'post_type' => 'local',
'post_title' => $data['nom'],
'post_status' => 'publish',
]);
if (is_wp_error($post_id)) {
return $post_id;
}
update_post_meta($post_id, 'nom', $data['nom']);
if (!empty($data['type_de_local'])) {
update_post_meta($post_id, 'type_de_local', $data['type_de_local']);
}
if (!empty($data['capacite'])) {
update_post_meta($post_id, 'capacite', $data['capacite']);
}
if (isset($data['local_bloque'])) {
update_post_meta($post_id, 'local_bloque', $data['local_bloque']);
}
if (!empty($data['commentaire'])) {
update_post_meta($post_id, 'commentaire', $data['commentaire']);
}
// ... autres champs
return $post_id;
}
/**
* Mettre à jour un local.
* @param int $id
* @param array $data
* @return bool|WP_Error
*/
public function update(int $id, array $data) {
// Vérifier les permissions (désactivé temporairement pour les tests)
// if (!is_user_logged_in() || !get_current_user_id()) {
// return Api_Helper::json_error('Authentification requise', 401);
// }
// if (!current_user_can('edit_posts')) {
// return Api_Helper::json_error('Non autorisé', 403);
// }
$post = get_post($id);
if (!$post || $post->post_type !== 'local') {
return Api_Helper::json_error('Local introuvable', 404);
}
// Vérifier unicité du nom si modifié
if (!empty($data['nom'])) {
$existing = get_posts([
'post_type' => 'local',
'meta_key' => 'nom',
'meta_value' => $data['nom'],
'post_status'=> 'any',
'exclude' => [$id],
'numberposts'=> 1,
]);
if ($existing) {
return Api_Helper::json_error('Un local avec ce nom existe déjà', 409);
}
}
$result = wp_update_post([
'ID' => $id,
'post_title' => $data['nom'] ?? get_post_meta($id, 'nom', true),
], true);
if (is_wp_error($result)) {
return $result;
}
foreach ($data as $key => $value) {
update_post_meta($id, $key, $value);
}
return true;
}
/**
* Supprimer un local (corbeille WordPress)
* @param int $id
* @return bool|WP_Error
*/
public function delete(int $id) {
if (!is_user_logged_in() || !get_current_user_id()) {
return Api_Helper::json_error('Authentification requise', 401);
}
if (!current_user_can('delete_posts')) {
return Api_Helper::json_error('Non autorisé', 403);
}
$post = get_post($id);
if (!$post || $post->post_type !== 'local') {
return Api_Helper::json_error('Local introuvable', 404);
}
$result = wp_trash_post($id);
if (!$result) {
return Api_Helper::json_error('Erreur lors de la suppression', 500);
}
return true;
}
public static function all() {
return get_posts([
'post_type' => 'local',
'posts_per_page' => -1,
'post_status' => 'publish',
]);
}
}

118
app/models/Main_Model.php Normal file
View File

@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace ESI_CRVI_AGENDA\models;
/**
* Modèle générique pour la gestion des imports ACF
*/
class Main_Model {
/**
* Applique la logique d'import selon le type de champ défini dans $acf_schema
* @param int $post_id
* @param string $field
* @param mixed $value
* @param array $acf_schema
*/
public static function set_acf_field($post_id, $field, $value, $acf_schema) {
if (!isset($acf_schema[$field])) {
// Champ inconnu, fallback update_field
update_field($field, $value, $post_id);
return;
}
$type = $acf_schema[$field];
switch ($type) {
case 'taxonomy':
// Gérer plusieurs valeurs séparées par |
$terms = is_array($value) ? $value : explode('|', (string)$value);
$term_ids = [];
foreach ($terms as $term) {
$term = trim($term);
if (!$term) continue;
$slug = sanitize_title($term);
$taxonomy = $field; // On suppose que le nom du champ = nom de la taxonomie
if (!taxonomy_exists($taxonomy)) {
register_taxonomy($taxonomy, '', ['label' => ucfirst($taxonomy), 'public' => false]);
}
$term_obj = get_term_by('slug', $slug, $taxonomy);
if (!$term_obj) {
$result = wp_insert_term($term, $taxonomy, ['slug' => $slug]);
if (!is_wp_error($result) && isset($result['term_id'])) {
$term_ids[] = $result['term_id'];
}
} else {
$term_ids[] = $term_obj->term_id;
}
}
if (!empty($term_ids)) {
update_field($field, $term_ids, $post_id);
}
break;
case 'repeater':
// Valeur attendue : string format pipe ou array d'items
$rows = [];
if (is_string($value)) {
$items = explode('|', $value);
foreach ($items as $item) {
$row = [];
$pairs = explode(';', $item);
foreach ($pairs as $pair) {
[$k, $v] = array_pad(explode(':', $pair, 2), 2, null);
if ($k && $v !== null) {
if (self::is_date($v)) {
$v = \DateTime::createFromFormat('d/m/Y', $v);
$v = $v->format('Y-m-d');
}
$row[trim($k)] = trim($v);
}
}
if ($row) $rows[] = $row;
}
} elseif (is_array($value)) {
$rows = $value;
}
/* echo '<pre>';
print_r($rows);
echo '</pre>';
die(); */
update_field($field, $rows, $post_id);
break;
case 'group':
// Valeur attendue : array associatif
if (is_string($value)) {
$group = [];
$pairs = explode(';', $value);
foreach ($pairs as $pair) {
[$k, $v] = array_pad(explode(':', $pair, 2), 2, null);
if ($k && $v !== null) $group[trim($k)] = trim($v);
}
update_field($field, $group, $post_id);
} elseif (is_array($value)) {
update_field($field, $value, $post_id);
}
break;
case 'checkbox':
// Valeur attendue : string format pipe ou array d'items
$value = is_array($value) ? $value : explode('|', (string)$value);
update_field($field, $value, $post_id);
break;
case 'date_picker':
// Valeur attendue : string format d/m/Y
$value = \DateTime::createFromFormat('d/m/Y', $value);
$value = $value->format('Y-m-d');
update_field($field, $value, $post_id);
break;
default:
// Champ simple (text, select, radio, etc.)
update_field($field, $value, $post_id);
}
}
public static function is_date($input, $format = 'd/m/Y') {
$d = \DateTime::createFromFormat($format, $input);
return $d && $d->format($format) === $input;
}
}

View File

@ -0,0 +1,579 @@
<?php
declare(strict_types=1);
namespace ESI_CRVI_AGENDA\models;
use ESI_CRVI_AGENDA\helpers\Api_Helper;
class CRVI_Traducteur_Model extends Main_Model {
public $id;
public $nom;
public $prenom;
public $email;
public $langues_parlees;
public $jours_de_disponibilite;
public $indisponibilitee_ponctuelle;
public $organisme;
public $commentaires;
public $type_de_fiche;
/**
* Carte des codes de langue vers le nom de la langue (Europe continentale + Afrique du Nord)
*/
public static $lg_map = [
'fr' => 'Français',
'de' => 'Allemand',
'it' => 'Italien',
'es' => 'Espagnol',
'pt' => 'Portugais',
'nl' => 'Néerlandais',
'en' => 'Anglais',
'ru' => 'Russe',
'pl' => 'Polonais',
'ro' => 'Roumain',
'el' => 'Grec',
'tr' => 'Turc',
'ar' => 'Arabe',
'kab' => 'Kabyle',
'ber' => 'Berbère',
'tzm' => 'Tamazight',
'da' => 'Danois',
'sv' => 'Suédois',
'no' => 'Norvégien',
'fi' => 'Finnois',
'cs' => 'Tchèque',
'sk' => 'Slovaque',
'hu' => 'Hongrois',
'bg' => 'Bulgare',
'hr' => 'Croate',
'sr' => 'Serbe',
'bs' => 'Bosnien',
'sq' => 'Albanais',
'mk' => 'Macédonien',
'sl' => 'Slovène',
'he' => 'Hébreu',
'lt' => 'Lituanien',
'lv' => 'Letton',
'et' => 'Estonien',
];
/**
* Schéma des champs ACF : nom => type (ex : group, repeater, taxonomy, text...)
*/
public static $acf_schema = [
'nom' => 'text',
'prenom' => 'text',
'email' => 'email',
'organisme' => 'taxonomy',
'langues_parlees' => 'taxonomy',
'jours_de_disponibilite' => 'checkbox',
'indisponibilitee_ponctuelle' => 'repeater',
'coordonnees' => 'group',
'commentaires' => 'textarea',
'type_de_fiche' => 'select',
];
public function __construct($data = []) {
foreach ($data as $key => $value) {
if (property_exists($this, $key)) {
$this->$key = $value;
}
}
}
public static function get_traducteurs($filters = [],$simple_list = false) {
$posts = get_posts([
'post_type' => 'traducteur',
'numberposts' => -1,
'meta_query' => $filters,
]);
if ($simple_list) {
$posts = array_map(function($post) {
return [
'id' => $post->ID,
'nom' => $post->post_title,
];
}, $posts);
}
return $posts;
}
public static function load($id, $fields = []) {
// Charger depuis CPT/meta
$traducteur = get_post($id);
if (!$traducteur) {
return null;
}
// Si des champs spécifiques sont demandés, ne charger que ceux-ci
if (!empty($fields)) {
$data = [];
foreach ($fields as $field) {
if ($field === 'id') {
$data['id'] = $traducteur->ID;
} elseif (property_exists(self::class, $field)) {
$data[$field] = get_field($field, $traducteur->ID);
}
}
return new self($data);
}
// Sinon, charger tous les champs par défaut
return new self([
'id' => $traducteur->ID,
'nom' => get_field('nom', $traducteur->ID),
'prenom' => get_field('prenom', $traducteur->ID),
'email' => get_field('email', $traducteur->ID),
'langues_parlees' => get_field('langues_parlees', $traducteur->ID),
'jours_de_disponibilite' => get_field('jours_de_disponibilite', $traducteur->ID),
'indisponibilitee_ponctuelle' => get_field('indisponibilitee_ponctuelle', $traducteur->ID),
'organisme' => get_field('organisme', $traducteur->ID),
'commentaires' => get_field('commentaires', $traducteur->ID),
'type_de_fiche' => get_field('type_de_fiche', $traducteur->ID),
]);
}
public function save() {
// À implémenter : sauvegarder dans CPT/meta
return true;
}
/**
* Retourne les traducteurs disponibles à une date donnée et pour une langue donnée.
* @param string $date au format Y-m-d
* @param string $langue_slug (slug de la langue)
* @param int|null $event_id ID de l'événement en cours d'édition (pour inclure le traducteur actuel)
* @return array Liste des traducteurs disponibles (CRVI_Traducteur_Model)
*/
public static function filtrer_disponibles($date, $langue_slug, $event_id = null) {
if (empty($date)) {
return [];
}
// Construire la requête de base
$args = [
'post_type' => 'traducteur',
'numberposts' => -1,
];
// Ajouter le filtre de langue seulement si spécifié
if (!empty($langue_slug)) {
$args['tax_query'] = [
[
'taxonomy' => 'langue',
'field' => 'slug',
'terms' => $langue_slug,
],
];
}
$traducteurs = get_posts($args);
$disponibles = [];
foreach ($traducteurs as $traducteur_post) {
$traducteur = new self([
'id' => $traducteur_post->ID,
'nom' => get_field('nom', $traducteur_post->ID),
'prenom' => get_field('prenom', $traducteur_post->ID),
'email' => get_field('email', $traducteur_post->ID),
'langues_parlees' => get_field('langues_parlees', $traducteur_post->ID),
'jours_de_disponibilite' => get_field('jours_de_disponibilite', $traducteur_post->ID),
'indisponibilitee_ponctuelle' => get_field('indisponibilitee_ponctuelle', $traducteur_post->ID),
'organisme' => get_field('organisme', $traducteur_post->ID),
'commentaires' => get_field('commentaires', $traducteur_post->ID),
'type_de_fiche' => get_field('type_de_fiche', $traducteur_post->ID),
]);
// Vérifier si le traducteur est disponible
$is_disponible = $traducteur->is_disponible($traducteur->id, $date, $langue_slug);
// Vérifier les conflits d'événements existants
if ($is_disponible) {
$conflits = self::verifier_conflits_traducteur($traducteur->id, $date, $event_id);
if (!empty($conflits)) {
$is_disponible = false;
}
}
// Si c'est l'événement en cours d'édition, inclure le traducteur même s'il est "pris"
if ($event_id && self::is_traducteur_of_event($traducteur->id, $event_id)) {
$is_disponible = true;
}
if ($is_disponible) {
$disponibles[] = $traducteur;
}
}
return $disponibles;
}
/**
* Vérifie si un traducteur est associé à un événement donné
* @param int $traducteur_id
* @param int $event_id
* @return bool
*/
private static function is_traducteur_of_event($traducteur_id, $event_id) {
global $wpdb;
$table_name = $wpdb->prefix . 'crvi_agenda';
$result = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM $table_name WHERE id = %d AND id_traducteur = %d",
$event_id,
$traducteur_id
));
return !empty($result);
}
/**
* Vérifie si ce traducteur est disponible à une date donnée et pour une langue donnée.
* @param string $date au format Y-m-d
* @param string|null $langue_slug
* @return bool
*/
public function is_disponible($traducteur_id, $date, $langue_slug = null) {
// Vérifier que la date n'est pas null ou vide
if (empty($date)) {
return false;
}
$timestamp = strtotime($date);
if ($timestamp === false) {
return false;
}
$jour = strtolower(date('l', $timestamp));
$jours_disponibles = get_field('jours_de_disponibilite', 'user_' . $traducteur_id);
if (!is_array($jours_disponibles) || !in_array($jour, $jours_disponibles, true)) {
return false;
}
$indisponibilites = get_field('indisponibilitee_ponctuelle', 'user_' . $traducteur_id);
if (is_array($indisponibilites)) {
foreach ($indisponibilites as $absence) {
$debut = isset($absence['debut']) ? strtotime($absence['debut']) : null;
$fin = isset($absence['fin']) ? strtotime($absence['fin']) : null;
if ($debut && $fin && $timestamp >= $debut && $timestamp <= $fin) {
return false;
}
}
}
// Optionnel : vérifier la langue si besoin
if ($langue_slug && is_array($this->langues_parlees) && !in_array($langue_slug, $this->langues_parlees)) {
return false;
}
return true;
}
public function get_relations() {
// À implémenter : récupérer les rendez-vous associés, etc.
return [];
}
/**
* Créer un traducteur.
* @param array $data
* @return int|WP_Error
*/
public static function create(array $data, bool $as_rest = false) {
if (empty($data['nom']) || empty($data['prenom']) || empty($data['email'])) {
if ($as_rest) {
return [
'success' => false,
'code' => 400,
'message' => 'Champs obligatoires manquants',
'data' => null
];
}
return false;
}
// Chercher un traducteur existant par email
$existing = get_posts([
'post_type' => 'traducteur',
'meta_key' => 'email',
'meta_value' => $data['email'],
'post_status'=> 'any',
'numberposts'=> 1,
]);
if ($existing) {
// Mise à jour
$post_id = $existing[0]->ID;
// Mettre à jour le titre si nom/prenom fournis
$post_title = ($data['nom'] ?? get_post_meta($post_id, 'nom', true)) . ' ' . ($data['prenom'] ?? get_post_meta($post_id, 'prenom', true));
wp_update_post([
'ID' => $post_id,
'post_title' => $post_title,
]);
foreach ($data as $key => $value) {
if ($key === 'organisme') {
if (!taxonomy_exists('organisme')) {
register_taxonomy(
'organisme',
'traducteur',
[
'label' => 'Organismes',
'public' => false,
'hierarchical' => false,
'show_ui' => true,
'show_in_rest' => false,
]
);
}
$org_ids = [];
$organismes = is_array($value) ? $value : explode('|', $value);
foreach ($organismes as $org_nom) {
$org_nom = trim($org_nom);
if (empty($org_nom)) continue;
$slug = sanitize_title($org_nom);
$term = get_term_by('slug', $slug, 'organisme');
if (!$term) {
$result = wp_insert_term($org_nom, 'organisme', ['slug' => $slug]);
if (!is_wp_error($result) && isset($result['term_id'])) {
$org_ids[] = $result['term_id'];
}
} else {
$org_ids[] = $term->term_id;
}
}
if (!empty($org_ids)) {
update_field('organisme', $org_ids, $post_id);
}
} else if ($key === 'langues_parlees') {
if (!taxonomy_exists('langue')) {
register_taxonomy(
'langue',
'traducteur',
[
'label' => 'Langues',
'public' => false,
'hierarchical' => false,
'show_ui' => true,
'show_in_rest' => false,
]
);
}
$langue_ids = [];
$langues = is_array($value) ? $value : explode('|', $value);
foreach ($langues as $slug) {
$slug = trim($slug);
if (empty($slug)) continue;
$term = get_term_by('slug', $slug, 'langue');
if (!$term) {
$nom = self::$lg_map[$slug] ?? $slug;
$result = wp_insert_term($nom, 'langue', ['slug' => $slug]);
if (!is_wp_error($result) && isset($result['term_id'])) {
$langue_ids[] = $result['term_id'];
}
} else {
$langue_ids[] = $term->term_id;
}
}
if (!empty($langue_ids)) {
update_field('langue', $langue_ids, $post_id);
}
} else if (isset(self::$acf_schema[$key])) {
self::set_acf_field($post_id, $key, $value, self::$acf_schema);
} else {
update_post_meta($post_id, $key, $value);
}
}
return $post_id;
}
// Création classique
$post_id = wp_insert_post([
'post_type' => 'traducteur',
'post_title' => $data['nom'] . ' ' . $data['prenom'],
'post_status' => 'publish',
]);
if (is_wp_error($post_id)) {
if ($as_rest) {
return [
'success' => false,
'code' => 500,
'message' => 'Erreur lors de la création',
'data' => json_encode($post_id)
];
}
return $post_id;
}
foreach ($data as $key => $value) {
if ($key === 'organisme') {
if (!taxonomy_exists('organisme')) {
register_taxonomy(
'organisme',
'traducteur',
[
'label' => 'Organismes',
'public' => false,
'hierarchical' => false,
'show_ui' => true,
'show_in_rest' => false,
]
);
}
$org_ids = [];
$organismes = is_array($value) ? $value : explode('|', $value);
foreach ($organismes as $org_nom) {
$org_nom = trim($org_nom);
if (empty($org_nom)) continue;
$slug = sanitize_title($org_nom);
$term = get_term_by('slug', $slug, 'organisme');
if (!$term) {
$result = wp_insert_term($org_nom, 'organisme', ['slug' => $slug]);
if (!is_wp_error($result) && isset($result['term_id'])) {
$org_ids[] = $result['term_id'];
}
} else {
$org_ids[] = $term->term_id;
}
}
if (!empty($org_ids)) {
update_field('organisme', $org_ids, $post_id);
}
} else if ($key === 'langues_parlees') {
if (!taxonomy_exists('langue')) {
register_taxonomy(
'langue',
'traducteur',
[
'label' => 'Langues',
'public' => false,
'hierarchical' => false,
'show_ui' => true,
'show_in_rest' => false,
]
);
}
$langue_ids = [];
$langues = is_array($value) ? $value : explode('|', $value);
foreach ($langues as $slug) {
$slug = trim($slug);
if (empty($slug)) continue;
$term = get_term_by('slug', $slug, 'langue');
if (!$term) {
$nom = self::$lg_map[$slug] ?? $slug;
$result = wp_insert_term($nom, 'langue', ['slug' => $slug]);
if (!is_wp_error($result) && isset($result['term_id'])) {
$langue_ids[] = $result['term_id'];
}
} else {
$langue_ids[] = $term->term_id;
}
}
if (!empty($langue_ids)) {
update_field('langue', $langue_ids, $post_id);
}
} else if (isset(self::$acf_schema[$key])) {
self::set_acf_field($post_id, $key, $value, self::$acf_schema);
} else {
update_post_meta($post_id, $key, $value);
}
}
return $post_id;
}
/**
* Mettre à jour un traducteur.
* @param int $id
* @param array $data
* @return bool|WP_Error
*/
public function update(int $id, array $data) {
if (!is_user_logged_in() || !get_current_user_id()) {
return Api_Helper::json_error('Authentification requise', 401);
}
if (!current_user_can('edit_posts')) {
return Api_Helper::json_error('Non autorisé', 403);
}
$post = get_post($id);
if (!$post || $post->post_type !== 'traducteur') {
return Api_Helper::json_error('Traducteur introuvable', 404);
}
// Vérifier unicité de l'email si modifié
if (!empty($data['email'])) {
$existing = get_posts([
'post_type' => 'traducteur',
'meta_key' => 'email',
'meta_value' => $data['email'],
'post_status'=> 'any',
'exclude' => [$id],
'numberposts'=> 1,
]);
if ($existing) {
return Api_Helper::json_error('Email déjà utilisé', 409);
}
}
$result = wp_update_post([
'ID' => $id,
'post_title' => ($data['nom'] ?? get_post_meta($id, 'nom', true)) . ' ' . ($data['prenom'] ?? get_post_meta($id, 'prenom', true)),
], true);
if (is_wp_error($result)) {
return $result;
}
foreach ($data as $key => $value) {
update_post_meta($id, $key, $value);
}
return true;
}
/**
* Supprimer un traducteur (corbeille WordPress)
* @param int $id
* @return bool|WP_Error
*/
public function delete(int $id) {
if (!is_user_logged_in() || !get_current_user_id()) {
return Api_Helper::json_error('Authentification requise', 401);
}
if (!current_user_can('delete_posts')) {
return Api_Helper::json_error('Non autorisé', 403);
}
$post = get_post($id);
if (!$post || $post->post_type !== 'traducteur') {
return Api_Helper::json_error('Traducteur introuvable', 404);
}
$result = wp_trash_post($id);
if (!$result) {
return Api_Helper::json_error('Erreur lors de la suppression', 500);
}
return true;
}
/**
* Vérifie s'il y a des conflits d'événements pour un traducteur donné
* @param int $traducteur_id
* @param string $date
* @param int|null $event_id - ID de l'événement à exclure (pour l'édition)
* @return array
*/
private static function verifier_conflits_traducteur($traducteur_id, $date, $event_id = null) {
global $wpdb;
$table_events = $wpdb->prefix . 'crvi_agenda';
$where_conditions = ['id_traducteur = %d'];
$where_values = [$traducteur_id];
// Exclure l'événement en cours d'édition si spécifié
if ($event_id) {
$where_conditions[] = 'id != %d';
$where_values[] = $event_id;
}
// Ajouter la condition de date
$where_conditions[] = 'date_rdv = %s';
$where_values[] = $date;
$where_clause = implode(' AND ', $where_conditions);
$query = $wpdb->prepare(
"SELECT * FROM {$table_events} WHERE {$where_clause}",
$where_values
);
return $wpdb->get_results($query, ARRAY_A);
}
}

View File

@ -0,0 +1,898 @@
<?php
declare(strict_types=1);
namespace ESI_CRVI_AGENDA\models;
use ESI_CRVI_AGENDA\models\CRVI_Event_Model;
/**
* Modèle pour les capacités de traduction
* Gère les règles de capacité par langue, jour et période
*/
class CRVI_TraductionLangue_Model extends Main_Model {
public $id;
public $langue; // Peut être un tableau d'IDs de langues (multi-select)
public $jour;
public $periode;
public $limite;
public $limite_par;
public $actif;
public $ajouter_exception;
public $exceptions_disponibilites;
/**
* Schéma des champs ACF : nom => type
*/
public static $acf_schema = [
'langue' => 'taxonomy', // multi_select - retourne un tableau d'IDs
'jour' => 'select',
'periode' => 'select',
'limite' => 'number',
'limite_par' => 'select',
'actif' => 'true_false',
'ajouter_exception' => 'true_false',
'exceptions_disponibilites' => 'group',
];
public function __construct($data = []) {
foreach ($data as $key => $value) {
if (property_exists($this, $key)) {
$this->$key = $value;
}
}
}
/**
* Charge une capacité de traduction par ID
* @param int $id
* @param array $fields Champs spécifiques à charger (optionnel)
* @return self|null
*/
public static function load($id, $fields = []) {
$post = \get_post($id);
if (!$post || $post->post_type !== 'traduction_langue') {
return null;
}
// Si des champs spécifiques sont demandés
if (!empty($fields)) {
$data = ['id' => $post->ID];
foreach ($fields as $field) {
if (property_exists(self::class, $field)) {
$data[$field] = \get_field($field, $post->ID);
}
}
return new self($data);
}
// Charger tous les champs
$langue_field = \get_field('langue', $post->ID);
// S'assurer que langue est toujours un tableau
if (!is_array($langue_field)) {
$langue_field = !empty($langue_field) ? [$langue_field] : [];
}
return new self([
'id' => $post->ID,
'langue' => $langue_field, // Tableau d'IDs de langues
'jour' => \get_field('jour', $post->ID),
'periode' => \get_field('periode', $post->ID),
'limite' => \get_field('limite', $post->ID),
'limite_par' => \get_field('limite_par', $post->ID) ?: 'semaine',
'actif' => \get_field('actif', $post->ID) !== false,
'ajouter_exception' => \get_field('ajouter_exception', $post->ID) ?: false,
'exceptions_disponibilites' => \get_field('exceptions_disponibilites', $post->ID) ?: null,
]);
}
/**
* Récupère toutes les capacités actives
* @param array $filters Filtres optionnels (langue, jour, periode, type_capacite)
* @param bool $use_cache Utiliser le cache (par défaut: true)
* @return array Liste des capacités actives
*/
public static function getActiveCapacites(array $filters = [], bool $use_cache = true): array {
// Générer une clé de cache basée sur les filtres
$cache_key = 'crvi_capacites_actives_' . md5(serialize($filters));
// Essayer de récupérer depuis le cache si activé et sans filtres complexes
if ($use_cache && empty($filters)) {
$cached = \get_transient($cache_key);
if ($cached !== false) {
return $cached;
}
}
$args = [
'post_type' => 'traduction_langue',
'posts_per_page' => -1,
'post_status' => 'publish',
'meta_query' => [
[
'key' => 'actif',
'value' => '1',
'compare' => '=',
],
],
];
// Appliquer les filtres
if (!empty($filters['langue'])) {
$args['tax_query'] = [
[
'taxonomy' => 'langue',
'field' => 'term_id',
'terms' => (int) $filters['langue'],
],
];
}
if (!empty($filters['jour'])) {
$args['meta_query'][] = [
'key' => 'jour',
'value' => $filters['jour'],
'compare' => '=',
];
}
if (!empty($filters['periode'])) {
$args['meta_query'][] = [
'key' => 'periode',
'value' => $filters['periode'],
'compare' => '=',
];
}
$posts = \get_posts($args);
$capacites = [];
foreach ($posts as $post) {
$capacites[] = self::load($post->ID);
}
// Mettre en cache si activé et sans filtres
if ($use_cache && empty($filters)) {
\set_transient($cache_key, $capacites, 1 * \HOUR_IN_SECONDS);
}
return $capacites;
}
/**
* Compte le nombre de créneaux utilisés pour une capacité donnée
* Gère la période (semaine/mois) et exclut les événements annulés/brouillons
*
* @param int $capacite_id ID de la capacité
* @param string|null $date Date de référence (Y-m-d). Si null, utilise la date actuelle
* @param self|null $capacite Capacité déjà chargée (évite un appel load() supplémentaire)
* @return int Nombre de créneaux utilisés
*/
public static function countUsed(int $capacite_id, ?string $date = null, ?self $capacite = null): int {
// Charger la capacité si non fournie
if (!$capacite) {
$capacite = self::load($capacite_id);
if (!$capacite) {
return 0;
}
}
// Date de référence
if (!$date) {
$date = \current_time('Y-m-d');
}
// Générer une clé de cache unique
$cache_key = 'crvi_count_used_' . $capacite_id . '_' . $date . '_' . md5(serialize([
$capacite->langue,
$capacite->jour,
$capacite->periode,
$capacite->limite_par,
]));
// Essayer de récupérer depuis le cache (cache de 15 minutes)
$cached = \get_transient($cache_key);
if ($cached !== false) {
return (int) $cached;
}
// Déterminer la plage de dates selon limite_par
$date_debut = $date;
$date_fin = $date;
$date_obj = new \DateTime($date);
if ($capacite->limite_par === 'semaine') {
// Calculer le début et la fin de la semaine
$start_of_week = (int) \get_option('start_of_week', 1); // 0=dimanche, 1=lundi, etc.
// Obtenir le jour de la semaine (0=dimanche, 1=lundi, ..., 6=samedi)
$day_of_week = (int) $date_obj->format('w');
// Calculer le nombre de jours à soustraire pour arriver au début de la semaine
$days_to_subtract = ($day_of_week - $start_of_week + 7) % 7;
$date_obj->modify("-{$days_to_subtract} days");
$date_debut = $date_obj->format('Y-m-d');
$date_obj->modify('+6 days');
$date_fin = $date_obj->format('Y-m-d');
} elseif ($capacite->limite_par === 'mois') {
// Premier et dernier jour du mois
$date_debut = $date_obj->format('Y-m-01');
$date_fin = $date_obj->format('Y-m-t');
}
// Récupérer les langues (peut être un tableau)
$langue_ids = $capacite->langue;
if (empty($langue_ids)) {
return 0;
}
// S'assurer que c'est un tableau
if (!is_array($langue_ids)) {
$langue_ids = [$langue_ids];
}
// Récupérer les slugs de toutes les langues (avec cache)
$langue_slugs = [];
foreach ($langue_ids as $langue_id) {
$slug = self::get_langue_slug($langue_id);
if ($slug) {
$langue_slugs[] = $slug;
}
}
if (empty($langue_slugs)) {
return 0;
}
// Convertir le jour en format de requête
$jour_nom = $capacite->jour; // lundi, mardi, etc.
// Requête pour compter les événements
global $wpdb;
$table_name = $wpdb->prefix . 'crvi_agenda';
// Construire la requête SQL
// Pour les langues multiples, utiliser IN au lieu de =
$langue_placeholders = implode(',', array_fill(0, count($langue_slugs), '%s'));
$where = [
"date_rdv BETWEEN %s AND %s",
"langue IN ($langue_placeholders)",
"is_deleted = 0",
"statut != 'annule'",
"statut != 'brouillon'",
];
$values = array_merge([$date_debut, $date_fin], $langue_slugs);
// Filtrer par jour de la semaine
// DAYOFWEEK() en MySQL retourne: 1=dimanche, 2=lundi, 3=mardi, 4=mercredi, 5=jeudi, 6=vendredi, 7=samedi
$jours_map = [
'dimanche' => 1,
'lundi' => 2,
'mardi' => 3,
'mercredi' => 4,
'jeudi' => 5,
'vendredi' => 6,
'samedi' => 7,
];
if (isset($jours_map[$jour_nom])) {
$where[] = "DAYOFWEEK(date_rdv) = %d";
$values[] = $jours_map[$jour_nom];
}
// Filtrer par période (matin, après-midi, journée)
// Matin: heure_rdv < 12:00
// Après-midi: heure_rdv >= 12:00
// Journée: pas de filtre sur l'heure (tous les événements du jour)
if ($capacite->periode === 'matin') {
$where[] = "heure_rdv < '12:00:00'";
} elseif ($capacite->periode === 'apres_midi') {
$where[] = "heure_rdv >= '12:00:00'";
}
// Si période = 'journee', on ne filtre pas par heure
$where_sql = 'WHERE ' . implode(' AND ', $where);
$sql = "SELECT COUNT(*) FROM {$table_name} {$where_sql}";
$prepared_sql = $wpdb->prepare($sql, $values);
$count = (int) $wpdb->get_var($prepared_sql);
// Mettre en cache le résultat (15 minutes)
\set_transient($cache_key, $count, 15 * \MINUTE_IN_SECONDS);
return $count;
}
/**
* Récupère le slug d'une langue avec cache
* @param int $langue_term_id Term ID de la langue
* @return string|null Slug de la langue ou null si erreur
*/
private static function get_langue_slug(int $langue_term_id): ?string {
$cache_key = 'crvi_langue_slug_' . $langue_term_id;
$cached = \get_transient($cache_key);
if ($cached !== false) {
return $cached;
}
$langue_term = \get_term($langue_term_id, 'langue');
if (!$langue_term || \is_wp_error($langue_term)) {
return null;
}
$slug = $langue_term->slug;
// Mettre en cache (1 heure)
\set_transient($cache_key, $slug, 1 * \HOUR_IN_SECONDS);
return $slug;
}
/**
* Vérifie si une capacité a des événements associés
* Optimisé avec EXISTS au lieu de COUNT pour meilleure performance
* Gère les langues multiples (limite partagée)
* @param int $capacite_id ID de la capacité
* @return bool True si la capacité a des événements
*/
public static function hasEvents(int $capacite_id): bool {
$capacite = self::load($capacite_id);
if (!$capacite) {
return false;
}
// Récupérer les langues (peut être un tableau)
$langue_ids = $capacite->langue;
if (empty($langue_ids)) {
return false;
}
// S'assurer que c'est un tableau
if (!is_array($langue_ids)) {
$langue_ids = [$langue_ids];
}
// Récupérer les slugs de toutes les langues
$langue_slugs = [];
foreach ($langue_ids as $langue_id) {
$slug = self::get_langue_slug($langue_id);
if ($slug) {
$langue_slugs[] = $slug;
}
}
if (empty($langue_slugs)) {
return false;
}
// Requête optimisée avec EXISTS (plus rapide que COUNT)
global $wpdb;
$table_name = $wpdb->prefix . 'crvi_agenda';
// Construire les placeholders pour IN
$langue_placeholders = implode(',', array_fill(0, count($langue_slugs), '%s'));
$sql = $wpdb->prepare(
"SELECT EXISTS(
SELECT 1 FROM {$table_name}
WHERE langue IN ($langue_placeholders)
AND is_deleted = 0
AND statut != 'annule'
AND statut != 'brouillon'
LIMIT 1
)",
$langue_slugs
);
$exists = (int) $wpdb->get_var($sql);
return $exists === 1;
}
/**
* Vérifie si une capacité est disponible (limite non atteinte)
* @param int $capacite_id ID de la capacité
* @param string|null $date Date de référence (Y-m-d). Si null, utilise la date actuelle
* @param self|null $capacite Capacité déjà chargée (évite un appel load() supplémentaire)
* @return bool True si la capacité est disponible
*/
public static function isAvailable(int $capacite_id, ?string $date = null, ?self $capacite = null): bool {
// Charger la capacité si non fournie
if (!$capacite) {
$capacite = self::load($capacite_id);
if (!$capacite) {
return false;
}
}
// Si la capacité est inactive, elle n'est pas disponible
if (!$capacite->actif) {
return false;
}
// Si pas de limite définie, la capacité est toujours disponible
if (empty($capacite->limite) || $capacite->limite <= 0) {
return true;
}
// Compter les créneaux utilisés (en passant la capacité pour éviter un load() supplémentaire)
$used = self::countUsed($capacite_id, $date, $capacite);
// Vérifier si la limite est atteinte
return $used < $capacite->limite;
}
/**
* Calcule la limite effective en prenant en compte les exceptions
* Exemple : 1 fois/semaine matin (= ~4/mois) + exception 1/mois après-midi = 4 matins + 1 après-midi
*
* @param self $capacite Capacité
* @param string|null $date Date de référence (Y-m-d). Si null, utilise la date actuelle
* @return array ['principale' => ['limite' => int, 'periode' => string], 'exception' => ['limite' => int, 'periode' => string] | null]
*/
public static function getEffectiveLimit(self $capacite, ?string $date = null): array {
if (!$date) {
$date = \current_time('Y-m-d');
}
$result = [
'principale' => [
'limite' => (int) $capacite->limite,
'periode' => $capacite->periode,
'limite_par' => $capacite->limite_par,
],
'exception' => null,
];
// Si pas d'exception, retourner seulement la limite principale
if (!$capacite->ajouter_exception || empty($capacite->exceptions_disponibilites)) {
return $result;
}
$exceptions = $capacite->exceptions_disponibilites;
// Vérifier que l'exception a les champs nécessaires
if (empty($exceptions['frequence']) || empty($exceptions['frequence_periode']) || empty($exceptions['periode'])) {
return $result;
}
// Calculer la limite de l'exception
$exception_limite = (int) $exceptions['frequence'];
$exception_periode = $exceptions['periode'];
$exception_frequence_periode = $exceptions['frequence_periode']; // 'semaine' ou 'mois'
// Si l'exception est par mois, utiliser directement la fréquence
if ($exception_frequence_periode === 'mois') {
$result['exception'] = [
'limite' => $exception_limite,
'periode' => $exception_periode,
'limite_par' => 'mois',
];
} else {
// Si l'exception est par semaine, convertir en nombre par mois
$date_obj = new \DateTime($date);
$nb_semaines = self::getNbSemainesDansMois($date_obj);
$result['exception'] = [
'limite' => $exception_limite * $nb_semaines,
'periode' => $exception_periode,
'limite_par' => 'mois',
];
}
return $result;
}
/**
* Calcule le nombre de semaines dans un mois donné
*
* @param \DateTime $date Date de référence
* @return int Nombre de semaines
*/
private static function getNbSemainesDansMois(\DateTime $date): int {
$premier_jour = new \DateTime($date->format('Y-m-01'));
$dernier_jour = new \DateTime($date->format('Y-m-t'));
$start_of_week = (int) \get_option('start_of_week', 1); // 0=dimanche, 1=lundi
// Calculer le nombre de semaines
$diff = $premier_jour->diff($dernier_jour);
$nb_jours = $diff->days + 1;
// Arrondir au nombre de semaines (4 ou 5 selon le mois)
return (int) ceil($nb_jours / 7);
}
/**
* Compte les événements utilisés pour la période principale et l'exception
*
* @param int $capacite_id ID de la capacité
* @param string|null $date Date de référence (Y-m-d). Si null, utilise la date actuelle
* @param self|null $capacite Capacité déjà chargée
* @return array ['principale' => int, 'exception' => int, 'total' => int]
*/
public static function countUsedWithExceptions(int $capacite_id, ?string $date = null, ?self $capacite = null): array {
// Charger la capacité si non fournie
if (!$capacite) {
$capacite = self::load($capacite_id);
if (!$capacite) {
return ['principale' => 0, 'exception' => 0, 'total' => 0];
}
}
// Compter les événements pour la période principale
$count_principale = self::countUsed($capacite_id, $date, $capacite);
$result = [
'principale' => $count_principale,
'exception' => 0,
'total' => $count_principale,
];
// Si pas d'exception, retourner
if (!$capacite->ajouter_exception || empty($capacite->exceptions_disponibilites)) {
return $result;
}
$exceptions = $capacite->exceptions_disponibilites;
// Vérifier que l'exception a les champs nécessaires
if (empty($exceptions['periode'])) {
return $result;
}
// Compter les événements pour l'exception (période différente)
$exception_periode = $exceptions['periode'];
// Si la période de l'exception est différente de la période principale
if ($exception_periode !== $capacite->periode) {
// Créer une capacité temporaire avec la période de l'exception
$temp_capacite = clone $capacite;
$temp_capacite->periode = $exception_periode;
$count_exception = self::countUsed($capacite_id, $date, $temp_capacite);
$result['exception'] = $count_exception;
$result['total'] = $count_principale + $count_exception;
}
return $result;
}
/**
* Récupère les disponibilités pour une langue donnée en agrégeant toutes les capacités
* Exemple : Si 2 capacités existent pour l'arabe (1/mois matin + 1/semaine après-midi)
* Le résultat sera : 1 matin/mois + ~4 après-midi/mois = 5 créneaux/mois au total
*
* @param int $langue_term_id ID du terme de la taxonomie langue
* @param string|null $date Date de référence (Y-m-d). Si null, utilise la date actuelle
* @param bool $use_cache Utiliser le cache transient (par défaut: true)
* @return array ['total' => int, 'by_periode' => ['matin' => [...], 'apres_midi' => [...], 'journee' => [...]]]
*/
public static function getDisponibilitesByLangue(int $langue_term_id, ?string $date = null, bool $use_cache = true): array {
if (!$date) {
$date = \current_time('Y-m-d');
}
// Générer une clé de cache unique basée sur la langue et la date
$cache_key = 'crvi_dispos_langue_' . $langue_term_id . '_' . $date;
// Essayer de récupérer depuis le cache
if ($use_cache) {
$cached = \get_transient($cache_key);
if ($cached !== false) {
return $cached;
}
}
// Initialiser le résultat
$result = [
'total' => 0,
'total_used' => 0,
'total_available' => 0,
'by_periode' => [
'matin' => [],
'apres_midi' => [],
'journee' => [],
],
];
// Récupérer toutes les capacités actives pour cette langue
$capacites = self::getActiveCapacites(['langue' => $langue_term_id], false);
if (empty($capacites)) {
return $result;
}
$date_obj = new \DateTime($date);
$nb_semaines = self::getNbSemainesDansMois($date_obj);
// Parcourir chaque capacité et calculer sa contribution
foreach ($capacites as $capacite) {
// Vérifier que la langue correspond (peut être un tableau)
$capacite_langues = is_array($capacite->langue) ? $capacite->langue : [$capacite->langue];
if (!in_array($langue_term_id, $capacite_langues)) {
continue;
}
// Calculer la limite mensuelle pour la période principale
$limite_mensuelle_principale = self::convertToMonthlyLimit(
$capacite->limite,
$capacite->limite_par,
$nb_semaines
);
// Compter les événements utilisés pour la période principale
$used_principale = self::countUsed($capacite->id, $date, $capacite);
// Ajouter la capacité principale
$periode_principale = $capacite->periode;
$result['by_periode'][$periode_principale][] = [
'capacite_id' => $capacite->id,
'capacite_title' => \get_the_title($capacite->id),
'jour' => $capacite->jour,
'limite' => $limite_mensuelle_principale,
'used' => $used_principale,
'available' => max(0, $limite_mensuelle_principale - $used_principale),
'type' => 'principale',
];
$result['total'] += $limite_mensuelle_principale;
$result['total_used'] += $used_principale;
// Si la capacité a une exception
if ($capacite->ajouter_exception && !empty($capacite->exceptions_disponibilites)) {
$exceptions = $capacite->exceptions_disponibilites;
if (!empty($exceptions['frequence']) && !empty($exceptions['frequence_periode']) && !empty($exceptions['periode'])) {
// Calculer la limite mensuelle pour l'exception
$limite_mensuelle_exception = self::convertToMonthlyLimit(
(int) $exceptions['frequence'],
$exceptions['frequence_periode'],
$nb_semaines
);
// Compter les événements utilisés pour l'exception
$temp_capacite = clone $capacite;
$temp_capacite->periode = $exceptions['periode'];
$used_exception = self::countUsed($capacite->id, $date, $temp_capacite);
// Ajouter l'exception
$periode_exception = $exceptions['periode'];
$result['by_periode'][$periode_exception][] = [
'capacite_id' => $capacite->id,
'capacite_title' => \get_the_title($capacite->id) . ' (Exception)',
'jour' => $capacite->jour,
'limite' => $limite_mensuelle_exception,
'used' => $used_exception,
'available' => max(0, $limite_mensuelle_exception - $used_exception),
'type' => 'exception',
];
$result['total'] += $limite_mensuelle_exception;
$result['total_used'] += $used_exception;
}
}
}
// Calculer le total disponible
$result['total_available'] = max(0, $result['total'] - $result['total_used']);
// Calculer les totaux par période
foreach ($result['by_periode'] as $periode => &$items) {
$periode_total = 0;
$periode_used = 0;
$periode_available = 0;
foreach ($items as $item) {
$periode_total += $item['limite'];
$periode_used += $item['used'];
$periode_available += $item['available'];
}
// Ajouter un résumé pour cette période
$result['by_periode'][$periode . '_summary'] = [
'total' => $periode_total,
'used' => $periode_used,
'available' => $periode_available,
];
}
// Mettre en cache le résultat (2 jours)
if ($use_cache) {
\set_transient($cache_key, $result, 2 * \DAY_IN_SECONDS);
}
return $result;
}
/**
* Convertit une limite (semaine ou mois) en limite mensuelle
*
* @param int $limite Limite originale
* @param string $limite_par 'semaine' ou 'mois'
* @param int $nb_semaines Nombre de semaines dans le mois
* @return int Limite mensuelle
*/
private static function convertToMonthlyLimit(int $limite, string $limite_par, int $nb_semaines): int {
if ($limite_par === 'semaine') {
// Convertir hebdomadaire en mensuel
return $limite * $nb_semaines;
}
// Déjà mensuel
return $limite;
}
/**
* Récupère un résumé formaté des disponibilités par langue
* Version simplifiée pour affichage rapide
*
* @param int $langue_term_id ID du terme de la taxonomie langue
* @param string|null $date Date de référence (Y-m-d). Si null, utilise la date actuelle
* @param bool $use_cache Utiliser le cache transient (par défaut: true)
* @return array ['matin' => 'X disponibles / Y total', 'apres_midi' => '...', 'journee' => '...', 'total' => '...']
*/
public static function getDisponibilitesSummaryByLangue(int $langue_term_id, ?string $date = null, bool $use_cache = true): array {
$dispos = self::getDisponibilitesByLangue($langue_term_id, $date, $use_cache);
$summary = [];
foreach (['matin', 'apres_midi', 'journee'] as $periode) {
if (isset($dispos['by_periode'][$periode . '_summary'])) {
$data = $dispos['by_periode'][$periode . '_summary'];
if ($data['total'] > 0) {
$summary[$periode] = sprintf(
'%d disponibles / %d total (%d utilisés)',
$data['available'],
$data['total'],
$data['used']
);
}
}
}
$summary['total'] = sprintf(
'%d disponibles / %d total (%d utilisés)',
$dispos['total_available'],
$dispos['total'],
$dispos['total_used']
);
return $summary;
}
/**
* Récupère toutes les langues avec leurs disponibilités
* Utile pour afficher un tableau récapitulatif
*
* @param string|null $date Date de référence (Y-m-d). Si null, utilise la date actuelle
* @param bool $use_cache Utiliser le cache transient (par défaut: true)
* @return array ['langue_slug' => ['name' => '...', 'total' => X, 'available' => Y, ...]]
*/
public static function getAllLanguesDisponibilites(?string $date = null, bool $use_cache = true): array {
if (!$date) {
$date = \current_time('Y-m-d');
}
// Générer une clé de cache unique
$cache_key = 'crvi_all_langues_dispos_' . $date;
// Essayer de récupérer depuis le cache
if ($use_cache) {
$cached = \get_transient($cache_key);
if ($cached !== false) {
return $cached;
}
}
// Récupérer toutes les langues utilisées dans les capacités actives
$capacites = self::getActiveCapacites([], false);
$langues_ids = [];
foreach ($capacites as $capacite) {
$capacite_langues = is_array($capacite->langue) ? $capacite->langue : [$capacite->langue];
$langues_ids = array_merge($langues_ids, $capacite_langues);
}
$langues_ids = array_unique($langues_ids);
$result = [];
foreach ($langues_ids as $langue_id) {
$langue_term = \get_term($langue_id, 'langue');
if ($langue_term && !\is_wp_error($langue_term)) {
// Utiliser le cache pour chaque langue
$dispos = self::getDisponibilitesByLangue($langue_id, $date, $use_cache);
$result[$langue_term->slug] = [
'id' => $langue_id,
'name' => $langue_term->name,
'slug' => $langue_term->slug,
'total' => $dispos['total'],
'used' => $dispos['total_used'],
'available' => $dispos['total_available'],
'by_periode' => [
'matin' => $dispos['by_periode']['matin_summary'] ?? ['total' => 0, 'used' => 0, 'available' => 0],
'apres_midi' => $dispos['by_periode']['apres_midi_summary'] ?? ['total' => 0, 'used' => 0, 'available' => 0],
'journee' => $dispos['by_periode']['journee_summary'] ?? ['total' => 0, 'used' => 0, 'available' => 0],
],
'details' => $dispos['by_periode'],
];
}
}
// Trier par nom de langue
uasort($result, function($a, $b) {
return strcmp($a['name'], $b['name']);
});
// Mettre en cache le résultat global (2 jours)
if ($use_cache) {
\set_transient($cache_key, $result, 2 * \DAY_IN_SECONDS);
}
return $result;
}
/**
* Invalide le cache pour une capacité donnée
* À appeler lors de la création/modification/suppression d'événements ou de capacités
* @param int|null $capacite_id ID de la capacité (optionnel, invalide tout si null)
*/
public static function invalidate_cache(?int $capacite_id = null): void {
global $wpdb;
if ($capacite_id) {
// Invalider le cache de la capacité spécifique
$capacite = self::load($capacite_id);
if ($capacite) {
// Invalider le cache pour toutes les langues de cette capacité
$langue_ids = is_array($capacite->langue) ? $capacite->langue : [$capacite->langue];
foreach ($langue_ids as $langue_id) {
// Récupérer toutes les clés de transient pour cette langue
// Format: crvi_dispos_langue_{langue_id}_{date}
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->options}
WHERE option_name LIKE %s
OR option_name LIKE %s",
'_transient_crvi_dispos_langue_' . $langue_id . '_%',
'_transient_timeout_crvi_dispos_langue_' . $langue_id . '_%'
)
);
}
// Invalider aussi le cache global de toutes les langues
$wpdb->query(
"DELETE FROM {$wpdb->options}
WHERE option_name LIKE '_transient_crvi_all_langues_dispos_%'
OR option_name LIKE '_transient_timeout_crvi_all_langues_dispos_%'"
);
}
// Invalider le cache général des capacités
\delete_transient('crvi_capacites_actives_' . md5(serialize([])));
} else {
// Invalider tous les caches de disponibilités
$wpdb->query(
"DELETE FROM {$wpdb->options}
WHERE option_name LIKE '_transient_crvi_dispos_langue_%'
OR option_name LIKE '_transient_timeout_crvi_dispos_langue_%'
OR option_name LIKE '_transient_crvi_all_langues_dispos_%'
OR option_name LIKE '_transient_timeout_crvi_all_langues_dispos_%'
OR option_name LIKE '_transient_crvi_count_used_%'
OR option_name LIKE '_transient_timeout_crvi_count_used_%'"
);
// Invalider le cache général des capacités
\delete_transient('crvi_capacites_actives_' . md5(serialize([])));
}
}
}

View File

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace ESI_CRVI_AGENDA\models;
class CRVI_Type_Intervention_Model extends Main_Model
{
/**
* Schéma des champs ACF : nom => type (ex : group, repeater, taxonomy, text...)
*/
public static $acf_schema = [
'nom' => 'text',
];
/**
* Charge un type d'intervention par ID (CPT)
*/
public static function load($id)
{
$post = get_post($id);
if (!$post || $post->post_type !== 'type_intervention') {
return null;
}
return $post;
}
/**
* Retourne la liste de tous les types d'intervention (CPT)
*/
public static function all($simple_list = false)
{
$posts = get_posts([
'post_type' => 'type_intervention',
'posts_per_page' => -1,
'post_status' => 'publish',
]);
if ($simple_list) {
$posts = array_map(function($post) {
return [
'id' => $post->ID,
'nom' => $post->post_title,
];
}, $posts);
}
return $posts;
}
/**
* Crée ou met à jour un type d'intervention par titre
* @param array $data (doit contenir au moins 'post_title')
* @return int ID du type d'intervention
*/
public static function create(array $data): int
{
if (empty($data['post_title'])) {
return 0;
}
$existing = get_posts([
'post_type' => 'type_intervention',
'title' => $data['post_title'],
'post_status' => 'publish',
'posts_per_page' => 1,
'fields' => 'ids',
]);
if (!empty($existing)) {
$post_id = (int)$existing[0];
$update_data = array_merge([
'ID' => $post_id,
'post_type' => 'type_intervention',
'post_status'=> 'publish',
], $data);
wp_update_post($update_data);
return $post_id;
}
$insert_data = array_merge([
'post_type' => 'type_intervention',
'post_status'=> 'publish',
], $data);
$post_id = wp_insert_post($insert_data);
return (int)$post_id;
}
}

48
app/views/Agenda_View.php Normal file
View File

@ -0,0 +1,48 @@
<?php
namespace ESI_CRVI_AGENDA\views;
use ESI_CRVI_AGENDA\models\CRVI_Local_Model;
use ESI_CRVI_AGENDA\models\CRVI_Intervenant_Model;
use ESI_CRVI_AGENDA\models\CRVI_Departement_Model;
use ESI_CRVI_AGENDA\models\CRVI_Traducteur_Model;
use ESI_CRVI_AGENDA\helpers\Api_Helper;
use ESI_CRVI_AGENDA\models\CRVI_Type_Intervention_Model;
class CRVI_Agenda_View {
public static function render_agenda_page() {
$locals = CRVI_Local_Model::get_locals([],true);
$intervenants = CRVI_Intervenant_Model::get_intervenants([],true);
$departements = CRVI_Departement_Model::all(true);
$types_intervention = CRVI_Type_Intervention_Model::all(true);
$traducteurs = CRVI_Traducteur_Model::get_traducteurs([],true);
$langues_beneficiaire = Api_Helper::get_languages(true);
$genres = Api_Helper::get_acf_field_options('field_685e466352755');
$genres = $genres['options'] ?? [];
$types_locaux = Api_Helper::get_acf_field_options('field_685bc6db12678');
$types_locaux = $types_locaux['options'] ?? [];
$jours_disponibles = Api_Helper::get_acf_field_options('field_685bdf6d66ef9');
$jours_disponibles = $jours_disponibles['options'] ?? [];
// Récupérer tous les bénéficiaires
$beneficiaire_model = new \ESI_CRVI_AGENDA\models\CRVI_Beneficiaire_Model();
$beneficiaires_objects = $beneficiaire_model->get_all_beneficiaires();
$beneficiaires = [];
foreach ($beneficiaires_objects as $beneficiaire) {
$beneficiaires[] = [
'id' => $beneficiaire->id,
'nom' => $beneficiaire->nom . ' ' . $beneficiaire->prenom,
];
}
$template = plugin_dir_path(__FILE__) . '../../templates/admin/agenda-page.php';
if (file_exists($template)) {
include $template;
} else {
echo '<p style="color:red">Template agenda-page.php introuvable.</p>';
}
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace ESI_CRVI_AGENDA\views;
class Beneficiaire_View {
/**
* La vue des bénéficiaires est gérée par le Custom Post Type WordPress.
*/
public function __construct() {}
public function init() {}
}

10
app/views/Event_View.php Normal file
View File

@ -0,0 +1,10 @@
<?php
namespace ESI_CRVI_AGENDA\views;
class Event_View {
/**
* La vue des événements est gérée par FullCalendar (JS côté client).
*/
public function __construct() {}
public function init() {}
}

View File

@ -0,0 +1,10 @@
<?php
namespace ESI_CRVI_AGENDA\views;
class Intervenant_View {
/**
* La vue des intervenants est gérée par le Custom Post Type WordPress.
*/
public function __construct() {}
public function init() {}
}

10
app/views/Local_View.php Normal file
View File

@ -0,0 +1,10 @@
<?php
namespace ESI_CRVI_AGENDA\views;
class Local_View {
/**
* La vue des locaux est gérée par le Custom Post Type WordPress.
*/
public function __construct() {}
public function init() {}
}

14
app/views/Main_View.php Normal file
View File

@ -0,0 +1,14 @@
<?php
namespace ESI_CRVI_AGENDA\views;
class CRVI_Main_View {
public static function render_hub_admin_page() {
$template = plugin_dir_path(__FILE__) . '../../templates/admin/admin_hub.php';
if (file_exists($template)) {
include $template;
} else {
echo '<p style="color:red">Template admin_hub.php introuvable.</p>';
}
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace ESI_CRVI_AGENDA\views;
class Traducteur_View {
/**
* La vue des traducteurs est gérée par le Custom Post Type WordPress.
*/
public function __construct() {}
public function init() {}
}

1
assets/.gitkeep Normal file
View File

@ -0,0 +1 @@

View File

@ -0,0 +1,27 @@
/* Améliorations UI du calendrier (admin) */
/* Scope strict au conteneur admin du calendrier pour éviter tout effet de bord */
/* Augmente légèrement la hauteur minimale des lignes du mois */
#agenda-calendar .fc .fc-daygrid-body {
--fc-daygrid-row-min-height: 5.5em;
}
/* Ajoute un peu d'air dans chaque case */
#agenda-calendar .fc .fc-daygrid-day-frame {
padding: 8px;
}
/* Chiffres des jours en gris et non soulignés (par défaut et au survol) */
#agenda-calendar a.fc-daygrid-day-number,
#agenda-calendar .fc-daygrid-day-number {
color: #6c757d;
text-decoration: none;
}
#agenda-calendar a.fc-daygrid-day-number:hover,
#agenda-calendar .fc-daygrid-day-number:hover {
text-decoration: none;
color: #6c757d;
}

View File

@ -0,0 +1,297 @@
/* Styles pour les événements CRVI Agenda */
/* Styles de base pour tous les événements */
.crvi-event {
border-radius: 4px !important;
border-left: 4px solid !important;
font-weight: 500;
font-size: 0.85em;
padding: 2px 4px !important;
margin: 1px 0 !important;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
/* Pastille de statut (cercle coloré) */
.crvi-event::before {
content: '';
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 4px;
background-color: var(--statut-color, #28a745);
border: 1px solid rgba(255,255,255,0.8);
}
/* Styles par type de RDV (fond) */
.crvi-type-individuel {
background-color: #f8f9fa !important;
border-color: #dee2e6 !important;
}
.crvi-type-groupe {
background-color: #e9ecef !important;
border-color: #ced4da !important;
}
/* Styles par statut (pastille) */
.crvi-statut-prevu::before {
background-color: #28a745 !important;
}
.crvi-statut-annule::before {
background-color: #dc3545 !important;
}
.crvi-statut-non_tenu::before {
background-color: #ffc107 !important;
}
.crvi-statut-cloture::before {
background-color: #6c757d !important;
}
.crvi-statut-absence::before {
background-color: #fd7e14 !important;
}
/* Styles par département (pour les icônes personnalisées) */
.crvi-departement-soutien-social {
/* Styles spécifiques pour le département Soutien Social */
}
.crvi-departement-accompagnement {
/* Styles spécifiques pour le département Accompagnement */
}
.crvi-departement-formation {
/* Styles spécifiques pour le département Formation */
}
.crvi-departement-consultation {
/* Styles spécifiques pour le département Consultation */
}
.crvi-departement-urgence {
/* Styles spécifiques pour le département Urgence */
}
/* Hover effects */
.crvi-event:hover {
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
transition: all 0.2s ease;
}
/* Styles pour les événements en mode liste */
.fc-list-event.crvi-event {
padding: 8px 12px !important;
margin: 2px 0 !important;
}
.fc-list-event.crvi-event::before {
width: 10px;
height: 10px;
margin-right: 8px;
}
/* Styles pour les événements en mode jour/semaine */
.fc-daygrid-event.crvi-event,
.fc-timegrid-event.crvi-event {
border-left-width: 6px !important;
padding: 3px 6px !important;
}
.fc-daygrid-event.crvi-event::before,
.fc-timegrid-event.crvi-event::before {
width: 6px;
height: 6px;
margin-right: 3px;
}
/* Responsive */
@media (max-width: 768px) {
.crvi-event {
font-size: 0.8em;
padding: 1px 3px !important;
}
.crvi-event::before {
width: 6px;
height: 6px;
margin-right: 2px;
}
}
/* Animation pour les nouveaux événements */
@keyframes crviEventAppear {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
.crvi-event-new {
animation: crviEventAppear 0.3s ease-out;
}
/* Styles pour les événements sélectionnés */
.crvi-event.fc-event-selected {
box-shadow: 0 0 0 2px rgba(0,123,255,0.5);
transform: scale(1.02);
}
/* Styles pour les événements en cours */
.crvi-event.crvi-en-cours {
border-left-color: #007bff !important;
background-color: rgba(0,123,255,0.1) !important;
}
/* Styles pour les événements en retard */
.crvi-event.crvi-en-retard {
border-left-color: #dc3545 !important;
background-color: rgba(220,53,69,0.1) !important;
}
/* Styles pour les événements terminés */
.crvi-event.crvi-termine {
opacity: 0.7;
background-color: rgba(108,117,125,0.1) !important;
}
/* Icônes de département dans le titre */
.crvi-departement-icon {
font-size: 1.1em;
margin-right: 4px;
vertical-align: middle;
}
/* Tooltip personnalisé pour les événements */
.crvi-event-tooltip {
position: absolute;
background: rgba(0,0,0,0.9);
color: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 0.85em;
z-index: 1000;
max-width: 300px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
pointer-events: none;
}
.crvi-event-tooltip::before {
content: '';
position: absolute;
top: -4px;
left: 10px;
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-bottom: 4px solid rgba(0,0,0,0.9);
}
/* Styles pour les filtres visuels */
.crvi-filter-legend {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 10px 0;
padding: 10px;
background: #f8f9fa;
border-radius: 4px;
border: 1px solid #dee2e6;
}
.crvi-filter-item {
display: flex;
align-items: center;
font-size: 0.9em;
padding: 4px 8px;
border-radius: 3px;
background: white;
border: 1px solid #dee2e6;
}
.crvi-filter-color {
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 6px;
border: 1px solid rgba(0,0,0,0.2);
}
/* Styles pour les statistiques visuelles */
.crvi-stats-bar {
height: 4px;
background: #e9ecef;
border-radius: 2px;
overflow: hidden;
margin: 2px 0;
}
.crvi-stats-fill {
height: 100%;
transition: width 0.3s ease;
}
.crvi-stats-fill.prevu { background-color: #28a745; }
.crvi-stats-fill.annule { background-color: #dc3545; }
.crvi-stats-fill.non_tenu { background-color: #ffc107; }
.crvi-stats-fill.cloture { background-color: #6c757d; }
.crvi-stats-fill.absence { background-color: #fd7e14; }
.fade.show {
opacity: 1;
}
.modal-backdrop.fade.show {
opacity: 0.5;
}
.popover.event-popover.fade,
.popover.event-popover.fade.show {
opacity: 1 !important;
}
/* Effets de survol pour les boutons de statut */
#eventStatusButtons .btn {
transition: all 0.3s ease;
transform: scale(1);
}
#eventStatusButtons .btn:hover {
transform: scale(1.05);
padding-left: 1rem;
padding-right: 1rem;
}
/* Styles pour les événements passés (non éditables) */
.fc-event.event-past {
opacity: 0.6;
cursor: not-allowed !important;
}
.fc-event.event-past .fc-event-title {
text-decoration: line-through;
}
.fc-event.event-past:hover {
opacity: 0.6;
transform: none;
box-shadow: none;
}
/* Désactiver le curseur de déplacement pour les événements passés */
.fc-event.event-past .fc-event-resizer {
display: none !important;
}
.fc-event.event-past .fc-event-main {
cursor: not-allowed !important;
}

145
assets/css/crvi-agenda.css Normal file
View File

@ -0,0 +1,145 @@
/* Styles pour le popover des événements */
.event-popover {
max-width: 300px;
font-size: 0.875rem;
}
.event-popover .popover-header {
background-color: #f8f9fa;
border-bottom: 1px solid #dee2e6;
font-weight: 600;
padding: 0.5rem 0.75rem;
}
.event-popover .popover-body {
padding: 0.75rem;
}
.event-popover h6 {
color: #495057;
font-weight: 600;
margin-bottom: 0.5rem;
}
.event-popover .mb-1 {
margin-bottom: 0.25rem;
}
.event-popover .mb-2 {
margin-bottom: 0.5rem;
}
.event-popover .mt-2 {
margin-top: 0.5rem;
}
.event-popover strong {
color: #495057;
font-weight: 600;
}
.event-popover .text-muted {
color: #6c757d !important;
font-size: 0.75rem;
}
/* Animation du popover */
.popover {
animation: fadeIn 0.2s ease-in-out;
}
.fade.show {
opacity: 1;
}
/* Forcer l'opacité du popover d'événement en front,
même si une règle .fade externe met opacity:0 */
.popover.event-popover.fade,
.popover.event-popover.fade.show {
opacity: 1 !important;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Styles pour le lien "plus" des événements */
.fc-daygrid-more-link {
background-color: #007bff !important;
color: white !important;
border-radius: 3px !important;
padding: 2px 6px !important;
font-size: 0.75rem !important;
font-weight: 500 !important;
text-decoration: none !important;
display: inline-block !important;
margin-top: 2px !important;
transition: background-color 0.2s ease !important;
}
.fc-daygrid-more-link:hover {
background-color: #0056b3 !important;
color: white !important;
text-decoration: none !important;
}
/* Style pour le popover des événements cachés */
.fc-more-popover {
max-width: 400px !important;
border-radius: 8px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
}
.fc-more-popover .fc-popover-header {
background-color: #f8f9fa !important;
border-bottom: 1px solid #dee2e6 !important;
padding: 0.75rem 1rem !important;
font-weight: 600 !important;
color: #495057 !important;
}
.fc-more-popover .fc-popover-body {
padding: 0.75rem 1rem !important;
max-height: 300px !important;
overflow-y: auto !important;
}
.fc-more-popover .fc-event {
margin-bottom: 0.5rem !important;
padding: 0.5rem !important;
border-radius: 4px !important;
cursor: pointer !important;
transition: background-color 0.2s ease !important;
}
.fc-more-popover .fc-event:hover {
opacity: 0.8 !important;
}
.fc-more-popover .fc-event:last-child {
margin-bottom: 0 !important;
}
/* Responsive pour les petits écrans */
@media (max-width: 768px) {
.event-popover {
max-width: 250px;
font-size: 0.8rem;
}
.fc-daygrid-more-link {
font-size: 0.7rem !important;
padding: 1px 4px !important;
}
.fc-more-popover {
max-width: 300px !important;
}
}

391
assets/css/crvi.css Normal file
View File

@ -0,0 +1,391 @@
.crvi-hub-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: repeat(3, 1fr);
gap: 2rem;
margin-top: 2rem;
}
.crvi-hub-block {
background: #fff;
border: 1px solid #e5e5e5;
border-radius: 6px;
padding: 2rem 1.5rem;
box-shadow: 0 1px 2px rgba(0,0,0,0.03);
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
}
.crvi-hub-block h2 {
margin-top: 0;
font-size: 1.1em;
margin-bottom: 1em;
}
.crvi-hub-block .button {
width: 100%;
margin-bottom: 0.5em;
}
.crvi-hub-block form {
width: 100%;
text-align: center;
}
/* Styles pour l'agenda */
.agenda-container {
margin-top: 2rem;
}
.filters-container {
background: #fff;
border: 1px solid #e5e5e5;
border-radius: 6px;
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: 0 1px 2px rgba(0,0,0,0.03);
}
.filters {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
align-items: end;
}
.filter {
display: flex;
flex-direction: column;
}
.filter label {
font-weight: 500;
margin-bottom: 0.5rem;
color: #333;
}
.filter input,
.filter select {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
}
.filter button {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.5rem;
}
.filter button[type="submit"] {
background-color: #0073aa;
color: white;
}
.filter button[type="submit"]:hover {
background-color: #005a87;
}
/* Boutons de filtres */
.filter button.btn-primary {
background-color: #0073aa;
color: white;
}
.filter button.btn-primary:hover {
background-color: #005a87;
transform: translateY(-1px);
}
.filter button.btn-secondary {
background-color: #6c757d;
color: white;
}
.filter button.btn-secondary:hover {
background-color: #545b62;
transform: translateY(-1px);
}
/* Indicateur de chargement des filtres */
#loading-indicator {
display: none;
text-align: center;
padding: 20px;
background: rgba(255, 255, 255, 0.9);
border-radius: 4px;
margin: 10px 0;
}
#loading-indicator p {
margin: 0;
color: #666;
font-style: italic;
}
/* Animation de chargement */
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
#loading-indicator p {
animation: pulse 1.5s infinite;
}
/* Bouton "Ajouter un RDV" */
#addEventBtn {
background-color: #28a745 !important;
color: white !important;
border: none !important;
padding: 0.5rem 1rem !important;
border-radius: 4px !important;
font-size: 0.9rem !important;
cursor: pointer !important;
transition: background-color 0.2s !important;
display: flex !important;
align-items: center !important;
gap: 0.5rem !important;
}
#addEventBtn:hover {
background-color: #218838 !important;
}
#addEventBtn i {
font-size: 0.8rem;
}
/* Responsive */
@media (max-width: 768px) {
.filters {
grid-template-columns: 1fr;
}
.filter button {
width: 100%;
}
}
/* Styles pour le bloc de vue avec grille Bootstrap en 2 colonnes */
#eventViewBlock .event-grid {
margin: 0;
}
#eventViewBlock .event-grid > .row {
margin-bottom: 1rem;
}
#eventViewBlock .event-grid .row .row {
margin-bottom: 0.5rem;
align-items: center;
min-height: 2.5rem;
border-bottom: 1px solid #f8f9fa;
padding: 0.5rem 0;
}
#eventViewBlock .event-grid .row .row:last-child {
border-bottom: none;
}
#eventViewBlock .event-label {
font-weight: 600;
color: #495057;
display: flex;
align-items: center;
margin: 0;
font-size: 0.9rem;
}
/* Icônes :before commentées - on utilise maintenant les balises <i> */
/*
#eventViewBlock .event-label::before {
margin-right: 0.5rem;
font-size: 1rem;
opacity: 0.8;
width: 16px;
text-align: center;
font-family: "Font Awesome 5 Free", "Font Awesome 5 Pro", "Font Awesome 6 Free", "Font Awesome 6 Pro";
}
#eventViewBlock .event-label.date::before { content: "\f073"; font-weight: 900; }
#eventViewBlock .event-label.time::before { content: "\f017"; font-weight: 900; }
#eventViewBlock .event-label.type::before { content: "\f007"; font-weight: 900; }
#eventViewBlock .event-label.language::before { content: "\f1ab"; font-weight: 900; }
#eventViewBlock .event-label.beneficiary::before { content: "\f0c0"; font-weight: 900; }
#eventViewBlock .event-label.intervenant::before { content: "\f0f0"; font-weight: 900; }
#eventViewBlock .event-label.translator::before { content: "\f1e0"; font-weight: 900; }
#eventViewBlock .event-label.location::before { content: "\f015"; font-weight: 900; }
#eventViewBlock .event-label.participants::before { content: "\f0c0"; font-weight: 900; }
#eventViewBlock .event-label.men::before { content: "\f183"; font-weight: 900; }
#eventViewBlock .event-label.women::before { content: "\f182"; font-weight: 900; }
#eventViewBlock .event-label.comment::before { content: "\f075"; font-weight: 900; }
#eventViewBlock .event-label.has-dept-icon::before {
content: var(--dept-icon);
font-weight: normal;
}
*/
#eventViewBlock .event-value {
font-size: 0.9rem;
margin: 0;
color: #212529;
}
/* Pastille de statut dans le header du modal */
#eventModal .modal-header {
position: relative;
}
#eventModal .statut-badge {
position: absolute;
top: 10px;
right: 50px;
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid white;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
z-index: 10;
}
.event-status {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
color: #fff;
background-color: #6c757d;
}
/* Styles pour les liens "+ Ajouter" */
.crvi-add-link {
font-size: 0.8rem;
color: #6b7c93;
text-decoration: none;
font-weight: 400;
transition: color 0.2s ease;
float: right;
}
.crvi-add-link:hover {
color: #4a5a6b;
text-decoration: underline;
}
/* Augmentation de la taille des icônes FontAwesome de 25% pour l'ensemble de l'agenda */
.agenda-container .fas,
.agenda-container .far,
.agenda-container .fab,
.agenda-container .fal,
#eventModal .fas,
#eventModal .far,
#eventModal .fab,
#eventModal .fal,
.crvi-add-link .fas,
.crvi-add-link .far,
.crvi-add-link .fab,
.crvi-add-link .fal {
font-size: 1.25em !important;
}
.plus-label {
display: flex;
justify-content: space-between;
}
.plus-label a {
color: #0097e6;
font-size: 0.9rem;
}
.plus-label a:hover {
color: #0073aa;
}
.plus-label a i {
font-size: 0.8rem;
}
.modal .select2-container {
z-index: 2000!important;
width: 100%
}
.modal .select2-dropdown,.modal .select2-results,.modal.show .select2-container--open .select2-dropdown,.entity-create-modal .select2-container,.entity-create-modal .select2-dropdown,.entity-create-modal .select2-results {
z-index: 2000!important;
}
.entity-create-modal .select2-container,.entity-create-modal .select2-dropdown,.entity-create-modal .select2-results {
width: 100% !important;
}
body>.select2-container {
z-index: 2000!important
}
body>.select2-dropdown {
z-index: 2000!important
}
.modal.show~.select2-container,.modal.show~.select2-dropdown {
z-index: 2000!important
}
/* Contexte front uniquement (shortcode agenda intervenant) :
Forcer le Select2 à passer au-dessus du modal Avada (z-index modal ~ 99999) */
.crvi-intervenant-agenda #eventModal .select2-container,
.crvi-intervenant-agenda #eventModal .select2-dropdown,
.crvi-intervenant-agenda #eventModal .select2-results {
z-index: 100002 !important;
}
/* Contexte front: forcer affichage et largeur des conteneurs Select2 */
.crvi-intervenant-agenda .select2-container {
display: block !important;
width: 100% !important;
}
/* Contexte front: couleur de police foncée pour la grille du modal */
.crvi-intervenant-agenda #eventModal .event-grid {
color: #333 !important;
}
.crvi-intervenant-agenda #eventModal .event-grid .event-label,
.crvi-intervenant-agenda #eventModal .event-grid .event-value {
color: #333 !important;
}
/* Correction du z-index de TOUTES les modales et backdrop
SOLUTION : Combinaison JavaScript + CSS
- JavaScript force toutes les modales et backdrop à être enfants directs du <body>
- JavaScript réordonne : backdrop PUIS modal dans le DOM
- CSS force des z-index élevés pour passer au-dessus d'Avada
Avec cette approche, les stacking contexts sont correctement gérés pour :
- eventModal (modal principale)
- createBeneficiaireModal, createIntervenantModal, createTraducteurModal, createLocalModal
- declarationIncidentModal
- beneficiaireHistoriqueModal
- rdvModal
*/
/* Backdrop : sous toutes les modales, au-dessus d'Avada */
body.modal-open .modal-backdrop {
z-index: 99999 !important;
}
/* TOUTES les modales : au-dessus du backdrop et d'Avada */
.modal {
z-index: 100000 !important;
position: fixed !important;
}

11
assets/css/crvi_main.css Normal file
View File

@ -0,0 +1,11 @@
/* CSS principal du plugin CRVI Agenda */
/* Les librairies CSS sont importées depuis crvi_libraries.css */
/* CSS personnalisé du plugin */
@import './crvi.css';
@import './agenda-events.css';
/* Overrides admin calendrier (sera minifié avec le reste via Vite) */
@import './admin-calendar-overrides.css';

View File

@ -0,0 +1,76 @@
/**
* Styles pour la page de profil intervenant
*/
.crvi-intervenant-profile {
min-height: 60vh;
}
.form-control[readonly] {
background-color: #e9ecef;
cursor: not-allowed;
}
.langues-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.badge-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.badge-container .badge {
font-size: 0.875rem;
padding: 0.5rem 0.75rem;
}
.indisponibilites-list {
list-style: none;
padding: 0;
}
.indisponibilites-list li {
padding: 0.75rem;
margin-bottom: 0.5rem;
background-color: #f8f9fa;
border-left: 4px solid #ffc107;
border-radius: 4px;
}
.indisponibilite-item {
padding: 0.75rem;
margin-bottom: 0.5rem;
background-color: #f8f9fa;
border-left: 4px solid #ffc107;
border-radius: 4px;
display: flex;
justify-content: space-between;
align-items: start;
}
.indisponibilite-item .btn-remove {
color: #dc3545;
border: none;
background: none;
cursor: pointer;
padding: 0.25rem 0.5rem;
}
.indisponibilite-item .btn-remove:hover {
color: #bb2d3b;
}
.input-group .btn {
border-left: 0;
}
@media (max-width: 768px) {
.col-lg-8 {
padding: 0 1rem;
}
}

View File

@ -0,0 +1,35 @@
/* Styles pour l'autocomplétion des présences dans la modal de validation des présences */
.presence-nom-input {
position: relative;
}
.autocomplete-suggestions {
position: absolute;
z-index: 1050;
background: white;
border: 1px solid #ccc;
border-radius: 4px;
max-height: 200px;
overflow-y: auto;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
margin-top: 2px;
width: 100%;
min-width: 200px;
}
.autocomplete-item {
padding: 8px 12px;
cursor: pointer;
border-bottom: 1px solid #eee;
}
.autocomplete-item:hover,
.autocomplete-item.selected {
background-color: #f0f0f0;
}
.autocomplete-item:last-child {
border-bottom: none;
}

View File

@ -0,0 +1,149 @@
/**
* Styles pour l'interface admin des capacités de traduction
*/
/* Metabox des règles dépendantes */
.traduction-langue-child-rules {
margin-bottom: 15px;
}
.traduction-langue-child-rules h4 {
margin: 0 0 10px 0;
font-size: 13px;
color: #1d2327;
}
.traduction-langue-child-rules ul {
margin: 0;
padding: 0;
list-style: none;
}
.traduction-langue-child-rules li {
margin: 0 0 8px 0;
padding: 8px;
background: #f6f7f7;
border-left: 3px solid #2271b1;
font-size: 13px;
}
.traduction-langue-child-rules li a {
text-decoration: none;
font-weight: 600;
}
.traduction-langue-child-rules li a:hover {
text-decoration: underline;
}
/* Bouton d'ajout de règle */
.traduction-langue-add-sub-rule {
margin-top: 15px;
}
.traduction-langue-add-sub-rule .spinner {
visibility: hidden;
margin: 0 0 0 10px;
}
.traduction-langue-add-sub-rule .spinner.is-active {
visibility: visible;
}
/* Modal */
#sub-rule-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 160000;
}
.sub-rule-modal-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
}
.sub-rule-modal-content {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #fff;
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3);
max-width: 500px;
width: 90%;
padding: 20px;
border-radius: 3px;
}
.sub-rule-modal-content h2 {
margin: 0 0 15px 0;
font-size: 18px;
line-height: 1.4;
color: #1d2327;
}
.sub-rule-modal-content p {
margin: 0 0 15px 0;
font-size: 13px;
line-height: 1.6;
color: #50575e;
}
.sub-rule-modal-content ul {
margin: 0 0 15px 20px;
padding: 0;
list-style: disc;
}
.sub-rule-modal-content li {
margin: 0 0 5px 0;
font-size: 13px;
line-height: 1.6;
color: #50575e;
}
.sub-rule-modal-actions {
margin-top: 20px;
text-align: right;
border-top: 1px solid #dcdcde;
padding-top: 15px;
}
.sub-rule-modal-actions .button {
margin-left: 8px;
}
/* Désactiver le scroll sur le body quand la modal est ouverte */
body.modal-open {
overflow: hidden;
}
/* Responsive */
@media screen and (max-width: 782px) {
.sub-rule-modal-content {
max-width: 90%;
padding: 15px;
}
.sub-rule-modal-content h2 {
font-size: 16px;
}
.sub-rule-modal-actions {
text-align: center;
}
.sub-rule-modal-actions .button {
display: block;
width: 100%;
margin: 0 0 8px 0;
text-align: center;
}
}

View File

@ -0,0 +1,282 @@
/**
* Styles pour la page admin de liste des capacités de traduction
* Interface moderne avec vue Parent Enfant
*/
.traduction-langue-list-wrap {
padding: 20px 20px 40px;
max-width: 1400px;
}
.traduction-langue-list-wrap h1 {
display: flex;
align-items: center;
gap: 10px;
font-size: 28px;
margin-bottom: 20px;
}
.traduction-langue-list-wrap h1 .dashicons {
font-size: 32px;
width: 32px;
height: 32px;
}
/* Grid des cartes */
.traduction-langue-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(450px, 1fr));
gap: 24px;
margin-top: 30px;
}
/* Carte de capacité */
.capacite-card {
background: #fff;
border: 1px solid #dcdcde;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
transition: all 0.2s ease;
}
.capacite-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border-color: #2271b1;
}
.capacite-card.inactive {
opacity: 0.7;
background: #f6f7f7;
}
.capacite-card.full {
border-left: 4px solid #d63638;
}
/* Header de la carte */
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 20px;
border-bottom: 1px solid #f0f0f1;
}
.card-title {
flex: 1;
}
.card-title h2 {
font-size: 18px;
font-weight: 600;
margin: 0 0 8px 0;
color: #1d2327;
}
.card-badges {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 4px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.badge-inactive {
background: #f0f0f1;
color: #50575e;
}
.badge-inactive-small {
padding: 2px 6px;
font-size: 11px;
}
.badge-warning {
background: #fcf3cf;
color: #856404;
border: 1px solid #f9e79f;
}
.card-actions {
display: flex;
gap: 8px;
align-items: flex-start;
}
.card-actions .button {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.card-actions .button .dashicons {
font-size: 18px;
width: 18px;
height: 18px;
}
/* Body de la carte */
.card-body {
padding: 20px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.info-item-full {
grid-column: 1 / -1;
}
.info-label {
font-size: 12px;
font-weight: 500;
color: #646970;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.info-value {
font-size: 15px;
font-weight: 600;
color: #1d2327;
}
.info-value .na {
color: #a7aaad;
font-style: italic;
}
/* Badges de langues multiples */
.langue-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.langue-badge {
display: inline-flex;
align-items: center;
padding: 4px 10px;
background: #2271b1;
color: #fff;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
}
.langue-badge:first-child {
background: #135e96;
}
.langue-badge:nth-child(2) {
background: #2271b1;
}
.langue-badge:nth-child(3) {
background: #3582c4;
}
/* Badge d'exception */
.exception-badge {
display: inline-flex;
align-items: center;
padding: 6px 12px;
background: #fef5e7;
color: #856404;
border: 1px solid #f9e79f;
border-radius: 4px;
font-size: 13px;
font-weight: 600;
}
/* Barre d'utilisation */
.usage-bar {
margin-top: 16px;
}
.usage-label {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 13px;
}
.usage-label > span:first-child {
font-weight: 600;
color: #1d2327;
}
.usage-stats {
color: #50575e;
font-weight: 500;
}
.usage-progress {
height: 8px;
background: #f0f0f1;
border-radius: 4px;
overflow: hidden;
}
.usage-progress-bar {
height: 100%;
background: #2271b1;
transition: width 0.3s ease;
border-radius: 4px;
}
.usage-progress-bar.warning {
background: #f0b849;
}
.usage-progress-bar.full {
background: #d63638;
}
/* Responsive */
@media screen and (max-width: 1200px) {
.traduction-langue-grid {
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
}
}
@media screen and (max-width: 782px) {
.traduction-langue-grid {
grid-template-columns: 1fr;
}
.card-header {
flex-direction: column;
gap: 16px;
}
.card-actions {
width: 100%;
justify-content: flex-start;
}
.info-grid {
grid-template-columns: 1fr;
}
}

12
assets/js/agenda.js Normal file
View File

@ -0,0 +1,12 @@
// agenda.js
// Fichier principal pour la gestion JS de l'agenda admin
export default function Agenda() {
console.log('agenda.js chargé');
}
// À compléter : logique de récupération dynamique des options de filtres, gestion des événements, etc.
document.addEventListener('DOMContentLoaded', function() {
// Exemple : point d'entrée pour l'initialisation future
console.log('agenda.js chargé');
});

View File

@ -0,0 +1,15 @@
// Librairies JS externes
// Import des CSS via imports directs (seront extraits automatiquement par Vite)
import 'bootstrap/dist/css/bootstrap.min.css';
import 'toastr/build/toastr.min.css';
import 'select2/dist/css/select2.css';
import * as bootstrap from 'bootstrap';
window.bootstrap = bootstrap; // Pour activer les composants JS de Bootstrap
import toastr from 'toastr';
window.toastr = toastr; // Exposer toastr globalement
// FullCalendar CSS - chargé depuis CDN
// Select2 JS est chargé depuis CDN dans Plugin.php

172
assets/js/crvi_main.js Normal file
View File

@ -0,0 +1,172 @@
// Les librairies JS sont importées depuis crvi_libraries.js
// Import de vos modules locaux
/* import './extra/agenda-loading-feedback.js'; */
import { initializeCalendar } from './modules/agenda-fullcalendar.js';
import { initializeIntervenantCalendar, initializeIntervenantAgendaTabs } from './modules/agenda-intervenant-calendar.js';
import { initializeIntervenantHub } from './modules/agenda-intervenant-hub.js';
import { initializeProfile } from './modules/agenda-intervenant-profile.js';
import { initializePermanences } from './modules/agenda-intervenant-permanences.js';
import { initializeAdminPermanences } from './modules/agenda-admin-permanences.js';
import { initStatsTable } from './modules/agenda-stats-table.js';
/**
* FIX GLOBAL : Corriger le stacking context de TOUTES les modales Bootstrap
* pour éviter que le backdrop passe devant les modales (problème avec Avada)
*/
function fixAllModalsStackingContext() {
// Attendre que Bootstrap soit chargé
if (typeof window.bootstrap === 'undefined') {
setTimeout(fixAllModalsStackingContext, 100);
return;
}
// Récupérer TOUTES les modales Bootstrap
const allModals = document.querySelectorAll('.modal');
allModals.forEach(modal => {
// 1. Forcer la modal à être enfant direct du body (échappe au stacking context Avada)
if (modal.parentNode !== document.body) {
document.body.appendChild(modal);
}
// 2. Fixer l'ordre backdrop/modal à chaque ouverture
modal.addEventListener('shown.bs.modal', function fixBackdropOrder() {
const backdrop = document.querySelector('.modal-backdrop');
if (backdrop && modal && document.body && document.body.children) {
try {
// Vérifier l'ordre dans le DOM
const bodyChildren = Array.from(document.body.children);
const modalIndex = bodyChildren.indexOf(modal);
const backdropIndex = bodyChildren.indexOf(backdrop);
// Si backdrop vient après modal, réordonner (backdrop AVANT modal)
if (backdropIndex > modalIndex && modalIndex !== -1 && backdropIndex !== -1) {
document.body.insertBefore(backdrop, modal);
}
} catch (error) {
console.warn('Erreur lors de la réorganisation du backdrop:', error);
}
}
});
});
console.log(`✅ Fix stacking context appliqué à ${allModals.length} modal(es)`);
}
// Initialisation au chargement de la page
document.addEventListener('DOMContentLoaded', function() {
console.log('DOM chargé, initialisation des modules...');
console.log('CRVI crviACFData', crviACFData);
/* configureToastr(); */
// ✨ FIX : Appliquer le fix stacking context à TOUTES les modales
fixAllModalsStackingContext();
// Détecter le contexte : intervenant ou admin
const isIntervenantContext = detectIntervenantContext();
if (isIntervenantContext) {
console.log('🔄 Contexte détecté : Espace Intervenant');
// Initialiser les modules selon la page
const hubEl = document.getElementById('intervenant-hub-container');
if (hubEl) {
console.log('📋 Initialisation du Hub intervenant...');
initializeIntervenantHub();
}
const agendaEl = document.getElementById('intervenant-agenda-container');
if (agendaEl) {
console.log('📅 Initialisation de l\'agenda intervenant...');
// Vérifier si on a des onglets (agenda + collègues) ou juste l'agenda simple
const agendaTabs = document.getElementById('agendaTabs');
if (agendaTabs) {
console.log('📑 Onglets détectés, initialisation avec basculement...');
initializeIntervenantAgendaTabs();
} else {
console.log('📅 Mode simple, initialisation agenda seul...');
initializeIntervenantCalendar();
}
}
const profileEl = document.getElementById('intervenant-profile-container');
if (profileEl) {
console.log('👤 Initialisation du profil intervenant...');
initializeProfile();
}
const permanencesEl = document.getElementById('intervenant-permanences-container');
if (permanencesEl) {
console.log('📝 Initialisation du formulaire permanences...');
initializePermanences();
}
} else {
// Contexte admin - vérifier quelle page on est
console.log('🔄 Contexte détecté : Admin');
// Vérifier si on est sur la page stats (priorité)
const statsTableEl = document.getElementById('stats-events-table');
const statsFiltersEl = document.querySelector('.stats-filters-container');
if (statsTableEl || statsFiltersEl) {
console.log('📊 Initialisation du tableau de stats...');
initStatsTable();
} else {
// Sinon, vérifier si on est sur la page agenda avec calendrier
const calendarEl = document.getElementById('agenda-calendar');
if (calendarEl) {
console.log('📅 Initialisation du calendrier admin...');
const calendar = initializeCalendar();
if (calendar) {
console.log('✅ Calendrier initialisé avec succès');
} else {
console.warn('⚠️ Échec de l\'initialisation du calendrier');
}
const modal = document.getElementById('eventModal');
if (modal) {
// Vérifier que bootstrap est disponible
if (typeof window.bootstrap !== 'undefined' && window.bootstrap.Modal) {
const bsModal = new window.bootstrap.Modal(modal);
} else {
console.warn('Bootstrap n\'est pas encore chargé, modal ne sera pas initialisé');
}
} else {
console.warn('Modal eventModal non trouvé (normal si pas sur page agenda admin)');
}
} else {
console.log(' Élément agenda-calendar non trouvé (normal si pas sur page agenda)');
}
}
// Vérifier si on est sur la page admin permanences
const adminPermanencesEl = document.getElementById('admin-permanences-container');
if (adminPermanencesEl) {
console.log('📝 Initialisation du formulaire permanences admin...');
initializeAdminPermanences();
}
}
// Initialiser Select2 sur les <select> (commun à tous les contextes)
jQuery('.select2').select2();
});
/**
* Détecte si on est dans un contexte intervenant
* @returns {boolean}
*/
function detectIntervenantContext() {
// Vérifier la présence des conteneurs intervenant
const intervenantContainers = [
'intervenant-hub-container',
'intervenant-agenda-container',
'intervenant-profile-container',
'intervenant-permanences-container'
];
return intervenantContainers.some(id => document.getElementById(id) !== null);
}

View File

@ -0,0 +1,230 @@
/* Feedback de chargement pour FullCalendar et spinner sur le bouton Enregistrer du modal */
(function () {
function setDisabled(containerSelector, disabled) {
try {
var container = document.querySelector(containerSelector);
if (!container) return;
var els = container.querySelectorAll('button, input, select, textarea');
els.forEach(function (el) {
el.disabled = !!disabled;
});
} catch (e) {}
}
function showIndicator(id, show) {
try {
var el = document.getElementById(id);
if (el) {
el.style.display = show ? 'block' : 'none';
}
} catch (e) {}
}
function attachLoading(calendar, indicatorId, filtersSelector) {
try {
if (!calendar || typeof calendar.setOption !== 'function') return;
calendar.setOption('loading', function (isLoading) {
showIndicator(indicatorId, isLoading);
// Ne pas désactiver les filtres pendant le chargement, uniquement overlay sur le calendrier
try {
var calEl = document.getElementById('agenda-calendar') || (calendar.el || null);
if (calEl && window.CRVI_OVERLAY) {
if (isLoading) {
window.CRVI_OVERLAY.show(calEl);
} else {
window.CRVI_OVERLAY.hide(calEl);
}
}
} catch (e) {}
});
} catch (e) {}
}
// Gestionnaire de chargement global, pour couvrir les appels AJAX génériques (création, update, delete, etc.)
// Usage: window.CRVI_LOADING.start(); ... finally window.CRVI_LOADING.end();
(function initGlobalLoading() {
var counter = 0;
var defaultIndicatorId = 'loading-indicator';
// Ne pas désactiver automatiquement les zones de filtres
var defaultDisableSelectors = [];
function updateUi() {
var isLoading = counter > 0;
try {
showIndicator(defaultIndicatorId, isLoading);
// Désactiver quelques containers communs si présents
defaultDisableSelectors.forEach(function (sel) {
setDisabled(sel, isLoading);
});
} catch (e) {}
}
if (!window.CRVI_LOADING) {
window.CRVI_LOADING = {
start: function () {
counter++;
updateUi();
},
end: function () {
if (counter > 0) counter--;
updateUi();
},
// Permet d'ajuster la cible si besoin ailleurs
configure: function (opts) {
if (opts && typeof opts.indicatorId === 'string') {
defaultIndicatorId = opts.indicatorId;
}
if (opts && Array.isArray(opts.disableSelectors)) {
defaultDisableSelectors = opts.disableSelectors.slice();
}
updateUi();
},
// Pour diagnostics
_getCount: function () {
return counter;
}
};
}
})();
// Overlay ciblé par élément: window.CRVI_OVERLAY.show(elementOrSelector), .hide(...)
(function initElementOverlay() {
function getTarget(elOrSelector) {
if (!elOrSelector) return null;
if (typeof elOrSelector === 'string') return document.querySelector(elOrSelector);
return elOrSelector.nodeType === 1 ? elOrSelector : null;
}
function ensurePositioned(target) {
var style = window.getComputedStyle(target);
if (style.position === 'static' || !style.position) {
target.style.position = 'relative';
}
}
function createOverlay(target) {
var overlay = document.createElement('div');
overlay.className = 'crvi-overlay';
overlay.style.position = 'absolute';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.right = '0';
overlay.style.bottom = '0';
overlay.style.background = 'rgba(255,255,255,0.6)';
overlay.style.display = 'flex';
overlay.style.alignItems = 'center';
overlay.style.justifyContent = 'center';
overlay.style.zIndex = '20';
var spinner = document.createElement('div');
spinner.className = 'spinner-border text-primary';
spinner.setAttribute('role', 'status');
spinner.setAttribute('aria-hidden', 'true');
overlay.appendChild(spinner);
return overlay;
}
function getCount(target) {
var c = parseInt(target.getAttribute('data-overlay-count') || '0', 10);
return isNaN(c) ? 0 : c;
}
function setCount(target, c) {
target.setAttribute('data-overlay-count', String(c));
}
function show(elOrSelector) {
var target = getTarget(elOrSelector);
if (!target) return;
ensurePositioned(target);
var count = getCount(target);
setCount(target, count + 1);
if (count > 0 && target.querySelector(':scope > .crvi-overlay')) {
return;
}
var overlay = createOverlay(target);
target.appendChild(overlay);
}
function hide(elOrSelector) {
var target = getTarget(elOrSelector);
if (!target) return;
var count = getCount(target);
count = Math.max(0, count - 1);
setCount(target, count);
if (count === 0) {
var overlay = target.querySelector(':scope > .crvi-overlay');
if (overlay) {
overlay.remove();
}
}
}
if (!window.CRVI_OVERLAY) {
window.CRVI_OVERLAY = { show: show, hide: hide };
}
})();
function tryAttach() {
if (window.currentCalendar) {
attachLoading(window.currentCalendar, 'loading-indicator', '.filters');
}
if (window.currentColleaguesCalendar) {
attachLoading(
window.currentColleaguesCalendar,
'loading-indicator-colleagues',
'.filters-colleagues'
);
}
}
document.addEventListener('DOMContentLoaded', function () {
// Essayer d'attacher au démarrage puis quelques tentatives (calendriers init asynchrones)
tryAttach();
var iv = setInterval(tryAttach, 500);
setTimeout(function () {
clearInterval(iv);
}, 10000);
// Spinner + désactivation sur le bouton Enregistrer du modal
document.addEventListener(
'click',
function (e) {
var btn = e.target && (e.target.closest ? e.target.closest('#saveEvent') : null);
if (!btn) return;
if (btn.dataset.loading === '1') return;
btn.dataset.loading = '1';
btn.disabled = true;
if (!btn.dataset.originalHtml) {
btn.dataset.originalHtml = btn.innerHTML;
}
btn.innerHTML =
'<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Enregistrer';
var onDone = function () {
btn.disabled = false;
btn.dataset.loading = '0';
if (btn.dataset.originalHtml) {
btn.innerHTML = btn.dataset.originalHtml;
}
};
// Réactiver à la fermeture du modal ou après un timeout de secours
var modal = document.getElementById('eventModal');
if (modal) {
var handler = function () {
modal.removeEventListener('hidden.bs.modal', handler);
onDone();
};
modal.addEventListener('hidden.bs.modal', handler);
}
setTimeout(onDone, 8000);
},
true
);
});
})();

View File

@ -0,0 +1,654 @@
/**
* Module Permanences Admin
* Gestion du formulaire d'encodage des permanences pour l'admin (avec sélection d'intervenant)
*/
import { apiFetch } from './agenda-api.js';
import toastr from 'toastr';
/**
* Initialise le module permanences admin
*/
export function initializeAdminPermanences() {
console.log('🚀 Initialisation du module permanences admin...');
const form = document.getElementById('admin-permanences-form');
if (!form) {
console.error('Formulaire admin-permanences-form non trouvé');
return;
}
// Préselection des jours et heures selon l'intervenant
setupIntervenantDefaults();
// Initialiser le champ mois de début avec le mois actuel
const moisDebutInput = document.getElementById('mois-debut');
if (moisDebutInput && !moisDebutInput.value) {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
moisDebutInput.value = `${year}-${month}`;
}
// Initialiser Select2 pour le champ langues
initializeSelect2();
// Écouter les changements pour mettre à jour l'aperçu
setupPreviewListeners();
// Gérer la soumission du formulaire
form.addEventListener('submit', handleFormSubmit);
// Calculer l'aperçu initial
updatePreview();
// Initialiser le formulaire d'import CSV
initializeCsvImport();
}
/**
* Initialise Select2 pour le champ de sélection des langues
*/
function initializeSelect2() {
const languesSelect = document.getElementById('langues-permanences');
if (languesSelect && typeof jQuery !== 'undefined' && jQuery.fn.select2) {
jQuery(languesSelect).select2({
placeholder: 'Sélectionnez une ou plusieurs langues',
allowClear: true,
width: '100%',
language: {
noResults: function() {
return "Aucun résultat trouvé";
}
}
});
}
}
/**
* Configure la préselection des inputs jours[] et heures[]
* à partir des attributs data-days et data-time-slots de l'option sélectionnée
*/
function setupIntervenantDefaults() {
const select = document.getElementById('intervenant-select');
if (!select) return;
// Suivi des changements manuels pour ne pas écraser l'utilisateur
let hasUserChanged = false;
// Marquer qu'il y a eu modification dès qu'une case est cochée/décochée
const markChanged = () => { hasUserChanged = true; };
document.querySelectorAll('input[name="jours[]"]').forEach(input => {
input.addEventListener('change', markChanged);
});
document.querySelectorAll('input[name="heures[]"]').forEach(input => {
input.addEventListener('change', markChanged);
});
const applyDefaults = () => {
// Si l'utilisateur a déjà modifié les cases, ne pas écraser ses choix
if (hasUserChanged) {
// Laisser l'aperçu se recalculer d'après l'état courant
updatePreview();
return;
}
const option = select.options[select.selectedIndex];
if (!option) return;
const daysStr = option.getAttribute('data-days') || '';
const timesStr = option.getAttribute('data-time-slots') || '';
const days = daysStr
.split(',')
.map(s => s.trim().toLowerCase())
.filter(Boolean);
const times = timesStr
.split(',')
.map(s => s.trim())
.filter(Boolean);
// Réinitialiser toutes les cases
document.querySelectorAll('input[name="jours[]"]').forEach(input => {
input.checked = false;
});
document.querySelectorAll('input[name="heures[]"]').forEach(input => {
input.checked = false;
});
// Appliquer les jours
if (days.length > 0) {
document.querySelectorAll('input[name="jours[]"]').forEach(input => {
const value = String(input.value || '').toLowerCase();
if (days.includes(value)) {
input.checked = true;
}
});
}
// Appliquer les heures
if (times.length > 0) {
document.querySelectorAll('input[name="heures[]"]').forEach(input => {
const value = String(input.value || '').trim();
if (times.includes(value)) {
input.checked = true;
}
});
}
// Mettre à jour l'aperçu
updatePreview();
};
// Sur changement d'intervenant
select.addEventListener('change', applyDefaults);
// Initialisation à l'ouverture si une option est déjà sélectionnée
applyDefaults();
// Si le formulaire est réinitialisé, réactiver l'application auto des valeurs par défaut
const form = document.getElementById('admin-permanences-form');
if (form) {
form.addEventListener('reset', () => {
// Attendre la remise à zéro effective du DOM
setTimeout(() => {
hasUserChanged = false;
applyDefaults();
}, 0);
});
}
}
/**
* Configure les listeners pour mettre à jour l'aperçu en temps réel
*/
function setupPreviewListeners() {
// Écouter les changements de mois de début
const moisDebut = document.getElementById('mois-debut');
if (moisDebut) {
moisDebut.addEventListener('change', updatePreview);
moisDebut.addEventListener('input', updatePreview);
}
// Écouter les changements de période
const periodInputs = document.querySelectorAll('input[name="periode"]');
periodInputs.forEach(input => {
input.addEventListener('change', updatePreview);
});
// Écouter les changements de jours
const joursInputs = document.querySelectorAll('input[name="jours[]"]');
joursInputs.forEach(input => {
input.addEventListener('change', updatePreview);
});
// Écouter les changements des heures sélectionnées
const heuresInputs = document.querySelectorAll('input[name="heures[]"]');
heuresInputs.forEach(input => {
input.addEventListener('change', updatePreview);
});
}
/**
* Calcule et affiche l'aperçu des permanences qui seront créées
*/
function updatePreview() {
const periode = parseInt(document.querySelector('input[name="periode"]:checked')?.value || '3');
const joursChecked = Array.from(document.querySelectorAll('input[name="jours[]"]:checked'))
.map(input => input.value);
const heuresChecked = Array.from(document.querySelectorAll('input[name="heures[]"]:checked'))
.map(input => input.value)
.sort(); // Trier les heures pour un affichage cohérent
const moisDebut = document.getElementById('mois-debut')?.value;
if (heuresChecked.length === 0) {
clearPreview();
return;
}
if (!moisDebut) {
clearPreview();
return;
}
if (joursChecked.length === 0) {
clearPreview();
return;
}
// Calculer les tranches horaires à partir des heures sélectionnées
// Chaque heure sélectionnée = 1 tranche d'1 heure
const tranches = heuresChecked.map(heureDebut => {
const [h, m] = heureDebut.split(':').map(Number);
const heureFin = `${String(h + 1).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
return {
debut: heureDebut,
fin: heureFin
};
});
// Calculer les dates à partir du mois de début sélectionné
const [year, month] = moisDebut.split('-').map(Number);
const startDate = new Date(year, month - 1, 1); // Premier jour du mois sélectionné
const endDate = new Date(year, month - 1 + periode, 0); // Dernier jour du mois de fin
// Compter les occurrences pour chaque jour sélectionné
let totalEvents = 0;
const joursMapping = {
'lundi': 1,
'mardi': 2,
'mercredi': 3,
'jeudi': 4,
'vendredi': 5,
'samedi': 6,
'dimanche': 0
};
joursChecked.forEach(jour => {
const jourNum = joursMapping[jour.toLowerCase()];
if (jourNum !== undefined) {
const occurrences = countDayOccurrences(jourNum, startDate, endDate);
totalEvents += occurrences * tranches.length;
}
});
// Afficher les tranches
displayTranches(tranches);
// Afficher l'estimation
displayEstimation(totalEvents);
}
/**
* Calcule les tranches horaires d'1 heure dans une plage
*/
function calculateTranches(heureDebut, heureFin) {
const tranches = [];
const [debutH, debutM] = heureDebut.split(':').map(Number);
const [finH, finM] = heureFin.split(':').map(Number);
let currentH = debutH;
let currentM = debutM;
while (currentH < finH || (currentH === finH && currentM < finM)) {
const trancheDebut = `${String(currentH).padStart(2, '0')}:${String(currentM).padStart(2, '0')}`;
// Ajouter 1 heure
currentH += 1;
const trancheFin = `${String(currentH).padStart(2, '0')}:${String(currentM).padStart(2, '0')}`;
// Vérifier que la tranche ne dépasse pas l'heure de fin
if (currentH < finH || (currentH === finH && currentM <= finM)) {
tranches.push({
debut: trancheDebut,
fin: trancheFin
});
} else {
break;
}
}
return tranches;
}
/**
* Compte le nombre d'occurrences d'un jour de la semaine dans une plage de dates
*/
function countDayOccurrences(jourSemaine, startDate, endDate) {
let count = 0;
const current = new Date(startDate);
// Ajuster au premier jour de la semaine ciblée
const currentDay = current.getDay();
const diff = (jourSemaine - currentDay + 7) % 7;
current.setDate(current.getDate() + diff);
// Compter les occurrences
while (current <= endDate) {
count++;
current.setDate(current.getDate() + 7); // Semaine suivante
}
return count;
}
/**
* Affiche les tranches horaires générées
*/
function displayTranches(tranches) {
const previewEl = document.getElementById('tranches-preview');
if (!previewEl) return;
if (tranches.length === 0) {
previewEl.innerHTML = '<i class="fas fa-info-circle me-2"></i>Veuillez sélectionner une plage horaire valide.';
return;
}
const tranchesList = tranches.map(t => `<li>${t.debut}${t.fin}</li>`).join('');
previewEl.innerHTML = `
<ul>${tranchesList}</ul>
`;
}
/**
* Affiche l'estimation du nombre d'événements
*/
function displayEstimation(count) {
const containerEl = document.getElementById('estimation-container');
const countEl = document.getElementById('estimation-count');
if (!containerEl || !countEl) return;
if (count > 0) {
countEl.textContent = count;
containerEl.style.display = 'block';
} else {
containerEl.style.display = 'none';
}
}
/**
* Efface l'aperçu
*/
function clearPreview() {
const previewEl = document.getElementById('tranches-preview');
if (previewEl) {
previewEl.innerHTML = '<i class="fas fa-info-circle me-2"></i>Veuillez sélectionner une plage horaire pour voir l\'aperçu.';
}
const containerEl = document.getElementById('estimation-container');
if (containerEl) {
containerEl.style.display = 'none';
}
}
/**
* Gère la soumission du formulaire
*/
async function handleFormSubmit(e) {
e.preventDefault();
const form = e.target;
const submitBtn = document.getElementById('submit-permanences-btn');
// Validation
if (!validateForm()) {
return;
}
// Récupérer les données du formulaire
const formData = new FormData(form);
const intervenantId = formData.get('intervenant_id');
const periode = parseInt(formData.get('periode'));
const moisDebut = formData.get('mois_debut');
const jours = formData.getAll('jours[]');
const heures = formData.getAll('heures[]');
const dureePermanence = document.querySelector('input[name="duree_permanence"]:checked')?.value || '1h';
const nbTranches = dureePermanence === '15min' ? parseInt(document.getElementById('nb-tranches')?.value || '1') : null;
// Récupérer les langues sélectionnées via Select2
const languesSelect = document.getElementById('langues-permanences');
const langues = languesSelect && typeof jQuery !== 'undefined' && jQuery(languesSelect).val()
? jQuery(languesSelect).val().filter(value => value !== '')
: [];
const informationsComplementaires = formData.get('informations_complementaires') || '';
// Validation supplémentaire
if (!intervenantId) {
toastr.error('Veuillez sélectionner un intervenant', 'Erreur');
return;
}
if (!moisDebut) {
toastr.error('Veuillez sélectionner un mois de début', 'Erreur');
return;
}
if (jours.length === 0) {
toastr.error('Veuillez sélectionner au moins un jour de la semaine', 'Erreur');
return;
}
if (heures.length === 0) {
toastr.error('Veuillez sélectionner au moins une heure de permanence', 'Erreur');
return;
}
// Calculer la plage horaire globale (min et max des heures sélectionnées)
const heuresSorted = heures.map(h => {
const [hour] = h.split(':').map(Number);
return hour;
}).sort((a, b) => a - b);
const heureMin = heuresSorted[0];
const heureMax = heuresSorted[heuresSorted.length - 1];
const plageDebut = `${String(heureMin).padStart(2, '0')}:00`;
const plageFin = `${String(heureMax + 1).padStart(2, '0')}:00`;
// Désactiver le bouton
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Création en cours...';
// Préparer les données pour l'API
const data = {
intervenant_id: parseInt(intervenantId),
periode: periode,
mois_debut: moisDebut,
jours: jours,
plage_horaire: {
debut: plageDebut,
fin: plageFin
},
heures: heures, // Envoyer aussi les heures individuelles pour le backend
duree_permanence: dureePermanence, // '1h' ou '15min'
nb_tranches: nbTranches, // Nombre de tranches si 15min (1-4), null si 1h
langues: langues, // Langues sélectionnées (peut être un tableau vide)
informations_complementaires: informationsComplementaires
};
try {
// Appel API (apiFetch retourne directement data, pas response.data)
const result = await apiFetch('admin/permanences', {
method: 'POST',
body: JSON.stringify(data)
});
toastr.success(
`${result.message} (${result.permanences_crees} permanences créées)`,
'Succès',
{ timeOut: 5000 }
);
// Réinitialiser le formulaire
form.reset();
clearPreview();
// Réinitialiser la période par défaut
document.getElementById('periode-3').checked = true;
} catch (error) {
console.error('Erreur lors de la création des permanences:', error);
toastr.error('Une erreur est survenue lors de la création des permanences', 'Erreur');
} finally {
// Réactiver le bouton
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="fas fa-save me-2"></i>Enregistrer les permanences';
}
}
/**
* Valide le formulaire avant soumission
*/
function validateForm() {
const intervenantId = document.getElementById('intervenant-select')?.value;
const periode = document.querySelector('input[name="periode"]:checked')?.value;
const moisDebut = document.getElementById('mois-debut')?.value;
const joursChecked = document.querySelectorAll('input[name="jours[]"]:checked');
const heuresChecked = document.querySelectorAll('input[name="heures[]"]:checked');
if (!intervenantId) {
toastr.error('Veuillez sélectionner un intervenant', 'Validation');
return false;
}
if (!moisDebut) {
toastr.error('Veuillez sélectionner un mois de début', 'Validation');
return false;
}
if (!periode) {
toastr.error('Veuillez sélectionner une période (3 ou 6 mois)', 'Validation');
return false;
}
if (joursChecked.length === 0) {
toastr.error('Veuillez sélectionner au moins un jour de la semaine', 'Validation');
return false;
}
if (heuresChecked.length === 0) {
toastr.error('Veuillez sélectionner au moins une heure de permanence', 'Validation');
return false;
}
return true;
}
/**
* Initialise le formulaire d'import CSV
*/
function initializeCsvImport() {
const csvForm = document.getElementById('import-csv-form');
if (!csvForm) {
console.warn('Formulaire import-csv-form non trouvé');
return;
}
csvForm.addEventListener('submit', handleCsvImportSubmit);
}
/**
* Gère la soumission du formulaire d'import CSV
*/
async function handleCsvImportSubmit(e) {
e.preventDefault();
const form = e.target;
const fileInput = document.getElementById('csv-file');
const submitBtn = document.getElementById('submit-csv-import-btn');
const resultContainer = document.getElementById('csv-import-result');
if (!fileInput || !fileInput.files || fileInput.files.length === 0) {
toastr.error('Veuillez sélectionner un fichier CSV', 'Erreur');
return;
}
const file = fileInput.files[0];
// Vérifier que c'est un fichier CSV
if (!file.name.toLowerCase().endsWith('.csv')) {
toastr.error('Le fichier doit être au format CSV', 'Erreur');
return;
}
// Désactiver le bouton
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Import en cours...';
// Créer FormData pour l'upload
const formData = new FormData();
formData.append('file', file);
try {
// Appel API pour l'import CSV
const url = '/wp-json/crvi/v1/admin/permanences/import-csv';
const response = await fetch(url, {
method: 'POST',
headers: {
'X-WP-Nonce': window.wpApiSettings?.nonce || ''
},
body: formData
});
const data = await response.json();
if (!response.ok || !data.success) {
const errorMessage = data.error?.message || data.message || 'Erreur lors de l\'import';
throw new Error(errorMessage);
}
// Afficher les résultats
displayCsvImportResult(data.data, resultContainer);
toastr.success(
`Import réussi : ${data.data.created || 0} créés, ${data.data.errors || 0} erreurs`,
'Succès',
{ timeOut: 5000 }
);
// Réinitialiser le formulaire
form.reset();
} catch (error) {
console.error('Erreur lors de l\'import CSV:', error);
toastr.error(error.message || 'Une erreur est survenue lors de l\'import CSV', 'Erreur');
if (resultContainer) {
resultContainer.innerHTML = `
<div class="alert alert-danger">
<i class="fas fa-exclamation-circle me-2"></i>
<strong>Erreur :</strong> ${error.message || 'Une erreur est survenue lors de l\'import'}
</div>
`;
resultContainer.style.display = 'block';
}
} finally {
// Réactiver le bouton
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="fas fa-upload me-2"></i>Importer le CSV';
}
}
/**
* Affiche les résultats de l'import CSV
*/
function displayCsvImportResult(result, container) {
if (!container) return;
let html = '';
if (result.errors && result.errors.length > 0) {
html += `
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>
<strong>Attention :</strong> ${result.errors.length} erreur(s) détectée(s)
</div>
`;
if (result.errors.length <= 10) {
html += '<ul class="list-group mt-2">';
result.errors.forEach((error, index) => {
html += `
<li class="list-group-item">
<strong>Ligne ${error.line || '?'} :</strong> ${error.message || 'Erreur inconnue'}
</li>
`;
});
html += '</ul>';
}
}
if (result.created > 0) {
html += `
<div class="alert alert-success mt-3">
<i class="fas fa-check-circle me-2"></i>
<strong>Succès :</strong> ${result.created} permanence(s) créée(s) avec succès
</div>
`;
}
container.innerHTML = html;
container.style.display = 'block';
}

View File

@ -0,0 +1,100 @@
// Module ES6 pour les appels API de l'agenda
export async function apiFetch(endpoint, options = {}) {
const root = (window.wpApiSettings && window.wpApiSettings.root) ? window.wpApiSettings.root : '/wp-json/';
const base = root.endsWith('/') ? root : (root + '/');
const url = `${base}crvi/v1/${endpoint}`.replace(/([^:]\/)\/+/g, '$1'); // normaliser les doubles /
const headers = {
'Content-Type': 'application/json',
...(window.wpApiSettings ? { 'X-WP-Nonce': window.wpApiSettings.nonce } : {})
};
const opts = { ...options, headers: { ...headers, ...(options.headers || {}) } };
try {
// Démarrage indicateur global
try { window.CRVI_LOADING && typeof window.CRVI_LOADING.start === 'function' && window.CRVI_LOADING.start(); } catch (_) {}
const response = await fetch(url, opts);
if (!response.ok) {
let serverText = '';
try {
serverText = await response.text();
} catch (_) {}
const err = new Error(`HTTP ${response.status}: ${response.statusText} | endpoint=${url} | body=${serverText?.slice(0,300)}`);
console.error('API error:', err);
throw err;
}
const data = await response.json();
if (!data.success) {
const errorMessage = data.error?.message || data.message || 'Erreur API';
console.error('Erreur API détectée:', errorMessage, '| endpoint=', url);
throw new Error(errorMessage);
}
return data.data;
} catch (error) {
console.error('Erreur lors de l\'appel API:', error);
throw error;
} finally {
// Fin indicateur global
try { window.CRVI_LOADING && typeof window.CRVI_LOADING.end === 'function' && window.CRVI_LOADING.end(); } catch (_) {}
}
}
export async function getEvents(params = {}) {
// console.log('Appel API getEvents avec paramètres:', params);
const query = new URLSearchParams(params).toString();
const endpoint = `events${query ? '?' + query : ''}`;
// console.log('Endpoint appelé:', endpoint);
const result = await apiFetch(endpoint);
// console.log('Résultat API getEvents:', result);
return result;
}
export async function getEvent(id) {
return apiFetch(`events/${id}`);
}
export async function createEvent(data) {
return apiFetch('events', {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function updateEvent(id, data) {
return apiFetch(`events/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
});
}
export async function deleteEvent(id) {
return apiFetch(`events/${id}`, {
method: 'DELETE'
});
}
export async function changeEventStatus(id, status, motifAnnulation = null) {
const data = { statut: status };
if (motifAnnulation) {
data.motif_annulation = motifAnnulation;
}
return apiFetch(`events/${id}/statut`, {
method: 'PUT',
body: JSON.stringify(data)
});
}
export async function getFilters(type, params = {}) {
// Gestion spéciale pour les disponibilités
if (type === 'disponibilites') {
const query = new URLSearchParams(params).toString();
const endpoint = `agenda/disponibilites${query ? '?' + query : ''}`;
return apiFetch(endpoint);
}
return apiFetch(`filters/${type}`);
}

View File

@ -0,0 +1,305 @@
// Module ES6 pour la création d'entités depuis le modal d'événement
import { apiFetch } from './agenda-api.js';
import { notifyError, notifySuccess } from './agenda-notifications.js';
import { populateSelects, preserveModalData } from './agenda-modal.js';
// Configuration des entités
const ENTITY_CONFIG = {
beneficiaire: {
modalId: 'createBeneficiaireModal',
formId: 'createBeneficiaireForm',
saveBtnId: 'saveBeneficiaireBtn',
selectId: 'id_beneficiaire',
endpoint: 'agenda/beneficiaires',
displayField: 'nom',
linkId: 'createBeneficiaireLink'
},
intervenant: {
modalId: 'createIntervenantModal',
formId: 'createIntervenantForm',
saveBtnId: 'saveIntervenantBtn',
selectId: 'id_intervenant',
endpoint: 'agenda/intervenants',
displayField: 'nom',
linkId: 'createIntervenantLink'
},
traducteur: {
modalId: 'createTraducteurModal',
formId: 'createTraducteurForm',
saveBtnId: 'saveTraducteurBtn',
selectId: 'id_traducteur',
endpoint: 'agenda/traducteurs',
displayField: 'nom',
linkId: 'createTraducteurLink'
},
local: {
modalId: 'createLocalModal',
formId: 'createLocalForm',
saveBtnId: 'saveLocalBtn',
selectId: 'id_local',
endpoint: 'agenda/locaux',
displayField: 'nom',
linkId: 'createLocalLink'
}
};
// Variable pour suivre si les créateurs d'entités ont déjà été initialisés
let entityCreatorsInitialized = false;
// Initialisation des modals de création d'entités
export function initializeEntityCreators() {
// Éviter la double initialisation
if (entityCreatorsInitialized) {
// console.log('Créateurs d\'entités déjà initialisés, ignoré.');
return;
}
// console.log('Initialisation des créateurs d\'entités...');
// Ajout des event listeners pour chaque type d'entité
Object.entries(ENTITY_CONFIG).forEach(([entityType, config]) => {
const createLink = document.getElementById(config.linkId);
const saveBtn = document.getElementById(config.saveBtnId);
if (createLink) {
// Supprimer les anciens event listeners s'ils existent
createLink.removeEventListener('click', createLink._entityCreatorHandler);
// Créer un nouveau handler et le stocker pour pouvoir le supprimer plus tard
createLink._entityCreatorHandler = (e) => {
// console.log('click sur le lien de création d\'entité');
e.preventDefault();
openCreateEntityModal(entityType);
};
createLink.addEventListener('click', createLink._entityCreatorHandler);
}
if (saveBtn) {
// Supprimer les anciens event listeners s'ils existent
saveBtn.removeEventListener('click', saveBtn._entityCreatorHandler);
// Créer un nouveau handler et le stocker pour pouvoir le supprimer plus tard
saveBtn._entityCreatorHandler = () => handleEntityCreation(entityType);
saveBtn.addEventListener('click', saveBtn._entityCreatorHandler);
}
});
entityCreatorsInitialized = true;
}
// Ouvre le modal de création d'une entité
function openCreateEntityModal(entityType) {
const config = ENTITY_CONFIG[entityType];
if (!config) {
console.error(`Configuration non trouvée pour l'entité: ${entityType}`);
return;
}
const modal = document.getElementById(config.modalId);
if (!modal) {
console.error(`Modal non trouvé: ${config.modalId}`);
return;
}
// Réinitialiser le formulaire
const form = document.getElementById(config.formId);
if (form) {
form.reset();
}
// Les selects sont maintenant initialisés automatiquement par jQuery('.select2').select2()
// Préserver les données de la modale principale avant de la fermer
preserveModalData();
// Fermer le modal principal d'événement s'il est ouvert
const eventModal = document.getElementById('eventModal');
if (eventModal) {
const eventBsModal = bootstrap.Modal.getInstance(eventModal);
if (eventBsModal) {
eventBsModal.hide();
}
}
// Attendre un peu que le modal principal se ferme avant d'ouvrir le nouveau
setTimeout(() => {
// Ouvrir le modal de création
if (window.bootstrap && window.bootstrap.Modal) {
const bsModal = new window.bootstrap.Modal(modal);
bsModal.show();
// Ajouter un event listener pour rouvrir le modal principal quand on ferme
modal.addEventListener('hidden.bs.modal', function() {
// Rouvrir le modal principal d'événement avec les données préservées
if (eventModal && window.bootstrap && window.bootstrap.Modal) {
const newEventModal = new window.bootstrap.Modal(eventModal);
newEventModal.show();
}
}, { once: true }); // Une seule fois
}
}, 300); // Délai pour la transition
}
// Les selects sont maintenant initialisés automatiquement par jQuery('.select2').select2()
// Les options sont générées en PHP dans le template
// Gère la création d'une entité
async function handleEntityCreation(entityType) {
const config = ENTITY_CONFIG[entityType];
if (!config) {
console.error(`Configuration non trouvée pour l'entité: ${entityType}`);
return;
}
const form = document.getElementById(config.formId);
if (!form) {
console.error(`Formulaire non trouvé: ${config.formId}`);
return;
}
// Validation du formulaire
if (!form.checkValidity()) {
form.reportValidity();
return;
}
// Récupération des données du formulaire
const formData = new FormData(form);
const data = Object.fromEntries(formData.entries());
// Gestion spéciale pour les selects multiples
if (entityType === 'traducteur') {
const languesSelect = document.getElementById('traducteur_langues');
if (languesSelect && window.jQuery) {
const selectedLangues = jQuery(languesSelect).select2('data').map(item => item.id);
data.langues_parlees = selectedLangues.join('|');
}
} else if (entityType === 'intervenant') {
const departementsSelect = document.getElementById('intervenant_departements');
if (departementsSelect && window.jQuery) {
const selectedDepartements = jQuery(departementsSelect).select2('data').map(item => item.id);
data.departements_ids = selectedDepartements.join('|');
}
}
try {
// console.log(`Création de l'entité ${entityType}:`, data);
// Appel API pour créer l'entité
const response = await apiFetch(config.endpoint, {
method: 'POST',
body: JSON.stringify(data)
});
// console.log(`Entité ${entityType} créée:`, response);
// Fermer le modal de création
const modal = document.getElementById(config.modalId);
if (modal && window.bootstrap && window.bootstrap.Modal) {
const bsModal = window.bootstrap.Modal.getInstance(modal);
if (bsModal) {
bsModal.hide();
}
}
// Attendre que le modal se ferme avant de rouvrir le modal principal
setTimeout(async () => {
// Ajouter la nouvelle entité au select correspondant
await addEntityToSelect(entityType, response);
// Rafraîchir les selects pour mettre à jour les disponibilités
await populateSelects();
// Rouvrir le modal principal d'événement avec les données préservées
const eventModal = document.getElementById('eventModal');
if (eventModal && window.bootstrap && window.bootstrap.Modal) {
const newEventModal = new window.bootstrap.Modal(eventModal);
newEventModal.show();
}
notifySuccess(`${entityType.charAt(0).toUpperCase() + entityType.slice(1)} créé avec succès`);
}, 300);
} catch (error) {
console.error(`Erreur lors de la création de l'entité ${entityType}:`, error);
notifyError(`Erreur lors de la création du ${entityType}: ${error.message || 'Erreur inconnue'}`);
}
}
// Ajoute une nouvelle entité au select correspondant
async function addEntityToSelect(entityType, entityData) {
const config = ENTITY_CONFIG[entityType];
if (!config) return;
const select = document.getElementById(config.selectId);
if (!select) return;
// Créer l'option pour la nouvelle entité
const option = document.createElement('option');
option.value = entityData.id;
// Construire le texte d'affichage enrichi selon le type d'entité
let displayText = '';
switch (entityType) {
case 'beneficiaire':
displayText = `${entityData.prenom || ''} ${entityData.nom || ''}`.trim();
if (entityData.email) {
displayText += ` (${entityData.email})`;
}
break;
case 'intervenant':
displayText = `${entityData.prenom || ''} ${entityData.nom || ''}`.trim();
if (entityData.specialite) {
displayText += ` - ${entityData.specialite}`;
}
if (entityData.email) {
displayText += ` (${entityData.email})`;
}
break;
case 'traducteur':
displayText = `${entityData.prenom || ''} ${entityData.nom || ''}`.trim();
if (entityData.langues_parlees) {
displayText += ` - ${entityData.langues_parlees}`;
}
if (entityData.email) {
displayText += ` (${entityData.email})`;
}
break;
case 'local':
displayText = entityData.nom || '';
if (entityData.capacite) {
displayText += ` (${entityData.capacite} places)`;
}
if (entityData.adresse) {
displayText += ` - ${entityData.adresse}`;
}
break;
default:
displayText = entityData[config.displayField] || entityData.nom || '';
if (entityData.prenom) {
displayText += ` ${entityData.prenom}`;
}
}
option.textContent = displayText;
// Ajouter l'option au select
select.appendChild(option);
// Sélectionner automatiquement la nouvelle entité
select.value = entityData.id;
// console.log(`Entité ${entityType} ajoutée au select ${config.selectId}:`, entityData);
}
// Fonction utilitaire pour obtenir la configuration d'une entité
export function getEntityConfig(entityType) {
return ENTITY_CONFIG[entityType];
}

View File

@ -0,0 +1,383 @@
// Module de gestion des filtres dynamiques pour l'agenda
import { getEvents } from './agenda-api.js';
let currentFilters = {};
let calendarInstance = null;
/**
* Génère le titre d'un événement en utilisant les données enrichies
* @param {Object} event - Données de l'événement
* @returns {string} Titre de l'événement
*/
function getEventTitle(event) {
let title = '';
// Utiliser le nom du bénéficiaire si disponible
if (event.beneficiaire && event.beneficiaire.nom_complet) {
title = event.beneficiaire.nom_complet;
} else if (event.type === 'groupe') {
title = 'Groupe';
} else {
title = event.type || 'Sans type';
}
// Ajouter le type d'intervention principal si disponible
if (event.intervenant && event.intervenant.types_intervention_noms && event.intervenant.types_intervention_noms.length > 0) {
const primaryType = event.intervenant.types_intervention_noms[0]; // Premier type d'intervention
title += ` - ${primaryType}`;
}
// Ajouter le département si disponible (optionnel - décommenter si souhaité)
// if (event.intervenant && event.intervenant.departements_noms && event.intervenant.departements_noms.length > 0) {
// const departements = event.intervenant.departements_noms.join(', ');
// title += ` [${departements}]`;
// }
// Ajouter le commentaire si disponible
if (event.commentaire) {
title += ' - ' + event.commentaire;
}
return title;
}
/**
* Initialise les filtres dynamiques
* @param {Object} calendar - Instance FullCalendar
*/
export function initializeFilters(calendar) {
calendarInstance = calendar;
// Remplacer la fonction events du calendrier pour inclure les filtres
if (calendarInstance) {
const originalEventsFunction = calendarInstance.getOption('events');
calendarInstance.setOption('events', async function(fetchInfo, successCallback, failureCallback) {
try {
const filters = collectFilters();
const params = {
start: fetchInfo.startStr.split('T')[0],
end: fetchInfo.endStr.split('T')[0],
...filters
};
// console.log('Chargement des événements pour la période:', params.start, 'à', params.end);
if (Object.keys(filters).length > 0) {
// console.log('Filtres appliqués:', filters);
}
const apiEvents = await getEvents(params);
const events = apiEvents.map(ev => ({
id: ev.id,
title: getEventTitle(ev),
start: ev.date_rdv + 'T' + ev.heure_rdv,
end: ev.date_fin + 'T' + ev.heure_fin,
extendedProps: {
type: ev.type,
commentaire: ev.commentaire,
date_rdv: ev.date_rdv,
heure_rdv: ev.heure_rdv,
date_fin: ev.date_fin,
heure_fin: ev.heure_fin,
// Garder les IDs pour compatibilité
id_beneficiaire: ev.id_beneficiaire,
id_intervenant: ev.id_intervenant,
id_traducteur: ev.id_traducteur,
id_local: ev.id_local,
langue: ev.langue,
// Nouvelles données enrichies
beneficiaire: ev.beneficiaire,
intervenant: ev.intervenant,
traducteur: ev.traducteur,
local: ev.local,
nb_participants: ev.nb_participants,
nb_hommes: ev.nb_hommes,
nb_femmes: ev.nb_femmes,
statut: ev.statut,
created_at: ev.created_at,
updated_at: ev.updated_at
}
}));
successCallback(events);
} catch (e) {
console.error('Erreur lors du chargement des événements:', e);
failureCallback(e);
}
});
}
// Intercepter la soumission du formulaire
const filterForm = document.querySelector('.filters');
if (filterForm) {
filterForm.addEventListener('submit', function(e) {
e.preventDefault();
applyFilters();
});
}
// Écouter les changements sur les selects pour filtrage automatique
const filterSelects = filterForm.querySelectorAll('select');
filterSelects.forEach(select => {
select.addEventListener('change', function() {
// Vérifier et mettre à jour la visibilité du bouton de réinitialisation
const currentFilters = collectFilters();
updateResetButtonVisibility(currentFilters);
// Appliquer les filtres automatiquement après un délai
clearTimeout(window.filterTimeout);
window.filterTimeout = setTimeout(() => {
applyFilters();
}, 500);
});
});
// Écouter les changements sur l'input date
const dateInput = document.getElementById('date');
if (dateInput) {
dateInput.addEventListener('change', function() {
// Vérifier et mettre à jour la visibilité du bouton de réinitialisation
const currentFilters = collectFilters();
updateResetButtonVisibility(currentFilters);
clearTimeout(window.filterTimeout);
window.filterTimeout = setTimeout(() => {
applyFilters();
}, 500);
});
}
// Écouter les changements sur la checkbox filtre permanence
const filtrePermanenceCheckbox = document.getElementById('filtre_permanence');
if (filtrePermanenceCheckbox) {
filtrePermanenceCheckbox.addEventListener('change', function() {
// Vérifier et mettre à jour la visibilité du bouton de réinitialisation
const currentFilters = collectFilters();
updateResetButtonVisibility(currentFilters);
clearTimeout(window.filterTimeout);
window.filterTimeout = setTimeout(() => {
applyFilters();
}, 500);
});
}
// Gérer le bouton de réinitialisation
const resetBtn = document.getElementById('resetFiltersBtn');
if (resetBtn) {
resetBtn.addEventListener('click', function() {
resetFilters();
});
}
// Gérer le bouton de filtrage
const filterBtn = document.getElementById('filterBtn');
if (filterBtn) {
// console.log('Bouton filterBtn trouvé, ajout du gestionnaire d\'événement');
// Désactiver le comportement par défaut du bouton
filterBtn.addEventListener('click', function(e) {
// console.log('Clic sur le bouton filterBtn détecté');
e.preventDefault();
e.stopPropagation();
applyFilters();
});
} else {
console.warn('Bouton filterBtn non trouvé');
}
// Masquer le bouton de réinitialisation au démarrage (aucun filtre actif)
updateResetButtonVisibility({});
// console.log('Filtres dynamiques initialisés');
}
/**
* Collecte les valeurs des filtres depuis le formulaire
* @returns {Object} - Objet contenant les filtres
*/
function collectFilters() {
const filters = {};
// Date
const date = document.getElementById('date').value;
if (date && date.trim() !== '') {
filters.date = date;
}
// Local
const local = document.getElementById('local').value;
if (local && local.trim() !== '') {
filters.local = local;
}
// Personne (intervenant)
const personne = document.getElementById('personne').value;
if (personne && personne.trim() !== '') {
filters.intervenant = personne;
}
// Type d'intervention
const typeIntervention = document.getElementById('type_intervention').value;
if (typeIntervention && typeIntervention.trim() !== '') {
filters.type_intervention = typeIntervention;
}
// Bénéficiaire
const beneficiaire = document.getElementById('beneficiaire').value;
if (beneficiaire && beneficiaire.trim() !== '') {
filters.beneficiaire = beneficiaire;
}
// Langue
const langue = document.getElementById('langue').value;
if (langue && langue.trim() !== '') {
filters.langue = langue;
}
// Intervenant externe (traducteur)
const intervenantExterne = document.getElementById('intervenant_externe').value;
if (intervenantExterne && intervenantExterne.trim() !== '') {
filters.traducteur = intervenantExterne;
}
// Filtre permanence uniquement
const filtrePermanence = document.getElementById('filtre_permanence');
if (filtrePermanence && filtrePermanence.checked) {
filters.type = 'permanence';
}
return filters;
}
/**
* Applique les filtres au calendrier
*/
async function applyFilters() {
if (!calendarInstance) {
console.error('Instance FullCalendar non disponible');
return;
}
const filters = collectFilters();
// Vérifier si les filtres ont changé
if (JSON.stringify(filters) === JSON.stringify(currentFilters)) {
// console.log('Filtres identiques, pas de rechargement nécessaire');
return;
}
currentFilters = filters;
// Mettre à jour la visibilité du bouton de réinitialisation
updateResetButtonVisibility(filters);
// Afficher un indicateur de chargement
showLoadingIndicator();
try {
// Recharger les événements avec les nouveaux filtres
await calendarInstance.refetchEvents();
// Afficher un message de succès
if (window.toastr) {
const filterCount = Object.keys(filters).length;
if (filterCount > 0) {
window.toastr.success(`${filterCount} filtre(s) appliqué(s)`);
} else {
window.toastr.info('Tous les filtres ont été supprimés');
}
}
// console.log('Filtres appliqués avec succès:', filters);
} catch (error) {
console.error('Erreur lors de l\'application des filtres:', error);
if (window.toastr) {
window.toastr.error('Erreur lors de l\'application des filtres');
}
} finally {
hideLoadingIndicator();
}
}
/**
* Affiche l'indicateur de chargement
*/
function showLoadingIndicator() {
const loadingIndicator = document.getElementById('loading-indicator');
if (loadingIndicator) {
loadingIndicator.style.display = 'block';
loadingIndicator.innerHTML = '<p>Application des filtres...</p>';
}
}
/**
* Cache l'indicateur de chargement
*/
function hideLoadingIndicator() {
const loadingIndicator = document.getElementById('loading-indicator');
if (loadingIndicator) {
loadingIndicator.style.display = 'none';
}
}
/**
* Gère l'affichage du bouton de réinitialisation
* @param {Object} filters - Filtres actuels
*/
function updateResetButtonVisibility(filters) {
const resetBtn = document.getElementById('resetFiltersBtn');
if (resetBtn) {
const hasActiveFilters = Object.keys(filters).length > 0;
// Masquer le conteneur parent au lieu de juste le bouton
const resetBtnContainer = resetBtn.closest('.filter');
if (resetBtnContainer) {
resetBtnContainer.style.display = hasActiveFilters ? 'flex' : 'none';
} else {
// Fallback si le conteneur n'est pas trouvé
resetBtn.style.display = hasActiveFilters ? 'inline-block' : 'none';
}
}
}
/**
* Réinitialise tous les filtres
*/
export function resetFilters() {
const filterForm = document.querySelector('.filters');
if (filterForm) {
// Réinitialiser le formulaire
filterForm.reset();
// Réinitialiser Select2 si il est utilisé
const selects = filterForm.querySelectorAll('select');
selects.forEach(select => {
if (jQuery(select).hasClass('select2-hidden-accessible')) {
jQuery(select).val('').trigger('change');
}
});
// Réinitialiser l'input date
const dateInput = document.getElementById('date');
if (dateInput) {
dateInput.value = '';
}
// Masquer le bouton de réinitialisation
updateResetButtonVisibility({});
// Appliquer les filtres (qui seront vides) et recharger l'agenda
applyFilters();
// Afficher un message de confirmation
if (window.toastr) {
window.toastr.info('Tous les filtres ont été réinitialisés');
}
}
}
/**
* Retourne les filtres actuels
* @returns {Object} - Filtres actuels
*/
export function getCurrentFilters() {
return { ...currentFilters };
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,971 @@
/**
* Module Agenda Intervenant
* Adapte l'agenda existant pour utiliser les endpoints intervenant
* Réutilise les modules agenda-fullcalendar.js, agenda-filters.js, etc.
*/
import { apiFetch, updateEvent, getEvent } from './agenda-api.js';
import { notifyError, notifySuccess } from './agenda-notifications.js';
import { openModal } from './agenda-modal.js';
import { Calendar } from '@fullcalendar/core';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import listPlugin from '@fullcalendar/list';
import interactionPlugin from '@fullcalendar/interaction';
// Mode de vue : 'mine' ou 'colleagues'
let currentViewMode = 'mine';
/**
* Initialise le calendrier pour l'intervenant (mon agenda)
*/
export function initializeIntervenantCalendar() {
console.log('🚀 Initialisation du calendrier intervenant (mon agenda)...');
const calendarEl = document.getElementById('agenda-calendar');
if (!calendarEl) {
console.error('❌ Élément agenda-calendar non trouvé');
return null;
}
currentViewMode = 'mine';
const calendar = new Calendar(calendarEl, {
plugins: [dayGridPlugin, timeGridPlugin, listPlugin, interactionPlugin],
initialView: 'dayGridMonth',
eventDisplay: 'block',
locale: 'fr',
firstDay: 1,
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
},
buttonText: {
today: 'Aujourd\'hui',
month: 'Mois',
week: 'Semaine',
day: 'Jour',
list: 'Liste'
},
height: 'auto',
// On active l'édition globalement, mais on contrôle au niveau de chaque
// événement + des permissions (is_mine + crviPermissions.can_edit)
editable: true,
eventStartEditable: true,
eventDurationEditable: true,
eventResizableFromStart: true,
selectable: true, // Activer la sélection pour créer des RDV
dayMaxEvents: 3,
weekends: true,
moreLinkClick: 'popover', // Afficher les événements cachés dans un popover
moreLinkContent: function(arg) {
// Personnaliser le texte du lien "+x plus"
const count = arg.num;
return `+${count} plus`;
},
// Gestionnaire de sélection de plage de dates pour créer un événement
select: function(arg) {
// Vérifier les permissions de création
const userCanCreate = window.crviPermissions && window.crviPermissions.can_create;
if (!userCanCreate) {
notifyError('Vous n\'êtes pas autorisé à créer des événements');
console.warn('❌ Utilisateur non autorisé à créer des événements (permissions WP)');
return;
}
// Vérifier que la date de début n'est pas dans le passé
if (arg.startStr) {
const startDateStr = arg.startStr.split('T')[0];
const today = new Date();
today.setHours(0, 0, 0, 0);
const selectedDate = new Date(startDateStr + 'T00:00:00');
selectedDate.setHours(0, 0, 0, 0);
if (selectedDate < today) {
notifyError('Impossible de créer un événement à une date passée');
console.warn('⚠️ Tentative de création d\'événement à une date passée:', startDateStr);
return;
}
}
// Préremplir avec l'intervenant actuel (utilisateur connecté)
const currentUserId = window.crviPermissions && window.crviPermissions.user_id;
// Ouvrir le modal de création avec l'intervenant prérempli
const eventData = {
...arg,
extendedProps: {
id_intervenant: currentUserId
}
};
openModal('create', eventData);
},
// Charger les événements depuis l'endpoint intervenant
events: async function(fetchInfo, successCallback, failureCallback) {
try {
const filters = collectIntervenantFilters();
const params = {
start: fetchInfo.startStr.split('T')[0],
end: fetchInfo.endStr.split('T')[0],
view_mode: 'mine', // Toujours en mode "mon agenda"
filters: JSON.stringify(filters)
};
const events = await apiFetch('intervenant/agenda?' + new URLSearchParams(params));
// Formater pour FullCalendar
const formattedEvents = events.map(event => {
// Extraire la date et l'heure depuis start si disponibles
let date_rdv = null;
let heure_rdv = null;
if (event.start) {
const startDate = new Date(event.start);
date_rdv = startDate.toISOString().split('T')[0];
heure_rdv = startDate.toTimeString().substring(0, 5);
}
return {
id: event.id,
title: event.title,
start: event.start,
end: event.end,
backgroundColor: getEventColor(event),
borderColor: getEventColor(event),
textColor: '#fff',
// Seuls les événements "à moi" sont éditables côté UI
editable: !!event.is_mine,
durationEditable: !!event.is_mine,
startEditable: !!event.is_mine,
extendedProps: {
type: event.type,
statut: event.statut,
is_mine: event.is_mine,
show_comments: event.show_comments,
commentaire: event.commentaire,
beneficiaire: event.beneficiaire,
local: event.local,
intervenant_nom: event.intervenant_nom,
date: date_rdv,
date_rdv: date_rdv,
heure: heure_rdv,
heure_rdv: heure_rdv,
langue: event.langue,
langues_disponibles: event.langues_disponibles || null
}
};
});
successCallback(formattedEvents);
} catch (error) {
console.error('Erreur lors du chargement de l\'agenda:', error);
failureCallback(error);
}
},
// Déplacement d'un événement (drag & drop)
eventDrop: async function(info) {
const props = info.event.extendedProps || {};
// Vérifier que l'événement appartient bien à l'intervenant
if (!props.is_mine) {
console.warn('❌ Tentative de déplacement dun événement qui ne vous appartient pas');
info.revert();
notifyError('Vous ne pouvez déplacer que vos propres rendez-vous');
return;
}
// Vérifier les permissions globales
const userCanEdit = window.crviPermissions && window.crviPermissions.can_edit;
if (!userCanEdit) {
console.warn('❌ Utilisateur non autorisé à déplacer des événements (permissions WP)');
info.revert();
notifyError('Vous n\'êtes pas autorisé à modifier les événements');
return;
}
try {
const newStart = info.event.start;
const newEnd = info.event.end || new Date(newStart.getTime() + 60 * 60 * 1000); // +1h par défaut
const updateData = {
date_rdv: newStart.toISOString().split('T')[0],
heure_rdv: newStart.toTimeString().substring(0, 5),
date_fin: newEnd.toISOString().split('T')[0],
heure_fin: newEnd.toTimeString().substring(0, 5)
};
await updateEvent(info.event.id, updateData);
notifySuccess('Événement déplacé avec succès');
} catch (error) {
console.error('Erreur lors du déplacement de l\'événement (front intervenant):', error);
info.revert();
notifyError('Erreur lors du déplacement de l\'événement');
}
},
// Redimensionnement d'un événement (changement de durée)
eventResize: async function(info) {
const props = info.event.extendedProps || {};
if (!props.is_mine) {
console.warn('❌ Tentative de redimensionnement dun événement qui ne vous appartient pas');
info.revert();
notifyError('Vous ne pouvez modifier que vos propres rendez-vous');
return;
}
const userCanEdit = window.crviPermissions && window.crviPermissions.can_edit;
if (!userCanEdit) {
console.warn('❌ Utilisateur non autorisé à redimensionner des événements (permissions WP)');
info.revert();
notifyError('Vous n\'êtes pas autorisé à modifier les événements');
return;
}
try {
const newEnd = info.event.end;
const updateData = {
date_fin: newEnd.toISOString().split('T')[0],
heure_fin: newEnd.toTimeString().substring(0, 5)
};
await updateEvent(info.event.id, updateData);
notifySuccess('Événement redimensionné avec succès');
} catch (error) {
console.error('Erreur lors du redimensionnement de l\'événement (front intervenant):', error);
info.revert();
notifyError('Erreur lors du redimensionnement de l\'événement');
}
},
eventClick: function(info) {
// Ouvrir le modal avec les détails (adapter le modal existant)
openIntervenantEventModal(info.event);
},
// Ajouter les popovers Bootstrap sur les événements
eventDidMount: function(arg) {
const eventEl = arg.el;
const event = arg.event;
const eventProps = event.extendedProps || {};
// Fonction utilitaire pour formater une date au format français
function formatDateToFrench(dateString) {
if (!dateString) return '';
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) {
// Si ce n'est pas une date valide, essayer le format YYYY-MM-DD
const parts = dateString.split('-');
if (parts.length === 3) {
return `${parts[2]}/${parts[1]}/${parts[0]}`;
}
return dateString;
}
return date.toLocaleDateString('fr-FR');
} catch (error) {
console.error('Erreur lors du formatage de la date:', error);
return dateString;
}
}
// Fonction utilitaire pour extraire le nom complet d'une entité
function getEntityDisplayName(entity, entityType = '') {
if (!entity) return '';
// Si c'est déjà une chaîne, la retourner
if (typeof entity === 'string') return entity;
// Si c'est un objet avec une propriété nom
if (entity && typeof entity === 'object') {
// Pour les bénéficiaires et intervenants, combiner prénom et nom
if (entityType === 'beneficiaire' || entityType === 'intervenant') {
const prenom = entity.prenom || '';
const nom = entity.nom || '';
return `${prenom} ${nom}`.trim() || entity.nom || entity.display_name || '';
}
// Pour les locaux, utiliser le nom
if (entityType === 'local') {
return entity.nom || entity.display_name || '';
}
// Fallback générique
return entity.nom || entity.display_name || entity.name || '';
}
return '';
}
// Extraire la date et l'heure depuis event.start si pas dans extendedProps
let dateStr = eventProps.date || eventProps.date_rdv || '';
let heureStr = eventProps.heure || eventProps.heure_rdv || '';
if (!dateStr && event.start) {
const startDate = new Date(event.start);
dateStr = startDate.toISOString().split('T')[0];
}
if (!heureStr && event.start) {
const startDate = new Date(event.start);
heureStr = startDate.toTimeString().substring(0, 5);
}
// Créer le contenu du popover
const popoverContent = `
<div class="event-popover">
<h6 class="mb-2">${event.title}</h6>
<div class="mb-1"><strong>Date:</strong> ${formatDateToFrench(dateStr)}</div>
<div class="mb-1"><strong>Heure:</strong> ${heureStr}</div>
<div class="mb-1"><strong>Type:</strong> ${eventProps.type || ''}</div>
${eventProps.langue ? `<div class="mb-1"><strong>Langue:</strong> ${eventProps.langue}</div>` : ''}
${eventProps.beneficiaire ? `<div class="mb-1"><strong>Bénéficiaire:</strong> ${typeof eventProps.beneficiaire === 'string' ? eventProps.beneficiaire : getEntityDisplayName(eventProps.beneficiaire, 'beneficiaire')}</div>` : ''}
${eventProps.intervenant_nom ? `<div class="mb-1"><strong>Intervenant:</strong> ${eventProps.intervenant_nom}</div>` : ''}
${eventProps.local ? `<div class="mb-1"><strong>Local:</strong> ${typeof eventProps.local === 'string' ? eventProps.local : getEntityDisplayName(eventProps.local, 'local')}</div>` : ''}
${eventProps.commentaire && eventProps.show_comments ? `<div class="mb-1"><strong>Commentaire:</strong> ${eventProps.commentaire}</div>` : ''}
<div class="mb-1"><strong>Statut:</strong> ${eventProps.statut || ''}</div>
<div class="mt-2">
<small class="text-muted">Cliquez pour plus de détails</small>
</div>
</div>
`;
// Initialiser le popover Bootstrap
if (window.bootstrap && window.bootstrap.Popover) {
const popover = new bootstrap.Popover(eventEl, {
title: 'Détails de l\'événement',
content: popoverContent,
html: true,
trigger: 'hover',
placement: 'top',
container: 'body',
template: '<div class="popover event-popover" role="tooltip"><div class="popover-arrow"></div><h3 class="popover-header"></h3><div class="popover-body"></div></div>'
});
}
}
});
calendar.render();
window.currentCalendar = calendar;
// Masquer l'indicateur de chargement
const loadingIndicator = document.getElementById('loading-indicator');
if (loadingIndicator) {
loadingIndicator.style.display = 'none';
}
// Initialiser les filtres adaptés pour intervenant
initializeIntervenantFilters(calendar);
return calendar;
}
/**
* Initialise le calendrier pour les collègues
*/
export function initializeColleaguesCalendar() {
console.log('🚀 Initialisation du calendrier collègues...');
const calendarEl = document.getElementById('agenda-calendar-colleagues');
if (!calendarEl) {
console.error('❌ Élément agenda-calendar-colleagues non trouvé');
return null;
}
currentViewMode = 'colleagues';
const calendar = new Calendar(calendarEl, {
plugins: [dayGridPlugin, timeGridPlugin, listPlugin, interactionPlugin],
initialView: 'dayGridMonth',
eventDisplay: 'block',
locale: 'fr',
firstDay: 1,
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
},
buttonText: {
today: 'Aujourd\'hui',
month: 'Mois',
week: 'Semaine',
day: 'Jour',
list: 'Liste'
},
height: 'auto',
editable: false,
selectable: false,
dayMaxEvents: 3,
weekends: true,
moreLinkClick: 'popover', // Afficher les événements cachés dans un popover
moreLinkContent: function(arg) {
// Personnaliser le texte du lien "+x plus"
const count = arg.num;
return `+${count} plus`;
},
events: async function(fetchInfo, successCallback, failureCallback) {
try {
const filters = collectColleaguesFilters();
const params = {
start: fetchInfo.startStr.split('T')[0],
end: fetchInfo.endStr.split('T')[0],
view_mode: 'colleagues',
filters: JSON.stringify(filters)
};
const events = await apiFetch('intervenant/agenda?' + new URLSearchParams(params));
const formattedEvents = events.map(event => {
// Extraire la date et l'heure depuis start si disponibles
let date_rdv = null;
let heure_rdv = null;
if (event.start) {
const startDate = new Date(event.start);
date_rdv = startDate.toISOString().split('T')[0];
heure_rdv = startDate.toTimeString().substring(0, 5);
}
return {
id: event.id,
title: event.title,
start: event.start,
end: event.end,
backgroundColor: getEventColor(event),
borderColor: getEventColor(event),
textColor: '#fff',
extendedProps: {
type: event.type,
statut: event.statut,
is_mine: event.is_mine,
show_comments: false, // Jamais de commentaires pour les collègues
beneficiaire: event.beneficiaire,
local: event.local,
intervenant_nom: event.intervenant_nom,
date: date_rdv,
date_rdv: date_rdv,
heure: heure_rdv,
heure_rdv: heure_rdv,
langue: event.langue
}
};
});
successCallback(formattedEvents);
} catch (error) {
console.error('Erreur lors du chargement de l\'agenda des collègues:', error);
failureCallback(error);
}
},
eventClick: function(info) {
openIntervenantEventModal(info.event);
},
// Ajouter les popovers Bootstrap sur les événements
eventDidMount: function(arg) {
const eventEl = arg.el;
const event = arg.event;
const eventProps = event.extendedProps || {};
// Fonction utilitaire pour formater une date au format français
function formatDateToFrench(dateString) {
if (!dateString) return '';
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) {
// Si ce n'est pas une date valide, essayer le format YYYY-MM-DD
const parts = dateString.split('-');
if (parts.length === 3) {
return `${parts[2]}/${parts[1]}/${parts[0]}`;
}
return dateString;
}
return date.toLocaleDateString('fr-FR');
} catch (error) {
console.error('Erreur lors du formatage de la date:', error);
return dateString;
}
}
// Fonction utilitaire pour extraire le nom complet d'une entité
function getEntityDisplayName(entity, entityType = '') {
if (!entity) return '';
// Si c'est déjà une chaîne, la retourner
if (typeof entity === 'string') return entity;
// Si c'est un objet avec une propriété nom
if (entity && typeof entity === 'object') {
// Pour les bénéficiaires et intervenants, combiner prénom et nom
if (entityType === 'beneficiaire' || entityType === 'intervenant') {
const prenom = entity.prenom || '';
const nom = entity.nom || '';
return `${prenom} ${nom}`.trim() || entity.nom || entity.display_name || '';
}
// Pour les locaux, utiliser le nom
if (entityType === 'local') {
return entity.nom || entity.display_name || '';
}
// Fallback générique
return entity.nom || entity.display_name || entity.name || '';
}
return '';
}
// Extraire la date et l'heure depuis event.start si pas dans extendedProps
let dateStr = eventProps.date || eventProps.date_rdv || '';
let heureStr = eventProps.heure || eventProps.heure_rdv || '';
if (!dateStr && event.start) {
const startDate = new Date(event.start);
dateStr = startDate.toISOString().split('T')[0];
}
if (!heureStr && event.start) {
const startDate = new Date(event.start);
heureStr = startDate.toTimeString().substring(0, 5);
}
// Créer le contenu du popover
const popoverContent = `
<div class="event-popover">
<h6 class="mb-2">${event.title}</h6>
<div class="mb-1"><strong>Date:</strong> ${formatDateToFrench(dateStr)}</div>
<div class="mb-1"><strong>Heure:</strong> ${heureStr}</div>
<div class="mb-1"><strong>Type:</strong> ${eventProps.type || ''}</div>
${eventProps.langue ? `<div class="mb-1"><strong>Langue:</strong> ${eventProps.langue}</div>` : ''}
${eventProps.beneficiaire ? `<div class="mb-1"><strong>Bénéficiaire:</strong> ${typeof eventProps.beneficiaire === 'string' ? eventProps.beneficiaire : getEntityDisplayName(eventProps.beneficiaire, 'beneficiaire')}</div>` : ''}
${eventProps.intervenant_nom ? `<div class="mb-1"><strong>Intervenant:</strong> ${eventProps.intervenant_nom}</div>` : ''}
${eventProps.local ? `<div class="mb-1"><strong>Local:</strong> ${typeof eventProps.local === 'string' ? eventProps.local : getEntityDisplayName(eventProps.local, 'local')}</div>` : ''}
${eventProps.commentaire && eventProps.show_comments ? `<div class="mb-1"><strong>Commentaire:</strong> ${eventProps.commentaire}</div>` : ''}
<div class="mb-1"><strong>Statut:</strong> ${eventProps.statut || ''}</div>
<div class="mt-2">
<small class="text-muted">Cliquez pour plus de détails</small>
</div>
</div>
`;
// Initialiser le popover Bootstrap
if (window.bootstrap && window.bootstrap.Popover) {
const popover = new bootstrap.Popover(eventEl, {
title: 'Détails de l\'événement',
content: popoverContent,
html: true,
trigger: 'hover',
placement: 'top',
container: 'body',
template: '<div class="popover event-popover" role="tooltip"><div class="popover-arrow"></div><h3 class="popover-header"></h3><div class="popover-body"></div></div>'
});
}
}
});
calendar.render();
window.currentColleaguesCalendar = calendar;
const loadingIndicator = document.getElementById('loading-indicator-colleagues');
if (loadingIndicator) {
loadingIndicator.style.display = 'none';
}
initializeColleaguesFilters(calendar);
return calendar;
}
/**
* Collecte les filtres depuis le formulaire "Mon agenda"
*/
function collectIntervenantFilters() {
const filters = {};
const local = document.getElementById('local')?.value;
if (local) filters.local_id = parseInt(local);
const beneficiaire = document.getElementById('beneficiaire')?.value;
if (beneficiaire) filters.beneficiaire_id = parseInt(beneficiaire);
const typeRdv = document.getElementById('type_rdv')?.value;
if (typeRdv) filters.type_rdv = typeRdv;
const typeIntervention = document.getElementById('type_intervention')?.value;
if (typeIntervention) filters.type_intervention = parseInt(typeIntervention);
const langue = document.getElementById('langue')?.value;
if (langue) filters.langue = langue;
const permanences = document.getElementById('permanences_non_assignees')?.checked;
if (permanences) filters.permanences_non_assignees = true;
return filters;
}
/**
* Collecte les filtres depuis le formulaire "Agenda des collègues"
*/
function collectColleaguesFilters() {
const filters = {};
const local = document.getElementById('local-colleagues')?.value;
if (local) filters.local_id = parseInt(local);
const beneficiaire = document.getElementById('beneficiaire-colleagues')?.value;
if (beneficiaire) filters.beneficiaire_id = parseInt(beneficiaire);
const typeRdv = document.getElementById('type_rdv-colleagues')?.value;
if (typeRdv) filters.type_rdv = typeRdv;
const typeIntervention = document.getElementById('type_intervention-colleagues')?.value;
if (typeIntervention) filters.type_intervention = parseInt(typeIntervention);
const langue = document.getElementById('langue-colleagues')?.value;
if (langue) filters.langue = langue;
return filters;
}
/**
* Initialise les filtres pour "Mon agenda"
*/
function initializeIntervenantFilters(calendar) {
// Charger les options de filtres depuis l'endpoint disponibilités
loadFilterOptions('intervenant');
// Écouter les changements de filtres
const filterForm = document.querySelector('.filters');
if (filterForm) {
const filterBtn = document.getElementById('filterBtn');
if (filterBtn) {
filterBtn.addEventListener('click', () => {
calendar.refetchEvents();
});
}
const resetBtn = document.getElementById('resetFiltersBtn');
if (resetBtn) {
resetBtn.addEventListener('click', () => {
filterForm.reset();
calendar.refetchEvents();
});
}
// Bouton "Ajouter un événement"
const addEventBtn = document.getElementById('addEventBtn');
if (addEventBtn) {
addEventBtn.addEventListener('click', () => {
// Vérifier les permissions de création
const userCanCreate = window.crviPermissions && window.crviPermissions.can_create;
if (!userCanCreate) {
notifyError('Vous n\'êtes pas autorisé à créer des événements');
console.warn('❌ Utilisateur non autorisé à créer des événements (permissions WP)');
return;
}
// Ouvrir le modal de création avec la date du jour et l'intervenant prérempli
const today = new Date();
const dateStr = today.toISOString().split('T')[0];
const startStr = dateStr + 'T09:00:00';
const endStr = dateStr + 'T10:00:00';
// Préremplir avec l'intervenant actuel (utilisateur connecté)
const currentUserId = window.crviPermissions && window.crviPermissions.user_id;
openModal('create', {
start: new Date(startStr),
end: new Date(endStr),
startStr: startStr,
endStr: endStr,
allDay: false,
extendedProps: {
id_intervenant: currentUserId
}
});
});
}
// Auto-filtrer sur changement
const selects = filterForm.querySelectorAll('select, input[type="checkbox"]');
selects.forEach(el => {
el.addEventListener('change', () => {
setTimeout(() => calendar.refetchEvents(), 300);
});
});
}
}
/**
* Initialise les filtres pour "Agenda des collègues"
*/
function initializeColleaguesFilters(calendar) {
loadFilterOptions('colleagues');
const filterForm = document.querySelector('.filters-colleagues');
if (filterForm) {
const filterBtn = document.getElementById('filterBtn-colleagues');
if (filterBtn) {
filterBtn.addEventListener('click', () => {
calendar.refetchEvents();
});
}
const resetBtn = document.getElementById('resetFiltersBtn-colleagues');
if (resetBtn) {
resetBtn.addEventListener('click', () => {
filterForm.reset();
calendar.refetchEvents();
});
}
const selects = filterForm.querySelectorAll('select');
selects.forEach(el => {
el.addEventListener('change', () => {
setTimeout(() => calendar.refetchEvents(), 300);
});
});
}
}
/**
* Charge les options des filtres depuis l'endpoint disponibilités
*/
async function loadFilterOptions(mode = 'intervenant') {
try {
const disponibilites = await apiFetch('agenda/disponibilites');
const prefix = mode === 'colleagues' ? '-colleagues' : '';
// Locaux
const localSelect = document.getElementById(`local${prefix}`);
if (localSelect && disponibilites.locaux) {
disponibilites.locaux.forEach(local => {
const option = document.createElement('option');
option.value = local.id;
option.textContent = local.nom;
localSelect.appendChild(option);
});
}
// Bénéficiaires
const beneficiaireSelect = document.getElementById(`beneficiaire${prefix}`);
if (beneficiaireSelect && disponibilites.beneficiaires) {
disponibilites.beneficiaires.forEach(benef => {
const option = document.createElement('option');
option.value = benef.id;
option.textContent = benef.nom;
beneficiaireSelect.appendChild(option);
});
}
// Types d'intervention
const typeInterventionSelect = document.getElementById(`type_intervention${prefix}`);
if (typeInterventionSelect && disponibilites.types_intervention) {
disponibilites.types_intervention.forEach(type => {
const option = document.createElement('option');
option.value = type.id;
option.textContent = type.nom;
typeInterventionSelect.appendChild(option);
});
}
// Langues
const langueSelect = document.getElementById(`langue${prefix}`);
if (langueSelect && disponibilites.langues) {
disponibilites.langues.forEach(langue => {
const option = document.createElement('option');
option.value = langue.id;
option.textContent = langue.nom;
langueSelect.appendChild(option);
});
}
// Initialiser Select2
if (window.jQuery && jQuery().select2) {
jQuery(`select${prefix ? '[id$="-colleagues"]' : ':not([id$="-colleagues"])'}`).select2();
}
} catch (error) {
console.error('Erreur lors du chargement des options de filtres:', error);
}
}
/**
* Détermine la couleur d'un événement selon son type/statut
*/
function getEventColor(event) {
if (event.type === 'permanence') {
return '#17a2b8'; // Teal pour les permanences
}
// Couleur selon le statut
switch (event.statut) {
case 'prevu':
return '#28a745'; // Vert
case 'present':
return '#0d6efd'; // Bleu
case 'absent':
return '#dc3545'; // Rouge
case 'cloture':
return '#6c757d'; // Gris
default:
return '#ffc107'; // Jaune
}
}
/**
* Ouvre le modal complet (eventModal) en mode vue, comme côté admin.
* Charge d'abord les détails complets de l'événement via l'API.
*/
async function openIntervenantEventModal(event) {
const userCanView = window.crviPermissions && window.crviPermissions.can_view;
if (!userCanView) {
console.warn('❌ Utilisateur non autorisé à voir les événements (permissions WP)');
notifyError('Vous n\'êtes pas autorisé à consulter les événements');
return;
}
try {
// Overlay sur le calendrier pendant le chargement des détails
const calendarEl = document.getElementById('agenda-calendar') || document.getElementById('agenda-calendar-colleagues');
if (window.CRVI_OVERLAY && calendarEl) { window.CRVI_OVERLAY.show(calendarEl); }
const eventId = event.id;
const eventDetails = await getEvent(eventId);
let startDate;
let endDate;
try {
// Date de début
if (eventDetails.date_rdv && eventDetails.heure_rdv) {
startDate = new Date(eventDetails.date_rdv + 'T' + eventDetails.heure_rdv);
if (isNaN(startDate.getTime())) {
startDate = new Date();
}
} else {
startDate = new Date(event.start);
}
// Date de fin
if (eventDetails.date_fin && eventDetails.heure_fin) {
endDate = new Date(eventDetails.date_fin + 'T' + eventDetails.heure_fin);
if (isNaN(endDate.getTime())) {
endDate = startDate;
}
} else {
endDate = event.end ? new Date(event.end) : startDate;
}
} catch (e) {
console.error('Erreur lors de la création des dates (intervenant):', e);
startDate = new Date();
endDate = new Date();
}
const fullEvent = {
id: eventDetails.id,
start: startDate,
end: endDate,
extendedProps: {
// Données de base
type: eventDetails.type,
commentaire: eventDetails.commentaire,
date: eventDetails.date_rdv,
heure: eventDetails.heure_rdv,
date_fin: eventDetails.date_fin,
heure_fin: eventDetails.heure_fin,
// Relations
id_beneficiaire: eventDetails.id_beneficiaire,
id_intervenant: eventDetails.id_intervenant,
id_traducteur: eventDetails.id_traducteur,
id_local: eventDetails.id_local,
langue: eventDetails.langue,
langues_disponibles: eventDetails.langues_disponibles || null,
beneficiaire: eventDetails.beneficiaire,
intervenant: eventDetails.intervenant,
traducteur: eventDetails.traducteur,
local: eventDetails.local,
// Données groupe
nb_participants: eventDetails.nb_participants,
nb_hommes: eventDetails.nb_hommes,
nb_femmes: eventDetails.nb_femmes,
// Autres
statut: eventDetails.statut,
created_at: eventDetails.created_at,
updated_at: eventDetails.updated_at
}
};
// Ouvrir le modal complet en mode "view" (même pattern qu'en admin)
openModal('view', fullEvent);
} catch (error) {
console.error('Erreur lors du chargement des détails de l\'événement (intervenant):', error);
notifyError('Erreur lors du chargement des détails de l\'événement');
} finally {
const calendarEl = document.getElementById('agenda-calendar') || document.getElementById('agenda-calendar-colleagues');
if (window.CRVI_OVERLAY && calendarEl) { window.CRVI_OVERLAY.hide(calendarEl); }
}
}
/**
* Retourne la classe Bootstrap pour le badge de statut
*/
function getStatusBadgeClass(statut) {
switch (statut) {
case 'prevu':
return 'success';
case 'present':
return 'primary';
case 'absent':
return 'danger';
case 'cloture':
return 'secondary';
case 'annule':
return 'warning';
default:
return 'secondary';
}
}
/**
* Initialise les onglets pour basculer entre mon agenda et agenda des collègues
*/
export function initializeIntervenantAgendaTabs() {
const myAgendaTab = document.getElementById('my-agenda-tab');
const colleaguesTab = document.getElementById('colleagues-agenda-tab');
let myCalendar = null;
let colleaguesCalendar = null;
if (myAgendaTab) {
myAgendaTab.addEventListener('shown.bs.tab', () => {
console.log('🔄 Basculement vers Mon Agenda (mode: mine)');
if (!myCalendar) {
myCalendar = initializeIntervenantCalendar();
} else {
myCalendar.refetchEvents();
}
});
}
if (colleaguesTab) {
colleaguesTab.addEventListener('shown.bs.tab', () => {
console.log('🔄 Basculement vers Agenda des collègues (mode: colleagues)');
if (!colleaguesCalendar) {
colleaguesCalendar = initializeColleaguesCalendar();
} else {
colleaguesCalendar.refetchEvents();
}
});
}
// Initialiser le premier onglet (mon agenda) par défaut
if (myAgendaTab && myAgendaTab.classList.contains('active')) {
myCalendar = initializeIntervenantCalendar();
}
}

View File

@ -0,0 +1,198 @@
/**
* Module Hub Intervenant
* Gestion des RDV du jour et marquage présence/absence
*/
import { apiFetch } from './agenda-api.js';
import toastr from 'toastr';
/**
* Charge les RDV du jour de l'intervenant connecté
*/
export async function loadRdvToday() {
const container = document.getElementById('rdv-today-container');
const emptyMessage = document.getElementById('rdv-today-empty');
if (!container) {
console.error('Container rdv-today-container non trouvé');
return;
}
try {
if (window.CRVI_OVERLAY) { window.CRVI_OVERLAY.show(container); }
container.innerHTML = `
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
<p class="mt-2 text-muted">Chargement des rendez-vous...</p>
</div>
`;
const rdvs = await apiFetch('intervenant/rdv-today');
if (!rdvs || rdvs.length === 0) {
container.style.display = 'none';
if (emptyMessage) {
emptyMessage.style.display = 'block';
}
return;
}
container.style.display = 'block';
if (emptyMessage) {
emptyMessage.style.display = 'none';
}
// Récupérer le template
const template = document.getElementById('rdv-item-template');
if (!template) {
console.error('Template rdv-item-template non trouvé');
return;
}
container.innerHTML = '';
rdvs.forEach(rdv => {
const clone = template.content.cloneNode(true);
// Formatage de l'heure
const heureDebut = formatTime(rdv.heure_rdv);
const heureFin = formatTime(rdv.heure_fin || rdv.heure_rdv);
// Remplir les données
const timeStartEl = clone.querySelector('[data-time-start]');
const timeEndEl = clone.querySelector('[data-time-end]');
const localEl = clone.querySelector('[data-local]');
const beneficiaireEl = clone.querySelector('[data-beneficiaire]');
const typeInterventionEl = clone.querySelector('[data-type-intervention]');
const presentBtn = clone.querySelector('button.mark-presence[data-statut="present"]');
const absentBtn = clone.querySelector('button.mark-presence[data-statut="absent"]');
if (timeStartEl) timeStartEl.textContent = heureDebut;
if (timeEndEl) timeEndEl.textContent = heureFin;
if (localEl && rdv.local) {
localEl.textContent = rdv.local.nom || 'Non assigné';
}
if (beneficiaireEl && rdv.beneficiaire) {
const nom = rdv.beneficiaire.prenom && rdv.beneficiaire.nom
? `${rdv.beneficiaire.prenom} ${rdv.beneficiaire.nom}`
: (rdv.beneficiaire.nom || 'Non assigné');
beneficiaireEl.textContent = nom;
}
if (typeInterventionEl) {
typeInterventionEl.textContent = rdv.type_intervention || '';
}
// Configurer les boutons présence/absence
if (presentBtn) {
presentBtn.setAttribute('data-event-id', rdv.id);
presentBtn.addEventListener('click', () => markPresence(rdv.id, 'present'));
}
if (absentBtn) {
absentBtn.setAttribute('data-event-id', rdv.id);
absentBtn.addEventListener('click', () => markPresence(rdv.id, 'absent'));
}
// Désactiver les boutons si le statut n'est plus "prevu"
if (rdv.statut !== 'prevu') {
if (presentBtn) presentBtn.disabled = true;
if (absentBtn) absentBtn.disabled = true;
}
container.appendChild(clone);
});
} catch (error) {
console.error('Erreur lors du chargement des RDV du jour:', error);
container.innerHTML = `
<div class="alert alert-danger">
<i class="fas fa-exclamation-triangle me-2"></i>
Erreur lors du chargement des rendez-vous : ${error.message}
</div>
`;
} finally {
if (window.CRVI_OVERLAY) { window.CRVI_OVERLAY.hide(container); }
}
}
/**
* Formate une heure (format HH:MM:SS) en HH:MM
*/
function formatTime(timeStr) {
if (!timeStr) return '';
return timeStr.substring(0, 5); // Prendre HH:MM
}
/**
* Marque la présence ou l'absence pour un RDV
*/
async function markPresence(eventId, statut) {
const statutLabel = statut === 'present' ? 'présent' : 'absent';
// Demander confirmation pour l'absence
if (statut === 'absent') {
const confirmed = confirm(`Êtes-vous sûr de vouloir marquer ce rendez-vous comme absent ?`);
if (!confirmed) {
return;
}
}
try {
// Désactiver les boutons pendant la requête
const buttons = document.querySelectorAll(`button[data-event-id="${eventId}"]`);
buttons.forEach(btn => btn.disabled = true);
const container = document.getElementById('rdv-today-container');
if (window.CRVI_OVERLAY && container) { window.CRVI_OVERLAY.show(container); }
await apiFetch('intervenant/mark-presence', {
method: 'POST',
body: JSON.stringify({
event_id: eventId,
statut: statut
})
});
toastr.success(
`Présence marquée comme ${statutLabel} avec succès`,
'Confirmation',
{ timeOut: 3000 }
);
// Recharger les RDV du jour
await loadRdvToday();
} catch (error) {
console.error('Erreur lors du marquage de présence:', error);
toastr.error(
error.message || `Erreur lors du marquage ${statutLabel}`,
'Erreur',
{ timeOut: 5000 }
);
// Réactiver les boutons en cas d'erreur
const buttons = document.querySelectorAll(`button[data-event-id="${eventId}"]`);
buttons.forEach(btn => btn.disabled = false);
} finally {
const container = document.getElementById('rdv-today-container');
if (window.CRVI_OVERLAY && container) { window.CRVI_OVERLAY.hide(container); }
}
}
/**
* Initialise le module hub intervenant
*/
export function initializeIntervenantHub() {
console.log('Initialisation du hub intervenant...');
// Charger les RDV du jour au chargement
loadRdvToday();
// Recharger toutes les 5 minutes
setInterval(loadRdvToday, 5 * 60 * 1000);
}

View File

@ -0,0 +1,575 @@
/**
* Module Permanences Intervenant
* Gestion du formulaire d'encodage des permanences
*/
import { apiFetch } from './agenda-api.js';
import toastr from 'toastr';
/**
* Initialise le module permanences intervenant
*/
export function initializePermanences() {
console.log('🚀 Initialisation du module permanences intervenant...');
const form = document.getElementById('permanences-form');
if (!form) {
console.error('Formulaire permanences-form non trouvé');
return;
}
// Initialiser le champ mois de début avec le mois actuel
const moisDebutInput = document.getElementById('mois-debut');
if (moisDebutInput && !moisDebutInput.value) {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
moisDebutInput.value = `${year}-${month}`;
}
// Initialiser Select2 pour le champ langues
initializeSelect2();
// Écouter les changements pour mettre à jour l'aperçu
setupPreviewListeners();
// Gérer la soumission du formulaire
form.addEventListener('submit', handleFormSubmit);
// Calculer l'aperçu initial
updatePreview();
// Initialiser le formulaire d'import CSV
initializeCsvImport();
}
/**
* Initialise Select2 pour le champ de sélection des langues
*/
function initializeSelect2() {
const languesSelect = document.getElementById('langues-permanences');
if (languesSelect && typeof jQuery !== 'undefined' && jQuery.fn.select2) {
jQuery(languesSelect).select2({
placeholder: 'Sélectionnez une ou plusieurs langues',
allowClear: true,
width: '100%',
language: {
noResults: function() {
return "Aucun résultat trouvé";
}
}
});
}
}
/**
* Configure les listeners pour mettre à jour l'aperçu en temps réel
*/
function setupPreviewListeners() {
// Écouter les changements de mois de début
const moisDebut = document.getElementById('mois-debut');
if (moisDebut) {
moisDebut.addEventListener('change', updatePreview);
moisDebut.addEventListener('input', updatePreview);
}
// Écouter les changements de période
const periodInputs = document.querySelectorAll('input[name="periode"]');
periodInputs.forEach(input => {
input.addEventListener('change', updatePreview);
});
// Écouter les changements de jours
const joursInputs = document.querySelectorAll('input[name="jours[]"]');
joursInputs.forEach(input => {
input.addEventListener('change', updatePreview);
});
// Écouter les changements des heures sélectionnées
const heuresInputs = document.querySelectorAll('input[name="heures[]"]');
heuresInputs.forEach(input => {
input.addEventListener('change', updatePreview);
});
}
/**
* Calcule et affiche l'aperçu des permanences qui seront créées
*/
function updatePreview() {
const periode = parseInt(document.querySelector('input[name="periode"]:checked')?.value || '3');
const joursChecked = Array.from(document.querySelectorAll('input[name="jours[]"]:checked'))
.map(input => input.value);
const heuresChecked = Array.from(document.querySelectorAll('input[name="heures[]"]:checked'))
.map(input => input.value)
.sort(); // Trier les heures pour un affichage cohérent
const moisDebut = document.getElementById('mois-debut')?.value;
if (heuresChecked.length === 0) {
clearPreview();
return;
}
if (!moisDebut) {
clearPreview();
return;
}
if (joursChecked.length === 0) {
clearPreview();
return;
}
// Calculer les tranches horaires à partir des heures sélectionnées
// Chaque heure sélectionnée = 1 tranche d'1 heure
const tranches = heuresChecked.map(heureDebut => {
const [h, m] = heureDebut.split(':').map(Number);
const heureFin = `${String(h + 1).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
return {
debut: heureDebut,
fin: heureFin
};
});
// Calculer les dates à partir du mois de début sélectionné
const [year, month] = moisDebut.split('-').map(Number);
const startDate = new Date(year, month - 1, 1); // Premier jour du mois sélectionné
const endDate = new Date(year, month - 1 + periode, 0); // Dernier jour du mois de fin
// Compter les occurrences pour chaque jour sélectionné
let totalEvents = 0;
const joursMapping = {
'lundi': 1,
'mardi': 2,
'mercredi': 3,
'jeudi': 4,
'vendredi': 5,
'samedi': 6,
'dimanche': 0
};
const currentDate = new Date(startDate);
while (currentDate <= endDate) {
const dayOfWeek = currentDate.getDay();
const jourName = Object.keys(joursMapping).find(k => joursMapping[k] === dayOfWeek);
if (joursChecked.includes(jourName)) {
totalEvents += tranches.length;
}
currentDate.setDate(currentDate.getDate() + 1);
}
// Afficher l'aperçu
displayPreview({
periode: periode,
jours: joursChecked,
tranches: tranches,
totalEvents: totalEvents,
dateDebut: startDate,
dateFin: endDate
});
}
/**
* Calcule les tranches horaires (par tranche de 1 heure)
*/
function calculateTranches(debut, fin) {
const tranches = [];
const [debutHour, debutMin] = debut.split(':').map(Number);
const [finHour, finMin] = fin.split(':').map(Number);
let currentHour = debutHour;
let currentMin = debutMin;
while (currentHour < finHour || (currentHour === finHour && currentMin < finMin)) {
const trancheDebut = `${String(currentHour).padStart(2, '0')}:${String(currentMin).padStart(2, '0')}`;
// Ajouter 1 heure
currentHour++;
if (currentHour >= 24) {
break; // Ne pas dépasser minuit
}
const trancheFin = `${String(currentHour).padStart(2, '0')}:${String(currentMin).padStart(2, '0')}`;
tranches.push({
debut: trancheDebut,
fin: trancheFin
});
}
return tranches;
}
/**
* Affiche l'aperçu des permanences
*/
function displayPreview(data) {
const container = document.getElementById('preview-container');
if (!container) return;
const joursLabels = {
'lundi': 'Lundi',
'mardi': 'Mardi',
'mercredi': 'Mercredi',
'jeudi': 'Jeudi',
'vendredi': 'Vendredi',
'samedi': 'Samedi',
'dimanche': 'Dimanche'
};
const joursDisplay = data.jours.map(j => joursLabels[j] || j).join(', ');
const dateDebutStr = data.dateDebut.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' });
const dateFinStr = data.dateFin.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' });
container.innerHTML = `
<div class="alert alert-info">
<h5 class="alert-heading">
<i class="fas fa-info-circle me-2"></i>Aperçu des permanences
</h5>
<hr>
<p class="mb-2">
<strong>Période :</strong> ${data.periode} mois (du ${dateDebutStr} au ${dateFinStr})
</p>
<p class="mb-2">
<strong>Jours sélectionnés :</strong> ${joursDisplay || 'Aucun'}
</p>
<p class="mb-2">
<strong>Tranches horaires :</strong>
${data.tranches.length > 0
? data.tranches.map(t => `${t.debut} - ${t.fin}`).join(', ')
: 'Aucune tranche valide'}
</p>
<p class="mb-0">
<strong class="text-primary">Nombre total d'événements à créer : ${data.totalEvents}</strong>
</p>
</div>
`;
}
/**
* Efface l'aperçu
*/
function clearPreview() {
const container = document.getElementById('preview-container');
if (!container) return;
container.innerHTML = `
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>
Veuillez sélectionner au moins un jour et une plage horaire valide pour voir l'aperçu.
</div>
`;
}
/**
* Gère la soumission du formulaire
*/
async function handleFormSubmit(event) {
event.preventDefault();
const formElement = document.getElementById('permanences-form');
// Récupérer les données
const periode = parseInt(document.querySelector('input[name="periode"]:checked')?.value || '3');
const moisDebut = document.getElementById('mois-debut')?.value;
const jours = Array.from(document.querySelectorAll('input[name="jours[]"]:checked'))
.map(input => input.value);
const heures = Array.from(document.querySelectorAll('input[name="heures[]"]:checked'))
.map(input => input.value);
const dureePermanence = document.querySelector('input[name="duree_permanence"]:checked')?.value || '1h';
const nbTranches = dureePermanence === '15min' ? parseInt(document.getElementById('nb-tranches')?.value || '1') : null;
// Récupérer les langues sélectionnées via Select2
const languesSelect = document.getElementById('langues-permanences');
const langues = languesSelect && typeof jQuery !== 'undefined' && jQuery(languesSelect).val()
? jQuery(languesSelect).val().filter(value => value !== '')
: [];
const informationsComplementaires = document.getElementById('informations-complementaires')?.value || '';
// Validation
if (!moisDebut) {
toastr.warning('Veuillez sélectionner un mois de début', 'Validation');
return;
}
if (jours.length === 0) {
toastr.warning('Veuillez sélectionner au moins un jour', 'Validation');
return;
}
if (heures.length === 0) {
toastr.warning('Veuillez sélectionner au moins une heure de permanence', 'Validation');
return;
}
// Calculer les tranches à partir des heures sélectionnées
const tranches = heures.map(heureDebut => {
const [h, m] = heureDebut.split(':').map(Number);
const heureFin = `${String(h + 1).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
return {
debut: heureDebut,
fin: heureFin
};
});
// Calculer la plage horaire globale (min et max des heures sélectionnées)
const heuresSorted = heures.map(h => {
const [hour] = h.split(':').map(Number);
return hour;
}).sort((a, b) => a - b);
const heureMin = heuresSorted[0];
const heureMax = heuresSorted[heuresSorted.length - 1];
const plageDebut = `${String(heureMin).padStart(2, '0')}:00`;
const plageFin = `${String(heureMax + 1).padStart(2, '0')}:00`;
// Calculer les dates à partir du mois de début pour l'estimation
const [year, month] = moisDebut.split('-').map(Number);
const startDate = new Date(year, month - 1, 1);
const endDate = new Date(year, month - 1 + periode, 0);
// Demander confirmation si beaucoup d'événements
const estimatedEvents = estimateTotalEvents(startDate, endDate, jours, tranches);
if (estimatedEvents > 50) {
const confirmed = confirm(
`Vous allez créer environ ${estimatedEvents} événements. Êtes-vous sûr de vouloir continuer ?`
);
if (!confirmed) {
return;
}
}
try {
// Désactiver le formulaire
const submitBtn = event.target.querySelector('button[type="submit"]');
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Création en cours...';
}
if (window.CRVI_OVERLAY && formElement) { window.CRVI_OVERLAY.show(formElement); }
// Préparer les données
const data = {
periode: periode,
mois_debut: moisDebut,
jours: jours,
plage_horaire: {
debut: plageDebut,
fin: plageFin
},
heures: heures, // Envoyer aussi les heures individuelles pour le backend
duree_permanence: dureePermanence, // '1h' ou '15min'
nb_tranches: nbTranches, // Nombre de tranches si 15min (1-4), null si 1h
langues: langues, // Langues sélectionnées (peut être un tableau vide)
informations_complementaires: informationsComplementaires
};
// Envoyer à l'API
const result = await apiFetch('intervenant/permanences', {
method: 'POST',
body: JSON.stringify(data)
});
toastr.success(
`${result.events_created || 'Les'} permanence(s) ont été créées avec succès`,
'Succès',
{ timeOut: 5000 }
);
// Réinitialiser le formulaire
event.target.reset();
clearPreview();
// Recalculer l'aperçu avec les valeurs par défaut
setTimeout(updatePreview, 100);
} catch (error) {
console.error('Erreur lors de la création des permanences:', error);
toastr.error(
error.message || 'Erreur lors de la création des permanences',
'Erreur',
{ timeOut: 5000 }
);
} finally {
// Réactiver le formulaire
const submitBtn = event.target.querySelector('button[type="submit"]');
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="fas fa-save me-2"></i>Créer les permanences';
}
if (window.CRVI_OVERLAY && formElement) { window.CRVI_OVERLAY.hide(formElement); }
}
}
/**
* Estime le nombre total d'événements qui seront créés
*/
function estimateTotalEvents(startDate, endDate, jours, tranches) {
const joursMapping = {
'lundi': 1,
'mardi': 2,
'mercredi': 3,
'jeudi': 4,
'vendredi': 5,
'samedi': 6,
'dimanche': 0
};
let total = 0;
const currentDate = new Date(startDate);
while (currentDate <= endDate) {
const dayOfWeek = currentDate.getDay();
const jourName = Object.keys(joursMapping).find(k => joursMapping[k] === dayOfWeek);
if (jours.includes(jourName)) {
total += tranches.length;
}
currentDate.setDate(currentDate.getDate() + 1);
}
return total;
}
/**
* Initialise le formulaire d'import CSV
*/
function initializeCsvImport() {
const csvForm = document.getElementById('import-csv-form');
if (!csvForm) {
console.warn('Formulaire import-csv-form non trouvé');
return;
}
csvForm.addEventListener('submit', handleCsvImportSubmit);
}
/**
* Gère la soumission du formulaire d'import CSV
*/
async function handleCsvImportSubmit(e) {
e.preventDefault();
const form = e.target;
const fileInput = document.getElementById('csv-file');
const submitBtn = document.getElementById('submit-csv-import-btn');
const resultContainer = document.getElementById('csv-import-result');
const csvForm = document.getElementById('import-csv-form');
if (!fileInput || !fileInput.files || fileInput.files.length === 0) {
toastr.error('Veuillez sélectionner un fichier CSV', 'Erreur');
return;
}
const file = fileInput.files[0];
// Vérifier que c'est un fichier CSV
if (!file.name.toLowerCase().endsWith('.csv')) {
toastr.error('Le fichier doit être au format CSV', 'Erreur');
return;
}
// Désactiver le bouton
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Import en cours...';
// Créer FormData pour l'upload
const formData = new FormData();
formData.append('file', file);
try {
// Appel API pour l'import CSV
const url = '/wp-json/crvi/v1/intervenant/permanences/import-csv';
if (window.CRVI_OVERLAY && csvForm) { window.CRVI_OVERLAY.show(csvForm); }
const response = await fetch(url, {
method: 'POST',
headers: {
'X-WP-Nonce': window.wpApiSettings?.nonce || ''
},
body: formData
});
const data = await response.json();
if (!response.ok || !data.success) {
const errorMessage = data.error?.message || data.message || 'Erreur lors de l\'import';
throw new Error(errorMessage);
}
// Afficher les résultats
displayCsvImportResult(data.data, resultContainer);
toastr.success(
`Import réussi : ${data.data.created || 0} créés, ${data.data.errors?.length || 0} erreurs`,
'Succès',
{ timeOut: 5000 }
);
// Réinitialiser le formulaire
form.reset();
} catch (error) {
console.error('Erreur lors de l\'import CSV:', error);
toastr.error(error.message || 'Une erreur est survenue lors de l\'import CSV', 'Erreur');
if (resultContainer) {
resultContainer.innerHTML = `
<div class="alert alert-danger">
<i class="fas fa-exclamation-circle me-2"></i>
<strong>Erreur :</strong> ${error.message || 'Une erreur est survenue lors de l\'import'}
</div>
`;
resultContainer.style.display = 'block';
}
} finally {
// Réactiver le bouton
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="fas fa-upload me-2"></i>Importer le CSV';
if (window.CRVI_OVERLAY && csvForm) { window.CRVI_OVERLAY.hide(csvForm); }
}
}
/**
* Affiche les résultats de l'import CSV
*/
function displayCsvImportResult(result, container) {
if (!container) return;
let html = '';
if (result.errors && result.errors.length > 0) {
html += `
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>
<strong>Attention :</strong> ${result.errors.length} erreur(s) détectée(s)
</div>
`;
if (result.errors.length <= 10) {
html += '<ul class="list-group mt-2">';
result.errors.forEach((error, index) => {
html += `
<li class="list-group-item">
<strong>Ligne ${error.line || '?'} :</strong> ${error.message || 'Erreur inconnue'}
</li>
`;
});
html += '</ul>';
}
}
if (result.created > 0) {
html += `
<div class="alert alert-success mt-3">
<i class="fas fa-check-circle me-2"></i>
<strong>Succès :</strong> ${result.created} permanence(s) créée(s) avec succès
</div>
`;
}
container.innerHTML = html;
container.style.display = 'block';
}

View File

@ -0,0 +1,608 @@
/**
* Module Profil Intervenant
* Gestion du formulaire de profil et mise à jour des informations
*/
import { apiFetch } from './agenda-api.js';
import toastr from 'toastr';
// Variables globales pour gérer les indisponibilités
let indisponibilitesList = [];
let indisponibiliteCounter = 0;
/**
* Initialise le module profil intervenant
*/
export function initializeProfile() {
console.log('🚀 Initialisation du module profil intervenant...');
// Charger les données du profil
loadProfile();
// Écouter les événements du formulaire principal
const profileForm = document.getElementById('profile-form');
if (profileForm) {
profileForm.addEventListener('submit', handleProfileSubmit);
}
const cancelBtn = document.getElementById('cancel-profile-btn');
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
loadProfile(); // Recharger pour annuler les modifications
});
}
// Écouter les événements du formulaire de disponibilités
const disponibilitesForm = document.getElementById('disponibilites-form');
if (disponibilitesForm) {
disponibilitesForm.addEventListener('submit', handleDisponibilitesSubmit);
}
// Gestion de l'ajout d'indisponibilité
const addIndispoBtn = document.getElementById('add-indisponibilite-btn');
if (addIndispoBtn) {
addIndispoBtn.addEventListener('click', showIndisponibiliteForm);
}
const saveIndispoBtn = document.getElementById('save-indisponibilite-btn');
if (saveIndispoBtn) {
saveIndispoBtn.addEventListener('click', saveIndisponibilite);
}
const cancelIndispoBtn = document.getElementById('cancel-indisponibilite-btn');
if (cancelIndispoBtn) {
cancelIndispoBtn.addEventListener('click', hideIndisponibiliteForm);
}
}
/**
* Charge les données du profil depuis l'API
*/
async function loadProfile() {
try {
const profileContainer = document.getElementById('intervenant-profile-container') || document.getElementById('profile-form') || document.body;
if (window.CRVI_OVERLAY && profileContainer) { window.CRVI_OVERLAY.show(profileContainer); }
const profile = await apiFetch('intervenant/profile');
// Remplir les champs non modifiables
const nomEl = document.getElementById('profile-nom');
const prenomEl = document.getElementById('profile-prenom');
const emailEl = document.getElementById('profile-email');
if (nomEl) nomEl.value = profile.nom || '';
if (prenomEl) prenomEl.value = profile.prenom || '';
if (emailEl) emailEl.value = profile.email || '';
// Téléphone (modifiable)
const telephoneEl = document.getElementById('profile-telephone');
if (telephoneEl) {
telephoneEl.value = profile.telephone || '';
}
// Jours de disponibilité (checkboxes) - déjà générés en PHP, on met juste à jour les valeurs
updateJoursDisponibiliteCheckboxes(profile.jours_de_disponibilite || []);
// Heures de permanences (checkboxes)
updateHeuresPermanencesCheckboxes(profile.heures_de_permanences || []);
// Indisponibilités ponctuelles - déjà générées en PHP, on synchronise juste la liste
indisponibilitesList = profile.indisponibilites_ponctuelles || [];
// Synchroniser la liste avec celle affichée en PHP (pour les mises à jour après sauvegarde)
syncIndisponibilitesList(indisponibilitesList);
// Rafraîchir explicitement l'affichage pour éviter tout écart visuel
displayIndisponibilites(indisponibilitesList);
// Départements (format: [{id, nom}, ...])
displayDepartements(profile.departements || []);
// Spécialisations (format: [{id, nom}, ...])
displaySpecialisations(profile.specialisations || []);
} catch (error) {
console.error('Erreur lors du chargement du profil:', error);
toastr.error(
error.message || 'Erreur lors du chargement du profil',
'Erreur',
{ timeOut: 5000 }
);
} finally {
const profileContainer = document.getElementById('intervenant-profile-container') || document.getElementById('profile-form') || document.body;
if (window.CRVI_OVERLAY && profileContainer) { window.CRVI_OVERLAY.hide(profileContainer); }
}
}
/**
* Met à jour les checkboxes des jours de disponibilité (déjà générées en PHP)
*/
function updateJoursDisponibiliteCheckboxes(joursActuels) {
const joursValues = ['lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi', 'dimanche'];
joursValues.forEach(jour => {
const checkbox = document.getElementById(`jour-${jour}`);
if (checkbox) {
checkbox.checked = joursActuels.includes(jour);
}
});
}
/**
* Met à jour les checkboxes des heures de permanences
*/
function updateHeuresPermanencesCheckboxes(heuresActuelles) {
const heuresValues = ['09:00','10:00','11:00','12:00','13:00','14:00','15:00','16:00'];
heuresValues.forEach(h => {
const checkbox = document.getElementById(`heure-${h}`);
if (checkbox) {
checkbox.checked = Array.isArray(heuresActuelles) && heuresActuelles.includes(h);
}
});
}
/**
* Synchronise la liste JS des indisponibilités avec les données de l'API
* (les indisponibilités sont déjà affichées en PHP, on synchronise juste la liste interne)
*/
function syncIndisponibilitesList(indisponibilites) {
// La liste est déjà affichée en PHP, on synchronise juste la liste JS
// pour les opérations d'ajout/suppression
indisponibilitesList = indisponibilites || [];
// Mettre à jour l'affichage si des indisponibilités ont été ajoutées/supprimées
const container = document.getElementById('profile-indisponibilites-list');
const emptyContainer = document.getElementById('profile-indisponibilites-empty');
if (!container) return;
// Si la liste est vide, afficher le message
if (indisponibilitesList.length === 0) {
const existingItems = container.querySelectorAll('.indisponibilite-item');
existingItems.forEach(item => item.remove());
if (emptyContainer) {
emptyContainer.style.display = 'block';
}
} else {
if (emptyContainer) {
emptyContainer.style.display = 'none';
}
}
}
/**
* Affiche les indisponibilités ponctuelles (utilisée après ajout/suppression)
*/
function displayIndisponibilites(indisponibilites) {
const container = document.getElementById('profile-indisponibilites-list');
const emptyContainer = document.getElementById('profile-indisponibilites-empty');
if (!container) return;
if (!indisponibilites || indisponibilites.length === 0) {
container.innerHTML = '';
if (emptyContainer) {
emptyContainer.style.display = 'block';
}
return;
}
if (emptyContainer) {
emptyContainer.style.display = 'none';
}
// Types d'indisponibilité
const typesLabels = {
'conge': 'Congé',
'absence': 'Absence',
'maladie': 'Maladie'
};
container.innerHTML = indisponibilites.map((indisp, index) => {
const debut = indisp.debut || '';
const fin = indisp.fin || '';
const type = indisp.type || 'conge';
const typeLabel = typesLabels[type] || type;
const commentaire = indisp.commentaire || '';
// Badge classes selon type
let badgeClass = 'badge-indispo-unknown';
if (type === 'conge') badgeClass = 'badge-indispo-conge';
else if (type === 'absence') badgeClass = 'badge-indispo-absence';
else if (type === 'maladie') badgeClass = 'badge-indispo-maladie';
// Convertir les dates au format d/m/Y pour l'affichage
let debutFormatted = debut;
let finFormatted = fin;
// Si les dates sont au format YYYY-MM-DD, les convertir
if (debut && debut.match(/^\d{4}-\d{2}-\d{2}$/)) {
const dateObj = new Date(debut);
debutFormatted = dateObj.toLocaleDateString('fr-FR');
}
if (fin && fin.match(/^\d{4}-\d{2}-\d{2}$/)) {
const dateObj = new Date(fin);
finFormatted = dateObj.toLocaleDateString('fr-FR');
}
// Afficher la période
let periodeText = '';
if (debutFormatted && finFormatted) {
if (debutFormatted === finFormatted) {
periodeText = `Le ${debutFormatted}`;
} else {
periodeText = `Du ${debutFormatted} au ${finFormatted}`;
}
} else if (debutFormatted) {
periodeText = `Le ${debutFormatted}`;
}
return `
<div class="indisponibilite-item indispo-${type}" data-index="${index}">
<div>
<strong>${periodeText}</strong>
<span class="badge ${badgeClass} ms-2">${typeLabel}</span>
${commentaire ? `<p class="mb-0 text-muted mt-1">${commentaire}</p>` : ''}
</div>
<button type="button" class="btn-remove" onclick="removeIndisponibilite(${index})" title="Supprimer">
<i class="fas fa-times"></i>
</button>
</div>
`;
}).join('');
}
// Fonction globale pour supprimer une indisponibilité
window.removeIndisponibilite = function(index) {
if (confirm('Êtes-vous sûr de vouloir supprimer cette indisponibilité ?')) {
indisponibilitesList.splice(index, 1);
displayIndisponibilites(indisponibilitesList);
}
};
/**
* Affiche le formulaire d'ajout d'indisponibilité
*/
function showIndisponibiliteForm() {
const container = document.getElementById('indisponibilite-form-container');
if (container) {
container.style.display = 'block';
// Ajouter les attributs required quand le formulaire est visible
const debutInput = document.getElementById('indispo-debut');
const finInput = document.getElementById('indispo-fin');
const typeSelect = document.getElementById('indispo-type');
if (debutInput) debutInput.setAttribute('required', 'required');
if (finInput) finInput.setAttribute('required', 'required');
if (typeSelect) typeSelect.setAttribute('required', 'required');
// Réinitialiser les champs
if (debutInput) debutInput.value = '';
if (finInput) finInput.value = '';
if (typeSelect) typeSelect.value = '';
const commentaireTextarea = document.getElementById('indispo-commentaire');
if (commentaireTextarea) commentaireTextarea.value = '';
}
}
/**
* Cache le formulaire d'ajout d'indisponibilité
*/
function hideIndisponibiliteForm() {
const container = document.getElementById('indisponibilite-form-container');
if (container) {
container.style.display = 'none';
// Retirer les attributs required quand le formulaire est caché
const debutInput = document.getElementById('indispo-debut');
const finInput = document.getElementById('indispo-fin');
const typeSelect = document.getElementById('indispo-type');
if (debutInput) debutInput.removeAttribute('required');
if (finInput) finInput.removeAttribute('required');
if (typeSelect) typeSelect.removeAttribute('required');
}
}
/**
* Sauvegarde une nouvelle indisponibilité dans la liste
*/
function saveIndisponibilite() {
const debut = document.getElementById('indispo-debut').value;
const fin = document.getElementById('indispo-fin').value;
const type = document.getElementById('indispo-type').value;
const commentaire = document.getElementById('indispo-commentaire').value.trim();
// Validation
if (!debut || !fin || !type) {
toastr.warning('Veuillez remplir tous les champs obligatoires', 'Validation');
return;
}
// Vérifier que la date de fin est après la date de début
if (new Date(fin) < new Date(debut)) {
toastr.warning('La date de fin doit être supérieure ou égale à la date de début', 'Validation');
return;
}
// Convertir les dates au format d/m/Y pour ACF
const debutFormatted = formatDateToDMY(debut);
const finFormatted = formatDateToDMY(fin);
// Ajouter à la liste
indisponibilitesList.push({
debut: debutFormatted,
fin: finFormatted,
type: type,
commentaire: commentaire
});
// Rafraîchir l'affichage
displayIndisponibilites(indisponibilitesList);
// Cacher le formulaire
hideIndisponibiliteForm();
toastr.success('Indisponibilité ajoutée. N\'oubliez pas d\'enregistrer.', 'Succès', { timeOut: 3000 });
}
/**
* Convertit une date au format YYYY-MM-DD vers d/m/Y
*/
function formatDateToDMY(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
return `${day}/${month}/${year}`;
}
/**
* Convertit une date au format d/m/Y vers YYYY-MM-DD
*/
function formatDateToYMD(dateStr) {
if (!dateStr) return '';
// Format d/m/Y vers YYYY-MM-DD
const parts = dateStr.split('/');
if (parts.length === 3) {
return `${parts[2]}-${parts[1]}-${parts[0]}`;
}
return dateStr;
}
/**
* Affiche les départements
*/
function displayDepartements(departements) {
const container = document.getElementById('profile-departements');
if (!container) return;
if (!departements || departements.length === 0) {
container.innerHTML = '<span class="text-muted">Aucun département assigné</span>';
return;
}
// Les départements viennent comme objets {id, nom}
container.innerHTML = departements.map(dep => {
const nom = dep.nom || dep.post_title || `Département #${dep.id || dep.ID || dep}`;
return `<span class="badge bg-secondary me-2 mb-2">${nom}</span>`;
}).join('');
}
/**
* Affiche les spécialisations (types d'intervention)
*/
function displaySpecialisations(specialisations) {
const container = document.getElementById('profile-specialisations');
if (!container) return;
if (!specialisations || specialisations.length === 0) {
container.innerHTML = '<span class="text-muted">Aucune spécialisation assignée</span>';
return;
}
// Les spécialisations viennent comme objets {id, nom}
container.innerHTML = specialisations.map(spec => {
const nom = spec.nom || spec.post_title || `Spécialisation #${spec.id || spec.ID || spec}`;
return `<span class="badge bg-primary me-2 mb-2">${nom}</span>`;
}).join('');
}
/**
* Gère la soumission du formulaire de profil
*/
async function handleProfileSubmit(event) {
event.preventDefault();
const telephoneEl = document.getElementById('profile-telephone');
if (!telephoneEl) {
toastr.error('Champ téléphone non trouvé', 'Erreur');
return;
}
const telephone = telephoneEl.value.trim();
// Validation basique
if (!telephone) {
toastr.warning('Le numéro de téléphone est requis', 'Validation');
telephoneEl.focus();
return;
}
// Format de validation simple (à adapter selon les besoins)
const phoneRegex = /^[0-9+\s\-()]{8,}$/;
if (!phoneRegex.test(telephone)) {
toastr.warning('Format de numéro de téléphone invalide', 'Validation');
telephoneEl.focus();
return;
}
try {
// Désactiver le bouton de soumission
const submitBtn = event.target.querySelector('button[type="submit"]');
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Enregistrement...';
}
const profileContainer = document.getElementById('intervenant-profile-container') || document.getElementById('profile-form') || document.body;
if (window.CRVI_OVERLAY && profileContainer) { window.CRVI_OVERLAY.show(profileContainer); }
await apiFetch('intervenant/profile', {
method: 'PUT',
body: JSON.stringify({
telephone: telephone
})
});
toastr.success(
'Profil mis à jour avec succès',
'Succès',
{ timeOut: 3000 }
);
// Recharger le profil pour avoir les données à jour
await loadProfile();
} catch (error) {
console.error('Erreur lors de la mise à jour du profil:', error);
toastr.error(
error.message || 'Erreur lors de la mise à jour du profil',
'Erreur',
{ timeOut: 5000 }
);
} finally {
// Réactiver le bouton
const submitBtn = event.target.querySelector('button[type="submit"]');
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="fas fa-save me-2"></i>Enregistrer les modifications';
}
const profileContainer = document.getElementById('intervenant-profile-container') || document.getElementById('profile-form') || document.body;
if (window.CRVI_OVERLAY && profileContainer) { window.CRVI_OVERLAY.hide(profileContainer); }
}
}
/**
* Gère la soumission du formulaire de disponibilités
*/
async function handleDisponibilitesSubmit(event) {
event.preventDefault();
try {
// Récupérer les jours de disponibilité cochés
const joursCheckboxes = document.querySelectorAll('input[name="jours_disponibilite[]"]:checked');
const joursDisponibilite = Array.from(joursCheckboxes).map(cb => cb.value);
// Récupérer les heures de permanences cochées
const heuresCheckboxes = document.querySelectorAll('input[name="heures_permanences[]"]:checked');
const heuresPermanences = Array.from(heuresCheckboxes).map(cb => cb.value);
// Désactiver le bouton de soumission
const submitBtn = event.target.querySelector('button[type="submit"]');
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Enregistrement...';
}
const profileContainer = document.getElementById('intervenant-profile-container') || document.getElementById('disponibilites-form') || document.body;
if (window.CRVI_OVERLAY && profileContainer) { window.CRVI_OVERLAY.show(profileContainer); }
await apiFetch('intervenant/profile', {
method: 'PUT',
body: JSON.stringify({
jours_de_disponibilite: joursDisponibilite,
heures_de_permanences: heuresPermanences,
indisponibilites_ponctuelles: indisponibilitesList
})
});
toastr.success(
'Disponibilités mises à jour avec succès',
'Succès',
{ timeOut: 3000 }
);
// Vérifier s'il y a des conflits d'indisponibilités
await checkAndDisplayConflicts();
// Recharger le profil pour avoir les données à jour
await loadProfile();
} catch (error) {
console.error('Erreur lors de la mise à jour des disponibilités:', error);
toastr.error(
error.message || 'Erreur lors de la mise à jour des disponibilités',
'Erreur',
{ timeOut: 5000 }
);
} finally {
// Réactiver le bouton
const submitBtn = event.target.querySelector('button[type="submit"]');
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="fas fa-save me-2"></i>Enregistrer les disponibilités';
}
const profileContainer = document.getElementById('intervenant-profile-container') || document.getElementById('disponibilites-form') || document.body;
if (window.CRVI_OVERLAY && profileContainer) { window.CRVI_OVERLAY.hide(profileContainer); }
}
}
/**
* Vérifie et affiche les conflits d'indisponibilités après la sauvegarde
*/
async function checkAndDisplayConflicts() {
try {
// Faire une requête à l'API WordPress pour récupérer le message de conflit
const response = await apiFetch('intervenant/conflicts-check');
if (response && response.has_conflicts && response.message) {
// Afficher le message de conflit en haut de la page
displayConflictMessage(response.message);
}
} catch (error) {
console.error('Erreur lors de la vérification des conflits:', error);
// Ne pas afficher d'erreur à l'utilisateur pour ne pas perturber l'expérience
}
}
/**
* Affiche un message de conflit en haut de la page
* @param {string} message - Le message HTML à afficher
*/
function displayConflictMessage(message) {
// Trouver le conteneur où afficher le message (après le header, avant le contenu)
const container = document.querySelector('.crvi-intervenant-profile .container-fluid');
if (!container) return;
// Supprimer les anciens messages de conflit s'il y en a
const oldAlert = container.querySelector('.conflict-alert');
if (oldAlert) {
oldAlert.remove();
}
// Créer un nouvel élément d'alerte
const alertDiv = document.createElement('div');
alertDiv.className = 'conflict-alert alert alert-danger alert-dismissible fade show mt-3';
alertDiv.setAttribute('role', 'alert');
alertDiv.innerHTML = message;
// Ajouter un bouton de fermeture
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.className = 'btn-close';
closeButton.setAttribute('data-bs-dismiss', 'alert');
closeButton.setAttribute('aria-label', 'Close');
alertDiv.appendChild(closeButton);
// Insérer le message après la navigation (row mb-4)
const navRow = container.querySelector('.row.mb-4');
if (navRow && navRow.nextSibling) {
container.insertBefore(alertDiv, navRow.nextSibling);
} else {
container.insertBefore(alertDiv, container.firstChild);
}
// Scroller vers le haut pour voir le message
window.scrollTo({
top: 0,
behavior: 'smooth'
});
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,25 @@
// Module ES6 pour la gestion des notifications Toastr
export function notifySuccess(message) {
toastr.success(message);
}
export function notifyError(message) {
toastr.error(message);
}
export function notifyInfo(message) {
toastr.info(message);
}
export function notifyWarning(message) {
toastr.warning(message);
}
export function configureToastr() {
toastr.options = {
"closeButton": true,
"progressBar": true,
"positionClass": "toast-top-right",
"timeOut": "5000"
};
}

View File

@ -0,0 +1,395 @@
// Module ES6 pour le tableau de statistiques Agenda
import { apiFetch } from './agenda-api.js';
let currentPage = 1;
let currentFilters = {};
/**
* Initialise le module de tableau de stats
*/
export function initStatsTable() {
// Initialiser Select2 sur les filtres
if (typeof jQuery !== 'undefined' && jQuery.fn.select2) {
jQuery('.stats-filters .select2').select2({
width: '100%'
});
}
// Écouter le bouton de filtre
const filterBtn = document.getElementById('stats_filterBtn');
if (filterBtn) {
filterBtn.addEventListener('click', handleFilter);
}
// Écouter le bouton de réinitialisation
const resetBtn = document.getElementById('stats_resetFiltersBtn');
if (resetBtn) {
resetBtn.addEventListener('click', handleReset);
}
// Charger les événements au démarrage
loadEvents();
}
/**
* Collecte les filtres du formulaire
*/
function collectFilters() {
const filters = {};
// Date (convertir en date pour recherche exacte sur une journée)
const dateInput = document.getElementById('stats_date');
if (dateInput && dateInput.value) {
filters.date = dateInput.value;
}
// Local
const localSelect = document.getElementById('stats_local');
if (localSelect && localSelect.value) {
filters.local = localSelect.value;
}
// Personne (intervenant)
const personneSelect = document.getElementById('stats_personne');
if (personneSelect && personneSelect.value) {
filters.intervenant = personneSelect.value;
}
// Type d'intervention
const typeInterventionSelect = document.getElementById('stats_type_intervention');
if (typeInterventionSelect && typeInterventionSelect.value) {
filters.type_intervention = typeInterventionSelect.value;
}
// Bénéficiaire
const beneficiaireSelect = document.getElementById('stats_beneficiaire');
if (beneficiaireSelect && beneficiaireSelect.value) {
filters.beneficiaire = beneficiaireSelect.value;
}
// Langue
const langueSelect = document.getElementById('stats_langue');
if (langueSelect && langueSelect.value) {
filters.langue = langueSelect.value;
}
// Intervenant externe (traducteur)
const intervenantExterneSelect = document.getElementById('stats_intervenant_externe');
if (intervenantExterneSelect && intervenantExterneSelect.value) {
filters.traducteur = intervenantExterneSelect.value;
}
// Année
const anneeInput = document.getElementById('stats_annee');
if (anneeInput && anneeInput.value) {
filters.annee = parseInt(anneeInput.value, 10);
}
// Filtre permanence
const permanenceCheckbox = document.getElementById('stats_filtre_permanence');
if (permanenceCheckbox && permanenceCheckbox.checked) {
filters.type = 'permanence';
}
return filters;
}
/**
* Gère le clic sur le bouton Filtrer
*/
function handleFilter() {
currentPage = 1;
loadEvents();
}
/**
* Gère le clic sur le bouton Réinitialiser
*/
function handleReset() {
// Réinitialiser tous les champs
const form = document.querySelector('.stats-filters');
if (form) {
form.reset();
// Réinitialiser Select2
if (typeof jQuery !== 'undefined' && jQuery.fn.select2) {
jQuery('.stats-filters .select2').val(null).trigger('change');
}
}
currentPage = 1;
currentFilters = {};
loadEvents();
}
/**
* Charge les événements depuis l'API
*/
async function loadEvents() {
const loadingIndicator = document.getElementById('stats-loading-indicator');
const table = document.getElementById('stats-events-table');
const tableBody = document.getElementById('stats-events-table-body');
const paginationContainer = document.getElementById('stats-pagination-container');
const tableWrapper = document.getElementById('stats-table-wrapper');
// Afficher le loading et masquer le tableau
if (loadingIndicator) {
loadingIndicator.style.display = 'flex';
}
if (tableWrapper) {
tableWrapper.style.display = 'none';
}
if (table) {
table.style.display = 'none';
}
if (paginationContainer) {
paginationContainer.style.display = 'none';
}
try {
// Collecter les filtres
currentFilters = collectFilters();
// Ajouter la pagination
const params = {
...currentFilters,
page: currentPage,
per_page: 20
};
// Appel API
const result = await apiFetch(`events/table?${new URLSearchParams(params).toString()}`);
// Afficher les résultats
displayEvents(result.events || []);
updateCounters(result.total || 0, result.filtered || 0);
displayPagination(result.page || 1, result.total_pages || 0);
// IMPORTANT: Masquer le loader EN PREMIER, puis afficher le tableau
if (loadingIndicator) {
loadingIndicator.style.display = 'none';
}
// Afficher le conteneur du tableau
if (tableWrapper) {
tableWrapper.style.display = 'block';
}
// Afficher le tableau
if (table) {
table.style.display = 'table';
}
// Afficher la pagination si nécessaire
if (paginationContainer && result.total_pages > 0) {
paginationContainer.style.display = 'block';
}
} catch (error) {
console.error('Erreur lors du chargement des événements:', error);
// En cas d'erreur, masquer le loader et afficher le tableau avec le message d'erreur
if (loadingIndicator) {
loadingIndicator.style.display = 'none';
}
if (tableWrapper) {
tableWrapper.style.display = 'block';
}
if (table) {
table.style.display = 'table';
}
if (tableBody) {
tableBody.innerHTML = `<tr><td colspan="8" style="text-align: center; color: red; padding: 20px;">Erreur lors du chargement des données</td></tr>`;
}
}
}
/**
* Affiche les événements dans le tableau
*/
function displayEvents(events) {
const tableBody = document.getElementById('stats-events-table-body');
if (!tableBody) return;
if (events.length === 0) {
tableBody.innerHTML = '<tr><td colspan="8" style="text-align: center;">Aucun événement trouvé</td></tr>';
return;
}
let html = '';
events.forEach(event => {
const dateHeure = event.date_rdv && event.heure_rdv
? `${formatDate(event.date_rdv)} ${event.heure_rdv}`
: '-';
const statutLabel = formatStatutLabel(event.statut);
const statutClass = getStatutBadgeClass(event.statut);
html += `
<tr>
<td style="font-weight: 500; color: #495057;">${event.id || '-'}</td>
<td>${dateHeure}</td>
<td>${event.intervenant_nom || '-'}</td>
<td>${event.beneficiaire_nom || '-'}</td>
<td>${event.traducteur_nom || '-'}</td>
<td>${event.langue || '-'}</td>
<td><span class="badge badge-${statutClass}">${statutLabel}</span></td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(event.commentaire || '')}">${event.commentaire || '-'}</td>
</tr>
`;
});
tableBody.innerHTML = html;
}
/**
* Formate une date au format français
*/
function formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString + 'T00:00:00');
return date.toLocaleDateString('fr-FR', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
}
/**
* Formate le label du statut pour l'affichage
*/
function formatStatutLabel(statut) {
if (!statut) return '-';
const statutMap = {
'prevu': 'Prévu',
'annule': 'Annulé',
'non_tenu': 'Non tenu',
'cloture': 'Clôturé',
'absence': 'Absence'
};
return statutMap[statut.toLowerCase()] || statut;
}
/**
* Retourne la classe Bootstrap pour le badge de statut
*/
function getStatutBadgeClass(statut) {
if (!statut) return 'secondary';
const statutMap = {
'prevu': 'success',
'annule': 'danger',
'non_tenu': 'warning',
'cloture': 'secondary',
'absence': 'warning'
};
return statutMap[statut.toLowerCase()] || 'secondary';
}
/**
* Échappe les caractères HTML pour éviter les injections XSS
*/
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Met à jour les compteurs
*/
function updateCounters(total, filtered) {
const totalCount = document.getElementById('total-count');
const filteredCount = document.getElementById('filtered-count');
if (totalCount) {
totalCount.textContent = total;
}
if (filteredCount) {
filteredCount.textContent = filtered;
}
}
/**
* Affiche la pagination (uniquement les numéros de page)
*/
function displayPagination(currentPageNum, totalPages) {
const paginationContainer = document.getElementById('stats-pagination-container');
if (!paginationContainer || totalPages <= 1) {
if (paginationContainer) {
paginationContainer.style.display = 'none';
}
return;
}
// Afficher le conteneur
paginationContainer.style.display = 'block';
let html = '<nav aria-label="Pagination" style="display: flex; justify-content: center; align-items: center;"><ul class="pagination" style="margin: 0; display: flex; list-style: none; gap: 5px; flex-wrap: wrap;">';
// Afficher toutes les pages si peu nombreuses, sinon un sous-ensemble
const maxPagesToShow = 10;
if (totalPages <= maxPagesToShow) {
// Afficher toutes les pages
for (let i = 1; i <= totalPages; i++) {
if (i === currentPageNum) {
html += `<li class="page-item active"><span class="page-link" style="padding: 8px 12px; border: 1px solid #007bff; border-radius: 4px; background: #007bff; color: #fff; font-weight: 600;">${i}</span></li>`;
} else {
html += `<li class="page-item"><a class="page-link" href="#" data-page="${i}" style="padding: 8px 12px; border: 1px solid #dee2e6; border-radius: 4px; text-decoration: none; color: #007bff; background: #fff; cursor: pointer;">${i}</a></li>`;
}
}
} else {
// Afficher un sous-ensemble de pages avec ellipses
let startPage = Math.max(1, currentPageNum - Math.floor(maxPagesToShow / 2));
let endPage = Math.min(totalPages, startPage + maxPagesToShow - 1);
if (endPage - startPage < maxPagesToShow - 1) {
startPage = Math.max(1, endPage - maxPagesToShow + 1);
}
// Première page
if (startPage > 1) {
html += `<li class="page-item"><a class="page-link" href="#" data-page="1" style="padding: 8px 12px; border: 1px solid #dee2e6; border-radius: 4px; text-decoration: none; color: #007bff; background: #fff; cursor: pointer;">1</a></li>`;
if (startPage > 2) {
html += `<li class="page-item disabled"><span class="page-link" style="padding: 8px 12px; color: #6c757d;">...</span></li>`;
}
}
// Pages du milieu
for (let i = startPage; i <= endPage; i++) {
if (i === currentPageNum) {
html += `<li class="page-item active"><span class="page-link" style="padding: 8px 12px; border: 1px solid #007bff; border-radius: 4px; background: #007bff; color: #fff; font-weight: 600;">${i}</span></li>`;
} else {
html += `<li class="page-item"><a class="page-link" href="#" data-page="${i}" style="padding: 8px 12px; border: 1px solid #dee2e6; border-radius: 4px; text-decoration: none; color: #007bff; background: #fff; cursor: pointer;">${i}</a></li>`;
}
}
// Dernière page
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
html += `<li class="page-item disabled"><span class="page-link" style="padding: 8px 12px; color: #6c757d;">...</span></li>`;
}
html += `<li class="page-item"><a class="page-link" href="#" data-page="${totalPages}" style="padding: 8px 12px; border: 1px solid #dee2e6; border-radius: 4px; text-decoration: none; color: #007bff; background: #fff; cursor: pointer;">${totalPages}</a></li>`;
}
}
html += '</ul></nav>';
paginationContainer.innerHTML = html;
// Ajouter les écouteurs d'événements
const pageLinks = paginationContainer.querySelectorAll('a[data-page]');
pageLinks.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const page = parseInt(link.getAttribute('data-page'), 10);
if (page && page !== currentPage) {
currentPage = page;
loadEvents();
}
});
});
}

1141
assets/js/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
assets/js/package.json Normal file
View File

@ -0,0 +1,20 @@
{
"scripts": {
"build": "vite build",
"watch": "vite build --watch",
"dev": "vite build --watch --mode development"
},
"dependencies": {
"@fullcalendar/core": "^6.1.18",
"@fullcalendar/daygrid": "^6.1.18",
"@fullcalendar/timegrid": "^6.1.18",
"@fullcalendar/list": "^6.1.18",
"@fullcalendar/interaction": "^6.1.18",
"bootstrap": "^5.3.7",
"select2": "^4.0.13",
"toastr": "^2.1.4"
},
"devDependencies": {
"vite": "^7.0.3"
}
}

View File

@ -0,0 +1,157 @@
/**
* Script admin pour la gestion des règles dépendantes des capacités de traduction
*/
(function($) {
'use strict';
$(document).ready(function() {
// Variables globales
const $addBtn = $('#add-sub-rule-btn');
const $modal = $('#sub-rule-modal');
const $confirmBtn = $('#confirm-add-sub-rule');
const $cancelBtn = $('#cancel-add-sub-rule');
const $spinner = $('.traduction-langue-add-sub-rule .spinner');
const parentId = $addBtn.data('parent-id');
// Ouvrir la modal au clic sur le bouton
$addBtn.on('click', function(e) {
e.preventDefault();
openModal();
});
// Fermer la modal au clic sur Annuler
$cancelBtn.on('click', function(e) {
e.preventDefault();
closeModal();
});
// Fermer la modal au clic sur l'overlay
$('.sub-rule-modal-overlay').on('click', function(e) {
closeModal();
});
// Fermer la modal avec la touche ESC
$(document).on('keyup', function(e) {
if (e.key === 'Escape' && $modal.is(':visible')) {
closeModal();
}
});
// Confirmer la création de la règle dépendante
$confirmBtn.on('click', function(e) {
e.preventDefault();
createSubRule();
});
/**
* Ouvre la modal de confirmation
*/
function openModal() {
$modal.fadeIn(200);
$('body').addClass('modal-open');
}
/**
* Ferme la modal de confirmation
*/
function closeModal() {
$modal.fadeOut(200);
$('body').removeClass('modal-open');
}
/**
* Crée une règle dépendante via l'API REST
*/
function createSubRule() {
// Désactiver les boutons pendant le traitement
$confirmBtn.prop('disabled', true);
$cancelBtn.prop('disabled', true);
$spinner.addClass('is-active');
// Données à envoyer
const data = {
parent_id: parentId
};
// Appel REST API
$.ajax({
url: traductionLangueAdmin.restUrl,
method: 'POST',
data: JSON.stringify(data),
contentType: 'application/json',
beforeSend: function(xhr) {
// Ajouter le nonce pour l'authentification
xhr.setRequestHeader('X-WP-Nonce', traductionLangueAdmin.nonce);
},
success: function(response) {
if (response.success && response.data) {
// Afficher un message de succès
showNotice('success', response.data.message || 'Règle dépendante créée avec succès.');
// Recharger la page après un court délai pour voir la nouvelle règle
setTimeout(function() {
window.location.reload();
}, 1000);
} else {
showNotice('error', 'Une erreur est survenue lors de la création de la règle.');
resetButtons();
}
},
error: function(xhr) {
let errorMessage = 'Une erreur est survenue lors de la création de la règle.';
if (xhr.responseJSON && xhr.responseJSON.message) {
errorMessage = xhr.responseJSON.message;
} else if (xhr.responseText) {
try {
const response = JSON.parse(xhr.responseText);
if (response.message) {
errorMessage = response.message;
}
} catch (e) {
// Ignorer les erreurs de parsing
}
}
showNotice('error', errorMessage);
resetButtons();
}
});
}
/**
* Réinitialise l'état des boutons après une erreur
*/
function resetButtons() {
$confirmBtn.prop('disabled', false);
$cancelBtn.prop('disabled', false);
$spinner.removeClass('is-active');
closeModal();
}
/**
* Affiche une notification WordPress
*
* @param {string} type - Type de notification (success, error, warning, info)
* @param {string} message - Message à afficher
*/
function showNotice(type, message) {
const noticeClass = 'notice-' + type;
const $notice = $('<div class="notice ' + noticeClass + ' is-dismissible"><p>' + message + '</p></div>');
// Insérer la notification après le titre de la page
$('.wrap h1').first().after($notice);
// Rendre la notification dismissible
$(document).trigger('wp-updates-notice-added');
// Auto-supprimer après 5 secondes
setTimeout(function() {
$notice.fadeOut(function() {
$(this).remove();
});
}, 5000);
}
});
})(jQuery);

View File

@ -0,0 +1,102 @@
/**
* Script pour la page de liste des capacités de traduction
* Gère les interactions (toggle des enfants, etc.)
*/
(function($) {
'use strict';
$(document).ready(function() {
// Toggle des règles dépendantes (enfants)
$('.toggle-children').on('click', function(e) {
e.preventDefault();
const $button = $(this);
const $card = $button.closest('.capacite-card');
const $children = $card.find('.card-children');
// Toggle de l'affichage
if ($children.is(':visible')) {
$children.slideUp(300);
$button.removeClass('active');
} else {
$children.slideDown(300);
$button.addClass('active');
}
});
// Animation au survol des cartes
$('.capacite-card').hover(
function() {
$(this).css('transform', 'translateY(-2px)');
},
function() {
$(this).css('transform', 'translateY(0)');
}
);
// Highlight des cartes pleines
$('.capacite-card.full').each(function() {
const $card = $(this);
// Animation subtile pour attirer l'attention
setInterval(function() {
$card.css('border-left-color', '#d63638');
setTimeout(function() {
$card.css('border-left-color', '#f0b849');
}, 1000);
}, 2000);
});
// Confirmation avant suppression (si implémenté plus tard)
$(document).on('click', '.delete-capacite', function(e) {
if (!confirm('Êtes-vous sûr de vouloir supprimer cette capacité de traduction ?')) {
e.preventDefault();
return false;
}
});
// Recherche rapide (optionnel - peut être ajouté plus tard)
if ($('#capacite-search').length) {
$('#capacite-search').on('keyup', function() {
const searchTerm = $(this).val().toLowerCase();
$('.capacite-card').each(function() {
const $card = $(this);
const title = $card.find('.card-title h2').text().toLowerCase();
const langue = $card.find('.info-value').first().text().toLowerCase();
if (title.includes(searchTerm) || langue.includes(searchTerm)) {
$card.show();
} else {
$card.hide();
}
});
});
}
// Tooltip pour les badges
$('.badge').each(function() {
const $badge = $(this);
if ($badge.attr('title')) {
$badge.css('cursor', 'help');
}
});
// Auto-refresh des statistiques (optionnel - peut être ajouté plus tard)
// Permet de rafraîchir les données d'utilisation sans recharger la page
function refreshUsageStats() {
// À implémenter si nécessaire avec un appel AJAX
// vers un endpoint REST qui retourne les stats actualisées
}
// Initialisation des tooltips WordPress (si disponible)
if (typeof jQuery.fn.tooltip !== 'undefined') {
$('.badge[title]').tooltip({
position: {
my: 'center bottom-5',
at: 'center top'
}
});
}
});
})(jQuery);

89
assets/js/vite.config.js Normal file
View File

@ -0,0 +1,89 @@
import { defineConfig } from 'vite';
export default defineConfig({
build: {
outDir: 'dist',
rollupOptions: {
input: {
crvi_libraries: 'crvi_libraries.js',
crvi_main: 'crvi_main.js',
crvi_main_css: '../css/crvi_main.css',
intervenant_profile_css: '../css/intervenant-profile.css',
traduction_langue_admin: 'traduction-langue-admin.js',
traduction_langue_admin_css: '../css/traduction-langue-admin.css',
traduction_langue_list: 'traduction-langue-list.js',
traduction_langue_list_css: '../css/traduction-langue-list.css'
},
output: {
format: 'es', // Format ES modules
entryFileNames: '[name].min.js',
assetFileNames: (assetInfo) => {
// Renommer le CSS extrait automatiquement de crvi_libraries.js
if (assetInfo.name && (assetInfo.name.includes('crvi_libraries') && assetInfo.name.endsWith('.css'))) {
return 'crvi_libraries.min.css';
}
// Renommer le CSS des libraries (si entrée séparée)
if (assetInfo.name && assetInfo.name.includes('crvi_libraries_css')) {
return 'crvi_libraries.min.css';
}
// Renommer le fichier CSS principal en crvi_main.min.css
if (assetInfo.name && assetInfo.name.includes('crvi_main_css')) {
return 'crvi_main.min.css';
}
// Renommer le CSS du profil intervenant
if (assetInfo.name && assetInfo.name.includes('intervenant_profile_css')) {
return 'intervenant-profile.min.css';
}
// Renommer le CSS de traduction-langue admin
if (assetInfo.name && assetInfo.name.includes('traduction_langue_admin_css')) {
return 'traduction-langue-admin.min.css';
}
// Renommer le CSS de traduction-langue list
if (assetInfo.name && assetInfo.name.includes('traduction_langue_list_css')) {
return 'traduction-langue-list.min.css';
}
// Pour les autres assets CSS, utiliser le pattern par défaut
if (assetInfo.name && assetInfo.name.endsWith('.css')) {
return '[name].min.css';
}
return '[name].min.[ext]';
},
// Séparer les librairies externes des modules locaux
manualChunks: (id) => {
// Si c'est le fichier crvi_libraries.js, tout va dans crvi_libraries
if (id.includes('crvi_libraries.js')) {
return 'crvi_libraries';
}
// Détecter les node_modules (librairies externes)
if (id.includes('node_modules')) {
// Séparer Bootstrap, Toastr, FullCalendar et leurs dépendances dans le chunk libraries
if (id.includes('bootstrap') ||
id.includes('toastr') ||
id.includes('select2') ||
id.includes('@fullcalendar') ||
id.includes('@popperjs') ||
id.includes('jquery')) {
return 'crvi_libraries';
}
// Autres node_modules - inclure dans libraries aussi
return 'crvi_libraries';
}
// Les modules locaux restent dans crvi_main
return null;
},
chunkFileNames: '[name].min.js',
globals: {
'jquery': 'jQuery',
'toastr': 'toastr',
'select2': 'select2'
}
}
},
// Forcer la transpilation pour compatibilité navigateur
target: 'es2015',
minify: true // Utilise esbuild par défaut (plus rapide que terser)
},
define: {
global: 'window'
}
});

1
index.php Normal file
View File

@ -0,0 +1 @@
<?php // Silence is golden.

16
phpunit.xml.dist Normal file
View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="tests/bootstrap.php"
colors="true"
verbose="true"
stopOnFailure="false">
<testsuites>
<testsuite name="Plugin ESI_crvi_agenda">
<directory>./tests/</directory>
</testsuite>
</testsuites>
<php>
<env name="WP_TESTS_DIR" value="C:\Users\jps\Documents\www\wptests\wordpress-develop\tests\phpunit"/>
<env name="WP_PHP_BINARY" value="php"/>
<env name="WP_PLUGIN_DIR" value="${PWD}"/>
</php>
</phpunit>

4
plugin.php Normal file
View File

@ -0,0 +1,4 @@
<?php
add_action('admin_post_crvi_import_intervenant', ['ESI_CRVI_AGENDA\\controllers\\CRVI_Intervenant_Controller', 'import_csv_admin']);
add_action('admin_post_crvi_import_beneficiaire', ['ESI_CRVI_AGENDA\\controllers\\CRVI_Beneficiaire_Controller', 'import_csv_admin']);
add_action('admin_post_crvi_import_traducteur', ['ESI_CRVI_AGENDA\\controllers\\CRVI_Traducteur_Controller', 'import_csv_admin']);

View File

@ -0,0 +1,105 @@
<?php
/**
* Hub d'administration principal du plugin CRVI Agenda
* Emplacement : templates/admin/admin_hub.php
*
* Ce fichier sert de point d'entrée pour l'interface d'administration centrale du plugin.
* À compléter avec les composants d'administration (tableaux de bord, accès rapides, etc.)
*/
// Sécurité WordPress : empêche l'accès direct au fichier
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// Génération des URLs admin pour les entités et pages spécifiques
function crvi_admin_url($page) {
return admin_url('edit.php?post_type=' . $page);
}
function crvi_import_form($entity) {
$action = 'crvi_import_' . $entity;
$label = 'Importer ' . ucfirst($entity) . ' (CSV)';
$nonce = wp_create_nonce('crvi_import_' . $entity);
return '<form method="post" enctype="multipart/form-data" action="' . esc_url(admin_url('admin-post.php')) . '" style="margin-top:1em;">'
. '<input type="hidden" name="action" value="' . esc_attr($action) . '">'
. '<input type="hidden" name="_wpnonce" value="' . esc_attr($nonce) . '">'
. '<input type="file" name="import_csv" accept=".csv" required style="margin-bottom:0.5em;">'
. '<button type="submit" class="button">' . esc_html($label) . '</button>'
. '</form>';
}
$entities = [
'beneficiaire' => 'Bénéficiaires',
'intervenant' => 'Intervenants',
'local' => 'Locaux',
'traducteur' => 'Traducteurs',
'departement' => 'Départements',
'type_intervention' => 'Types d\'intervention',
];
// Affichage du message de succès/erreur après import
function crvi_hub_notice() {
if (!empty($_GET['import'])) {
$type = $_GET['import'] === 'success' ? 'success' : 'error';
$msg = isset($_GET['msg']) ? sanitize_text_field($_GET['msg']) : ($type === 'success' ? 'Import réussi.' : 'Erreur lors de l\'import.');
echo '<div class="notice notice-' . esc_attr($type) . ' is-dismissible"><p>' . esc_html($msg) . '</p></div>';
}
}
?>
<style>
.crvi-hub-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: repeat(3, 1fr);
gap: 2rem;
margin-top: 2rem;
}
.crvi-hub-block {
background: #fff;
border: 1px solid #e5e5e5;
border-radius: 6px;
padding: 2rem 1.5rem;
box-shadow: 0 1px 2px rgba(0,0,0,0.03);
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
}
.crvi-hub-block h2 {
margin-top: 0;
font-size: 1.1em;
margin-bottom: 1em;
}
.crvi-hub-block .button {
width: 100%;
margin-bottom: 0.5em;
}
.crvi-hub-block form {
width: 100%;
text-align: center;
}
</style>
<div class="wrap">
<h1>CRVI Agenda Hub d'administration</h1>
<p>Bienvenue sur le hub d'administration du plugin CRVI Agenda.</p>
<?php crvi_hub_notice(); ?>
<div class="crvi-hub-grid">
<?php foreach ($entities as $slug => $label): ?>
<div class="crvi-hub-block">
<h2><?php echo esc_html($label); ?></h2>
<a href="<?php echo esc_url( crvi_admin_url($slug) ); ?>" class="button button-primary">Voir <?php echo esc_html(strtolower($label)); ?></a>
<?php echo crvi_import_form($slug); ?>
</div>
<?php endforeach; ?>
<div class="crvi-hub-block">
<h2>Agenda</h2>
<a href="<?php echo esc_url( admin_url('admin.php?page=crvi_agenda') ); ?>" class="button button-primary">Accéder à l'agenda</a>
</div>
<div class="crvi-hub-block">
<h2>Statistiques</h2>
<a href="<?php echo esc_url( admin_url('admin.php?page=crvi_agenda_stats') ); ?>" class="button button-primary">Voir les statistiques</a>
</div>
</div>
</div>

View File

@ -0,0 +1,127 @@
<?php
// Page d'agenda (admin) - squelette vide
?>
<div class="wrap">
<h1>Agenda</h1>
<p>Cette page est en cours de construction.</p>
<section class="agenda-container">
<!--filters-->
<section class="filters-container">
<form class="filters" method="get" onsubmit="return false;">
<div class="filter">
<label for="date">Date</label>
<input type="date" id="date" name="date">
</div>
<div class="filter">
<label for="local">Local</label>
<select id="local" name="local" class="select2">
<option value="">Tous</option>
<!-- Options dynamiques -->
<?php
foreach ($locals as $local) {
echo '<option value="' . $local['id'] . '">' . $local['nom'] . '</option>';
}
?>
</select>
</div>
<div class="filter">
<label for="personne">Personne</label>
<select id="personne" name="personne" class="select2">
<option value="">Toutes</option>
<!-- Options dynamiques -->
<?php
foreach ($intervenants as $intervenant) {
echo '<option value="' . $intervenant['id'] . '">' . $intervenant['nom'] . '</option>';
}
?>
</select>
</div>
<div class="filter">
<label for="type_intervention">Type d'intervention</label>
<select id="type_intervention" name="type_intervention" class="select2">
<option value="">Tous</option>
<!-- Options dynamiques -->
<?php
foreach ($types_intervention as $type_intervention) {
echo '<option value="' . $type_intervention['id'] . '">' . $type_intervention['nom'] . '</option>';
}
?>
</select>
</div>
<div class="filter">
<label for="beneficiaire">Bénéficiaire</label>
<select id="beneficiaire" name="beneficiaire" class="select2">
<option value="">Tous</option>
<!-- Options dynamiques -->
<?php
foreach ($beneficiaires as $beneficiaire) {
echo '<option value="' . $beneficiaire['id'] . '">' . $beneficiaire['nom'] . '</option>';
}
?>
</select>
</div>
<div class="filter">
<label for="langue">Langue du rendez-vous</label>
<select id="langue" name="langue" class="select2">
<option value="">Toutes</option>
<!-- Options dynamiques -->
<?php
foreach ($langues_beneficiaire as $langue) {
echo '<option value="' . $langue['id'] . '">' . $langue['nom'] . '</option>';
}
?>
</select>
</div>
<div class="filter">
<label for="intervenant_externe">Intervenant externe</label>
<select id="intervenant_externe" name="intervenant_externe" class="select2">
<option value="">Tous</option>
<!-- Options dynamiques -->
<?php
foreach ($traducteurs as $traducteur) {
echo '<option value="' . $traducteur['id'] . '">' . $traducteur['nom'] . '</option>';
}
?>
</select>
</div>
<div class="filter">
<label for="filtre_permanence" style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="filtre_permanence" name="filtre_permanence" value="permanence">
<span>Afficher uniquement les permanences</span>
</label>
</div>
<div class="filter">
<button type="button" id="addEventBtn" class="btn btn-primary">
<i class="fas fa-plus"></i> Ajouter un RDV
</button>
</div>
<div class="filter" style="display: none;">
<button type="button" id="resetFiltersBtn" class="btn btn-secondary">
<i class="fas fa-times"></i> Réinitialiser
</button>
</div>
<div class="filter">
<button type="button" id="filterBtn" class="btn btn-primary" onclick="return false;">
<i class="fas fa-filter"></i> Filtrer
</button>
</div>
</form>
</section>
<!--agenda-->
<section class="agenda-inner-container" id="agenda-calendar">
<div id="loading-indicator" style="text-align: center; padding: 20px;">
<p>Chargement du calendrier...</p>
</div>
</section>
<!--modal-->
<?php
require_once dirname(__DIR__, 1) . '/modules/agenda-modal.php';
?>
</section>
</div>

View File

@ -0,0 +1,234 @@
<?php
// Template : Tableau de statistiques Agenda avec filtres et pagination
?>
<div class="wrap">
<h1><?php esc_html_e('Statistiques Agenda', 'esi_crvi_agenda'); ?></h1>
<!-- Zone 1: Filtres -->
<section class="stats-filters-container" style="background: #fff; padding: 20px; margin-bottom: 20px; border: 1px solid #ddd; border-radius: 4px;">
<form class="stats-filters" method="get" onsubmit="return false;" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; align-items: end;">
<div class="filter">
<label for="stats_date" style="display: block; margin-bottom: 5px; font-weight: 500;"><?php esc_html_e('Date', 'esi_crvi_agenda'); ?></label>
<input type="date" id="stats_date" name="date" class="form-control" style="width: 100%;">
</div>
<div class="filter">
<label for="stats_local" style="display: block; margin-bottom: 5px; font-weight: 500;"><?php esc_html_e('Local', 'esi_crvi_agenda'); ?></label>
<select id="stats_local" name="local" class="select2 form-control" style="width: 100%;">
<option value=""><?php esc_html_e('Tous', 'esi_crvi_agenda'); ?></option>
<?php
foreach ($locals as $local) {
echo '<option value="' . esc_attr($local['id']) . '">' . esc_html($local['nom']) . '</option>';
}
?>
</select>
</div>
<div class="filter">
<label for="stats_personne" style="display: block; margin-bottom: 5px; font-weight: 500;"><?php esc_html_e('Personne', 'esi_crvi_agenda'); ?></label>
<select id="stats_personne" name="personne" class="select2 form-control" style="width: 100%;">
<option value=""><?php esc_html_e('Toutes', 'esi_crvi_agenda'); ?></option>
<?php
foreach ($intervenants as $intervenant) {
echo '<option value="' . esc_attr($intervenant['id']) . '">' . esc_html($intervenant['nom']) . '</option>';
}
?>
</select>
</div>
<div class="filter">
<label for="stats_type_intervention" style="display: block; margin-bottom: 5px; font-weight: 500;"><?php esc_html_e('Type d\'intervention', 'esi_crvi_agenda'); ?></label>
<select id="stats_type_intervention" name="type_intervention" class="select2 form-control" style="width: 100%;">
<option value=""><?php esc_html_e('Tous', 'esi_crvi_agenda'); ?></option>
<?php
foreach ($types_intervention as $type_intervention) {
echo '<option value="' . esc_attr($type_intervention['id']) . '">' . esc_html($type_intervention['nom']) . '</option>';
}
?>
</select>
</div>
<div class="filter">
<label for="stats_beneficiaire" style="display: block; margin-bottom: 5px; font-weight: 500;"><?php esc_html_e('Bénéficiaire', 'esi_crvi_agenda'); ?></label>
<select id="stats_beneficiaire" name="beneficiaire" class="select2 form-control" style="width: 100%;">
<option value=""><?php esc_html_e('Tous', 'esi_crvi_agenda'); ?></option>
<?php
foreach ($beneficiaires as $beneficiaire) {
echo '<option value="' . esc_attr($beneficiaire['id']) . '">' . esc_html($beneficiaire['nom']) . '</option>';
}
?>
</select>
</div>
<div class="filter">
<label for="stats_langue" style="display: block; margin-bottom: 5px; font-weight: 500;"><?php esc_html_e('Langue', 'esi_crvi_agenda'); ?></label>
<select id="stats_langue" name="langue" class="select2 form-control" style="width: 100%;">
<option value=""><?php esc_html_e('Toutes', 'esi_crvi_agenda'); ?></option>
<?php
foreach ($langues_beneficiaire as $langue) {
echo '<option value="' . esc_attr($langue['id']) . '">' . esc_html($langue['nom']) . '</option>';
}
?>
</select>
</div>
<div class="filter">
<label for="stats_intervenant_externe" style="display: block; margin-bottom: 5px; font-weight: 500;"><?php esc_html_e('Intervenant externe', 'esi_crvi_agenda'); ?></label>
<select id="stats_intervenant_externe" name="intervenant_externe" class="select2 form-control" style="width: 100%;">
<option value=""><?php esc_html_e('Tous', 'esi_crvi_agenda'); ?></option>
<?php
foreach ($traducteurs as $traducteur) {
echo '<option value="' . esc_attr($traducteur['id']) . '">' . esc_html($traducteur['nom']) . '</option>';
}
?>
</select>
</div>
<div class="filter">
<label for="stats_annee" style="display: block; margin-bottom: 5px; font-weight: 500;"><?php esc_html_e('Année', 'esi_crvi_agenda'); ?></label>
<input type="number" id="stats_annee" name="annee" min="2000" max="2100" placeholder="<?php echo date('Y'); ?>" class="form-control" style="width: 100%;">
</div>
<div class="filter" style="display: flex; flex-direction: column; gap: 10px;">
<label for="stats_filtre_permanence" style="display: flex; align-items: center; gap: 8px; cursor: pointer; margin-top: 25px;">
<input type="checkbox" id="stats_filtre_permanence" name="filtre_permanence" value="permanence">
<span><?php esc_html_e('Afficher uniquement les permanences', 'esi_crvi_agenda'); ?></span>
</label>
</div>
<div class="filter" style="display: flex; gap: 10px; justify-content: flex-end; align-items: end;">
<button type="button" id="stats_filterBtn" class="btn btn-primary" style="min-width: 100px;">
<i class="fas fa-filter"></i> <?php esc_html_e('Filtrer', 'esi_crvi_agenda'); ?>
</button>
<button type="button" id="stats_resetFiltersBtn" class="btn btn-secondary" style="min-width: 120px;">
<i class="fas fa-times"></i> <?php esc_html_e('Réinitialiser', 'esi_crvi_agenda'); ?>
</button>
</div>
</form>
</section>
<!-- Zone 2: Résultats -->
<section class="stats-results-container" style="background: #f5f5f5; padding: 12px 20px; margin-bottom: 15px; border-radius: 4px;">
<div class="stats-counters" style="display: flex; align-items: center; gap: 10px; font-size: 14px;">
<strong><?php esc_html_e('Total événements:', 'esi_crvi_agenda'); ?></strong>
<span id="total-count" style="font-weight: bold; color: #333;">0</span>
<span style="margin: 0 5px;">|</span>
<strong><?php esc_html_e('Affichés:', 'esi_crvi_agenda'); ?></strong>
<span id="filtered-count" style="font-weight: bold; color: #333;">0</span>
</div>
</section>
<!-- Zone 3: Tableau -->
<section class="stats-table-container" style="background: #fff; border: 1px solid #ddd; border-radius: 4px; overflow: hidden; min-height: 400px; position: relative;">
<div id="stats-loading-indicator" style="display: none; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); flex-direction: column; align-items: center; justify-content: center; padding: 60px 20px; width: 100%; z-index: 10;">
<div class="spinner-border text-primary" role="status" style="width: 3rem; height: 3rem; margin-bottom: 20px;">
<span class="sr-only"><?php esc_html_e('Chargement...', 'esi_crvi_agenda'); ?></span>
</div>
<p style="color: #666; font-size: 16px; margin: 0; text-align: center;"><?php esc_html_e('Chargement des événements...', 'esi_crvi_agenda'); ?></p>
</div>
<div id="stats-table-wrapper" style="overflow-x: auto; display: none;">
<table class="table table-striped table-hover stats-table" id="stats-events-table" style="display: none; margin: 0; width: 100%; border-collapse: collapse;">
<thead style="background: #f8f9fa; border-bottom: 2px solid #dee2e6;">
<tr>
<th style="padding: 12px; text-align: left; font-weight: 600; border-bottom: 2px solid #dee2e6;"><?php esc_html_e('ID', 'esi_crvi_agenda'); ?></th>
<th style="padding: 12px; text-align: left; font-weight: 600; border-bottom: 2px solid #dee2e6;"><?php esc_html_e('Date/Heure', 'esi_crvi_agenda'); ?></th>
<th style="padding: 12px; text-align: left; font-weight: 600; border-bottom: 2px solid #dee2e6;"><?php esc_html_e('Intervenant', 'esi_crvi_agenda'); ?></th>
<th style="padding: 12px; text-align: left; font-weight: 600; border-bottom: 2px solid #dee2e6;"><?php esc_html_e('Bénéficiaire', 'esi_crvi_agenda'); ?></th>
<th style="padding: 12px; text-align: left; font-weight: 600; border-bottom: 2px solid #dee2e6;"><?php esc_html_e('Traducteur', 'esi_crvi_agenda'); ?></th>
<th style="padding: 12px; text-align: left; font-weight: 600; border-bottom: 2px solid #dee2e6;"><?php esc_html_e('Langue', 'esi_crvi_agenda'); ?></th>
<th style="padding: 12px; text-align: left; font-weight: 600; border-bottom: 2px solid #dee2e6;"><?php esc_html_e('Statut', 'esi_crvi_agenda'); ?></th>
<th style="padding: 12px; text-align: left; font-weight: 600; border-bottom: 2px solid #dee2e6;"><?php esc_html_e('Commentaire', 'esi_crvi_agenda'); ?></th>
</tr>
</thead>
<tbody id="stats-events-table-body" style="background: #fff;">
<!-- Rempli dynamiquement par JavaScript -->
</tbody>
</table>
</div>
<div class="stats-pagination" id="stats-pagination-container" style="display: none; padding: 15px 20px; background: #f8f9fa; border-top: 1px solid #dee2e6;">
<!-- Rempli dynamiquement par JavaScript -->
</div>
</section>
</div>
<style>
.stats-table tbody tr {
border-bottom: 1px solid #e9ecef;
transition: background-color 0.15s ease;
}
.stats-table tbody tr:hover {
background-color: #f8f9fa !important;
}
.stats-table tbody tr:nth-child(even) {
background-color: #f8f9fa;
}
.stats-table tbody tr:nth-child(odd) {
background-color: #fff;
}
.stats-table td {
padding: 12px;
vertical-align: middle;
}
.stats-table .badge {
padding: 4px 8px;
border-radius: 3px;
font-size: 12px;
font-weight: 500;
display: inline-block;
}
.stats-table .badge-success {
background-color: #28a745;
color: #fff;
}
.stats-table .badge-danger {
background-color: #dc3545;
color: #fff;
}
.stats-table .badge-warning {
background-color: #ffc107;
color: #212529;
}
.stats-table .badge-secondary {
background-color: #6c757d;
color: #fff;
}
.stats-table .badge-info {
background-color: #17a2b8;
color: #fff;
}
/* Spinner Bootstrap */
.spinner-border {
display: inline-block;
width: 2rem;
height: 2rem;
vertical-align: text-bottom;
border: 0.25em solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spinner-border 0.75s linear infinite;
}
.spinner-border.text-primary {
color: #007bff !important;
}
@keyframes spinner-border {
to {
transform: rotate(360deg);
}
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
</style>

View File

@ -0,0 +1,516 @@
<?php
/**
* Template Admin - Gestion des Permanences
* Permet à l'admin de créer des permanences pour n'importe quel intervenant
*/
if (!defined('ABSPATH')) {
exit;
}
// Récupérer la liste des intervenants
$intervenants = \ESI_CRVI_AGENDA\models\CRVI_Intervenant_Model::get_intervenants([], true);
// Récupérer toutes les langues disponibles depuis la taxonomie
$langues_terms = get_terms([
'taxonomy' => 'langue',
'hide_empty' => false,
'orderby' => 'name',
'order' => 'ASC',
]);
$langues = [];
if (!is_wp_error($langues_terms) && !empty($langues_terms)) {
foreach ($langues_terms as $term) {
$langues[] = [
'id' => $term->slug,
'nom' => $term->name,
];
}
}
?>
<div class="crvi-admin-permanences" id="admin-permanences-container">
<div class="container-fluid py-4">
<!-- En-tête -->
<div class="row mb-4">
<div class="col-12">
<p class="text-muted">
Créez des permanences pour un intervenant en sélectionnant la période, les jours et la plage horaire, ou importez-les via CSV.
</p>
</div>
</div>
<div class="row">
<div class="col-12">
<!-- Navigation par onglets -->
<ul class="nav nav-tabs mb-4" id="permanences-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="creation-manuelle-tab" data-bs-toggle="tab" data-bs-target="#creation-manuelle" type="button" role="tab" aria-controls="creation-manuelle" aria-selected="true">
<i class="fas fa-plus-circle me-2"></i>Création manuelle
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="import-csv-tab" data-bs-toggle="tab" data-bs-target="#import-csv" type="button" role="tab" aria-controls="import-csv" aria-selected="false">
<i class="fas fa-file-csv me-2"></i>Import CSV
</button>
</li>
</ul>
<!-- Contenu des onglets -->
<div class="tab-content" id="permanences-tab-content">
<!-- Onglet 1 : Création manuelle -->
<div class="tab-pane fade show active" id="creation-manuelle" role="tabpanel" aria-labelledby="creation-manuelle-tab">
<form id="admin-permanences-form">
<!-- Sélection de l'intervenant -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-primary text-white">
<h3 class="h5 mb-0">
<i class="fas fa-user me-2"></i>Intervenant
</h3>
</div>
<div class="card-body">
<div class="mb-3">
<label for="intervenant-select" class="form-label fw-bold">Sélectionner un intervenant :</label>
<select class="form-select form-select-lg" id="intervenant-select" name="intervenant_id" required>
<option value="">-- Sélectionner un intervenant --</option>
<?php foreach ($intervenants as $intervenant): ?>
<?php
$days_attr = '';
$times_attr = '';
if (function_exists('get_field')) {
$jours = get_field('jours_de_disponibilite', 'user_' . $intervenant['id']);
$heures = get_field('heures_de_permanences', 'user_' . $intervenant['id']);
if (is_array($jours) && !empty($jours)) {
$days_attr = implode(',', array_map('strval', $jours));
}
if (is_array($heures) && !empty($heures)) {
$times_attr = implode(',', array_map('strval', $heures));
}
}
?>
<option value="<?php echo esc_attr($intervenant['id']); ?>" <?php echo $days_attr !== '' ? 'data-days="' . esc_attr($days_attr) . '"' : ''; ?> <?php echo $times_attr !== '' ? 'data-time-slots="' . esc_attr($times_attr) . '"' : ''; ?>>
<?php echo esc_html($intervenant['nom'] . ' ' . $intervenant['prenom']); ?>
</option>
<?php endforeach; ?>
</select>
</div>
</div>
</div>
<!-- Section 1 : Sélection des permanences -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-primary text-white">
<h3 class="h5 mb-0">
<i class="fas fa-calendar-check me-2"></i>SECTION 1 : Sélection des permanences
</h3>
</div>
<div class="card-body">
<!-- Mois de début -->
<div class="mb-4">
<label for="mois-debut" class="form-label fw-bold">Mois de début :</label>
<input type="month" class="form-control form-control-lg" id="mois-debut" name="mois_debut" required>
<small class="form-text text-muted">
Sélectionnez le mois et l'année à partir desquels les permanences seront créées.
</small>
</div>
<!-- Période -->
<div class="mb-4">
<label class="form-label fw-bold">Période :</label>
<div class="btn-group" role="group" aria-label="Période">
<input type="radio" class="btn-check" name="periode" id="periode-3" value="3" checked>
<label class="btn btn-outline-primary" for="periode-3">3 mois</label>
<input type="radio" class="btn-check" name="periode" id="periode-6" value="6">
<label class="btn btn-outline-primary" for="periode-6">6 mois</label>
</div>
</div>
<!-- Jours de la semaine -->
<div class="mb-4">
<label class="form-label fw-bold">Jours de la semaine :</label>
<div class="row g-2">
<div class="col-md-3 col-sm-4 col-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="jours[]" value="lundi" id="jour-lundi">
<label class="form-check-label" for="jour-lundi">Lundi</label>
</div>
</div>
<div class="col-md-3 col-sm-4 col-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="jours[]" value="mardi" id="jour-mardi">
<label class="form-check-label" for="jour-mardi">Mardi</label>
</div>
</div>
<div class="col-md-3 col-sm-4 col-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="jours[]" value="mercredi" id="jour-mercredi">
<label class="form-check-label" for="jour-mercredi">Mercredi</label>
</div>
</div>
<div class="col-md-3 col-sm-4 col-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="jours[]" value="jeudi" id="jour-jeudi">
<label class="form-check-label" for="jour-jeudi">Jeudi</label>
</div>
</div>
<div class="col-md-3 col-sm-4 col-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="jours[]" value="vendredi" id="jour-vendredi">
<label class="form-check-label" for="jour-vendredi">Vendredi</label>
</div>
</div>
<div class="col-md-3 col-sm-4 col-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="jours[]" value="samedi" id="jour-samedi">
<label class="form-check-label" for="jour-samedi">Samedi</label>
</div>
</div>
<div class="col-md-3 col-sm-4 col-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="jours[]" value="dimanche" id="jour-dimanche">
<label class="form-check-label" for="jour-dimanche">Dimanche</label>
</div>
</div>
</div>
</div>
<!-- Durée de permanence -->
<div class="mb-4">
<label class="form-label fw-bold">Durée de permanence :</label>
<div class="btn-group mb-3" role="group" aria-label="Durée">
<input type="radio" class="btn-check" name="duree_permanence" id="duree-1h"
value="1h" checked>
<label class="btn btn-outline-primary" for="duree-1h">1 heure</label>
<input type="radio" class="btn-check" name="duree_permanence" id="duree-15min"
value="15min">
<label class="btn btn-outline-primary" for="duree-15min">1/4 d'heure (15 min)</label>
</div>
<small class="form-text text-muted d-block mb-3">
Choisissez la durée de chaque permanence.
</small>
</div>
<!-- Nombre de tranches (si 1/4 d'heure) -->
<div class="mb-4" id="nb-tranches-container" style="display: none;">
<label for="nb-tranches" class="form-label fw-bold">Nombre de tranches par heure :</label>
<select class="form-select form-select-lg" id="nb-tranches" name="nb_tranches">
<option value="1">1 tranche (ex: 11h00 11h15)</option>
<option value="2">2 tranches (ex: 11h00 11h15, 11h15 11h30)</option>
<option value="3">3 tranches (ex: 11h00 11h15, 11h15 11h30, 11h30 11h45)</option>
<option value="4">4 tranches (ex: 11h00 11h15, 11h15 11h30, 11h30 11h45, 11h45 12h00)</option>
</select>
<small class="form-text text-muted">
Si vous choisissez 1/4 d'heure, indiquez combien de tranches de 15 minutes créer pour chaque heure sélectionnée.
</small>
</div>
<!-- Heures de permanence -->
<div class="mb-4">
<label class="form-label fw-bold">Heures de permanence :</label>
<small class="form-text text-muted d-block mb-3" id="heures-description">
Sélectionnez les heures de début. Chaque heure sélectionnée créera une tranche d'1 heure (ex: 09:00 10:00).
</small>
<div class="row g-2">
<div class="col-12">
<label class="form-label fw-semibold text-muted">Matin :</label>
</div>
<?php
// Plage matin : 09:00 à 12:00
for ($h = 9; $h <= 12; $h++):
$heure = str_pad($h, 2, '0', STR_PAD_LEFT) . ':00';
$heureFin = str_pad($h + 1, 2, '0', STR_PAD_LEFT) . ':00';
?>
<div class="col-md-3 col-sm-4 col-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="heures[]" value="<?php echo esc_attr($heure); ?>" id="heure-<?php echo $h; ?>">
<label class="form-check-label" for="heure-<?php echo $h; ?>">
<?php echo $heure; ?> → <?php echo $heureFin; ?>
</label>
</div>
</div>
<?php endfor; ?>
<div class="col-12 mt-2">
<label class="form-label fw-semibold text-muted">Après-midi :</label>
</div>
<?php
// Plage après-midi : 13:00 à 16:00
for ($h = 13; $h <= 16; $h++):
$heure = str_pad($h, 2, '0', STR_PAD_LEFT) . ':00';
$heureFin = str_pad($h + 1, 2, '0', STR_PAD_LEFT) . ':00';
?>
<div class="col-md-3 col-sm-4 col-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="heures[]" value="<?php echo esc_attr($heure); ?>" id="heure-<?php echo $h; ?>">
<label class="form-check-label" for="heure-<?php echo $h; ?>">
<?php echo $heure; ?> → <?php echo $heureFin; ?>
</label>
</div>
</div>
<?php endfor; ?>
</div>
</div>
<!-- Aperçu des tranches générées -->
<div class="mb-3">
<label class="form-label fw-bold">Aperçu des tranches générées :</label>
<div id="tranches-preview" class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
Veuillez sélectionner une plage horaire pour voir l'aperçu.
</div>
</div>
<!-- Estimation -->
<div class="mb-3">
<div id="estimation-container" class="alert alert-warning" style="display: none;">
<i class="fas fa-calculator me-2"></i>
<strong>Estimation :</strong> <span id="estimation-count">0</span> événements seront créés
</div>
</div>
</div>
</div>
<!-- Section 2 : Langues -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-info text-white">
<h3 class="h5 mb-0">
<i class="fas fa-language me-2"></i>SECTION 2 : Langues des permanences
</h3>
</div>
<div class="card-body">
<div class="mb-3">
<label for="langues-permanences" class="form-label fw-bold">Langues disponibles (optionnel) :</label>
<select class="form-select form-select-lg" id="langues-permanences" name="langues[]" multiple>
<?php if (!empty($langues)): ?>
<?php foreach ($langues as $langue): ?>
<option value="<?php echo esc_attr($langue['id']); ?>">
<?php echo esc_html($langue['nom']); ?>
</option>
<?php endforeach; ?>
<?php else: ?>
<option value="">Aucune langue disponible</option>
<?php endif; ?>
</select>
<small class="form-text text-muted">
Sélectionnez une ou plusieurs langues pour ces permanences.
</small>
</div>
</div>
</div>
<!-- Section 3 : Informations complémentaires -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-secondary text-white">
<h3 class="h5 mb-0">
<i class="fas fa-sticky-note me-2"></i>SECTION 3 : Informations complémentaires
</h3>
</div>
<div class="card-body">
<div class="mb-3">
<label for="informations-complementaires" class="form-label">Notes ou commentaires (optionnel)</label>
<textarea class="form-control" id="informations-complementaires" name="informations_complementaires" rows="3" placeholder="Ajoutez des notes ou commentaires sur ces permanences..."></textarea>
<small class="form-text text-muted">
Ces informations seront associées aux permanences créées.
</small>
</div>
</div>
</div>
<!-- Bouton de soumission -->
<div class="card shadow-sm">
<div class="card-body text-center">
<button type="submit" class="btn btn-primary btn-lg" id="submit-permanences-btn">
<i class="fas fa-save me-2"></i>Enregistrer les permanences
</button>
<button type="reset" class="btn btn-outline-secondary btn-lg ms-2" id="reset-permanences-btn">
<i class="fas fa-redo me-2"></i>Réinitialiser
</button>
</div>
</div>
</form>
</div>
<!-- Onglet 2 : Import CSV -->
<div class="tab-pane fade" id="import-csv" role="tabpanel" aria-labelledby="import-csv-tab">
<div class="card shadow-sm">
<div class="card-header bg-info text-white">
<h3 class="h5 mb-0">
<i class="fas fa-file-csv me-2"></i>Import de permanences via CSV
</h3>
</div>
<div class="card-body">
<div class="alert alert-info mb-4">
<i class="fas fa-info-circle me-2"></i>
<strong>Format CSV attendu :</strong> Le fichier CSV doit contenir les colonnes suivantes :
<ul class="mt-2 mb-0">
<li><code>intervenant_id</code> : ID de l'intervenant</li>
<li><code>date_debut</code> : Date de début (format YYYY-MM-DD)</li>
<li><code>date_fin</code> : Date de fin (format YYYY-MM-DD)</li>
<li><code>heure_debut</code> : Heure de début (format HH:MM)</li>
<li><code>heure_fin</code> : Heure de fin (format HH:MM)</li>
<li><code>informations_complementaires</code> : Notes (optionnel)</li>
</ul>
</div>
<form id="import-csv-form" enctype="multipart/form-data">
<div class="mb-3">
<label for="csv-file" class="form-label fw-bold">Sélectionner un fichier CSV :</label>
<input type="file" class="form-control form-control-lg" id="csv-file" name="csv_file" accept=".csv" required>
<small class="form-text text-muted">
Format accepté : CSV (séparateur virgule)
</small>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-info btn-lg" id="submit-csv-import-btn">
<i class="fas fa-upload me-2"></i>Importer le CSV
</button>
<button type="reset" class="btn btn-outline-secondary btn-lg" id="reset-csv-import-btn">
<i class="fas fa-redo me-2"></i>Réinitialiser
</button>
</div>
</form>
<div id="csv-import-result" class="mt-4" style="display: none;"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.crvi-admin-permanences {
min-height: 60vh;
}
/* Augmentation de la largeur de la page */
.crvi-admin-permanences .container-fluid {
max-width: 100%;
padding-left: 20px;
padding-right: 20px;
}
.btn-group .btn-check:checked + .btn {
background-color: #0d6efd;
border-color: #0d6efd;
color: white;
}
.form-check-input:checked {
background-color: #0d6efd;
border-color: #0d6efd;
}
#tranches-preview {
min-height: 60px;
}
#tranches-preview ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
#tranches-preview li {
background-color: #e7f3ff;
padding: 0.5rem 1rem;
border-radius: 4px;
border: 1px solid #b3d9ff;
}
.alert {
border-left: 4px solid;
}
.alert-info {
border-left-color: #0dcaf0;
}
.alert-warning {
border-left-color: #ffc107;
}
/* Styles pour les onglets */
.nav-tabs {
border-bottom: 2px solid #dee2e6;
}
.nav-tabs .nav-link {
color: #495057;
border: none;
border-bottom: 3px solid transparent;
padding: 0.75rem 1.5rem;
transition: all 0.3s ease;
}
.nav-tabs .nav-link:hover {
border-color: #dee2e6;
background-color: #f8f9fa;
}
.nav-tabs .nav-link.active {
color: #0d6efd;
background-color: transparent;
border-color: #0d6efd;
font-weight: 600;
}
.tab-content {
padding-top: 1.5rem;
}
/* Amélioration de l'affichage des résultats d'import */
#csv-import-result {
margin-top: 1.5rem;
}
#csv-import-result .alert {
border-radius: 0.375rem;
}
</style>
<script>
// Gérer l'affichage conditionnel du champ nombre de tranches
document.addEventListener('DOMContentLoaded', function() {
const duree1h = document.getElementById('duree-1h');
const duree15min = document.getElementById('duree-15min');
const nbTranchesContainer = document.getElementById('nb-tranches-container');
const heuresDescription = document.getElementById('heures-description');
function updateDescription() {
if (duree15min && duree15min.checked) {
const nbTranches = document.getElementById('nb-tranches')?.value || '1';
heuresDescription.textContent = `Sélectionnez les heures de début. Pour chaque heure sélectionnée, ${nbTranches} tranche(s) de 15 minutes seront créées (ex: 11h00 → 11h15).`;
} else {
heuresDescription.textContent = `Sélectionnez les heures de début. Chaque heure sélectionnée créera une tranche d'1 heure (ex: 09:00 → 10:00).`;
}
}
if (duree1h && duree15min && nbTranchesContainer) {
duree1h.addEventListener('change', function() {
nbTranchesContainer.style.display = 'none';
updateDescription();
});
duree15min.addEventListener('change', function() {
nbTranchesContainer.style.display = 'block';
updateDescription();
});
const nbTranches = document.getElementById('nb-tranches');
if (nbTranches) {
nbTranches.addEventListener('change', updateDescription);
}
}
});
</script>

View File

@ -0,0 +1,191 @@
<?php
/**
* Template pour la page admin des capacités de traduction
* Vue moderne avec structure Parent Enfant
*/
// Sécurité : vérifier que le fichier est bien appelé depuis WordPress
if (!defined('ABSPATH')) {
exit;
}
// Helper pour récupérer le(s) nom(s) de langue(s) - gère les langues multiples
function get_langue_name($post_id) {
$langue_ids = get_field('langue', $post_id);
if (empty($langue_ids)) {
return '<span class="na">Non définie</span>';
}
// S'assurer que c'est un tableau
if (!is_array($langue_ids)) {
$langue_ids = [$langue_ids];
}
$langue_names = [];
foreach ($langue_ids as $langue_id) {
$term = get_term($langue_id, 'langue');
if ($term && !is_wp_error($term)) {
$langue_names[] = esc_html($term->name);
}
}
if (empty($langue_names)) {
return '<span class="na">Non définie</span>';
}
// Si plusieurs langues, les afficher avec des badges
if (count($langue_names) > 1) {
$output = '<div class="langue-badges">';
foreach ($langue_names as $name) {
$output .= '<span class="langue-badge">' . $name . '</span>';
}
$output .= '</div>';
return $output;
}
return $langue_names[0];
}
// Helper pour récupérer les exceptions d'une capacité
function get_exceptions_info($post_id) {
$ajouter_exception = get_field('ajouter_exception', $post_id);
if (!$ajouter_exception) {
return null;
}
$exceptions = get_field('exceptions_disponibilites', $post_id);
if (empty($exceptions)) {
return null;
}
return $exceptions;
}
// Helper pour formater l'affichage de l'exception
function format_exception_display($exceptions) {
if (empty($exceptions)) {
return '';
}
$frequence = isset($exceptions['frequence']) ? $exceptions['frequence'] : '';
$frequence_periode = isset($exceptions['frequence_periode']) ? $exceptions['frequence_periode'] : '';
$periode = isset($exceptions['periode']) ? $exceptions['periode'] : '';
if (empty($frequence) || empty($frequence_periode) || empty($periode)) {
return '';
}
$periode_label = $periode === 'matin' ? 'Matin' : ($periode === 'apres_midi' ? 'Après-midi' : 'Journée');
$frequence_periode_label = $frequence_periode === 'semaine' ? 'semaine' : 'mois';
return sprintf('%d × %s / %s', $frequence, $periode_label, $frequence_periode_label);
}
// Helper pour vérifier si la capacité a des événements
function has_events_badge($post_id) {
$has_events = ESI_CRVI_AGENDA\controllers\CRVI_TraductionLangue_Controller::check_events_for_capacite($post_id);
if ($has_events) {
return '<span class="badge badge-warning" title="Cette capacité a des événements associés">🔒 Événements</span>';
}
return '';
}
?>
<div class="wrap traduction-langue-list-wrap">
<h1 class="wp-heading-inline">
<span class="dashicons dashicons-translation"></span>
Capacités de traduction
</h1>
<a href="<?php echo admin_url('post-new.php?post_type=traduction_langue'); ?>" class="page-title-action">
Ajouter une capacité
</a>
<hr class="wp-header-end">
<?php if (empty($parent_capacites)) : ?>
<div class="notice notice-info">
<p>Aucune capacité de traduction trouvée. <a href="<?php echo admin_url('post-new.php?post_type=traduction_langue'); ?>">Créer la première capacité</a></p>
</div>
<?php else : ?>
<div class="traduction-langue-grid">
<?php foreach ($parent_capacites as $parent) :
$langue_name = get_langue_name($parent->ID);
$jour = get_field('jour', $parent->ID);
$periode = get_field('periode', $parent->ID);
$limite = get_field('limite', $parent->ID);
$limite_par = get_field('limite_par', $parent->ID);
$actif = get_field('actif', $parent->ID);
$has_events = has_events_badge($parent->ID);
// Récupérer les exceptions
$exceptions = get_exceptions_info($parent->ID);
$exception_display = format_exception_display($exceptions);
// Calculer le nombre de créneaux utilisés
$model = ESI_CRVI_AGENDA\models\CRVI_TraductionLangue_Model::load($parent->ID);
$used = $model ? ESI_CRVI_AGENDA\models\CRVI_TraductionLangue_Model::countUsed($parent->ID, null, $model) : 0;
$percentage = $limite > 0 ? round(($used / $limite) * 100) : 0;
$is_full = $percentage >= 100;
?>
<div class="capacite-card <?php echo !$actif ? 'inactive' : ''; ?> <?php echo $is_full ? 'full' : ''; ?>">
<div class="card-header">
<div class="card-title">
<h2><?php echo esc_html($parent->post_title); ?></h2>
<div class="card-badges">
<?php if (!$actif) : ?>
<span class="badge badge-inactive">Inactif</span>
<?php endif; ?>
<?php echo $has_events; ?>
</div>
</div>
<div class="card-actions">
<a href="<?php echo get_edit_post_link($parent->ID); ?>" class="button button-primary">
<span class="dashicons dashicons-edit"></span> Modifier
</a>
</div>
</div>
<div class="card-body">
<div class="info-grid">
<div class="info-item">
<span class="info-label">Langue</span>
<span class="info-value"><?php echo $langue_name; ?></span>
</div>
<div class="info-item">
<span class="info-label">Jour</span>
<span class="info-value"><?php echo esc_html(ucfirst($jour ?: 'N/A')); ?></span>
</div>
<div class="info-item">
<span class="info-label">Période</span>
<span class="info-value"><?php echo esc_html(ucfirst($periode ?: 'N/A')); ?></span>
</div>
<div class="info-item">
<span class="info-label">Limite</span>
<span class="info-value"><?php echo esc_html($limite); ?> / <?php echo esc_html($limite_par); ?></span>
</div>
<?php if (!empty($exception_display)) : ?>
<div class="info-item info-item-full">
<span class="info-label">Exception</span>
<span class="info-value exception-badge"><?php echo esc_html($exception_display); ?></span>
</div>
<?php endif; ?>
</div>
<div class="usage-bar">
<div class="usage-label">
<span>Utilisation</span>
<span class="usage-stats"><?php echo $used; ?> / <?php echo $limite; ?> (<?php echo $percentage; ?>%)</span>
</div>
<div class="usage-progress">
<div class="usage-progress-bar <?php echo $percentage >= 100 ? 'full' : ($percentage >= 80 ? 'warning' : ''); ?>"
style="width: <?php echo min($percentage, 100); ?>%"></div>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>

1
templates/email/.gitkeep Normal file
View File

@ -0,0 +1 @@

View File

@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Rendez-vous clôturés</title>
</head>
<body style="font-family: Arial, sans-serif; background-color: #f7f7f7; margin:0; padding:0;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #f7f7f7; padding: 20px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" border="0" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 6px #eee; padding: 24px;">
<tr>
<td align="center" style="padding-bottom: 16px;">
<h2 style="color: #333333; margin: 0;">Liste des rendez-vous clôturés</h2>
</td>
</tr>
<tr>
<td style="padding-bottom: 16px; color: #444444; font-size: 16px;">
Voici la liste des rendez-vous clôturés récemment :
</td>
</tr>
<tr>
<td>
<table width="100%" cellpadding="8" cellspacing="0" border="1" style="border-collapse: collapse; border: 1px solid #cccccc;">
<thead>
<tr style="background-color: #f0f0f0; color: #222;">
<th align="left">Date</th>
<th align="left">Heure</th>
<th align="left">Intervenant</th>
<th align="left">Bénéficiaire</th>
</tr>
</thead>
<tbody>
<?php foreach ($datas as $rdv): ?>
<tr>
<td><?= htmlspecialchars($rdv['date'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></td>
<td><?= htmlspecialchars($rdv['heure'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></td>
<td><?= htmlspecialchars($rdv['intervenant'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></td>
<td><?= htmlspecialchars($rdv['beneficiaire'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</td>
</tr>
<tr>
<td style="padding-top: 24px; color: #888888; font-size: 13px; text-align: center;">
Ceci est un message automatique, merci de ne pas répondre directement à cet email.
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

1
templates/front/.gitkeep Normal file
View File

@ -0,0 +1 @@

View File

@ -0,0 +1,238 @@
<?php
/**
* Template Agenda Intervenant
* Affiche le calendrier complet avec filtres
* Shortcode: [crvi_intervenant_agenda]
*/
if (!defined('ABSPATH')) {
exit;
}
$user = wp_get_current_user();
?>
<div class="crvi-intervenant-agenda" id="intervenant-agenda-container">
<div class="container-fluid py-4">
<!-- En-tête -->
<div class="row mb-4">
<div class="col-12">
<h1 class="h2 mb-2">
<i class="fas fa-calendar-alt me-2"></i>Mon Agenda
</h1>
<nav class="navbar navbar-expand-lg navbar-light bg-light rounded p-3">
<div class="container-fluid">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="<?php echo esc_url(home_url('/espace-intervenant')); ?>">
<i class="fas fa-home me-1"></i>Hub
</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="<?php echo esc_url(home_url('/espace-intervenant-agenda')); ?>">
<i class="fas fa-calendar-alt me-1"></i>Agenda
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="<?php echo esc_url(home_url('/espace-intervenant-profil')); ?>">
<i class="fas fa-user me-1"></i>Mon Profil
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="<?php echo esc_url(home_url('/encodage-permanences')); ?>">
<i class="fas fa-clock me-1"></i>Permanences
</a>
</li>
</ul>
</div>
</nav>
</div>
</div>
<!-- Onglets pour basculer entre mon agenda et celui des collègues -->
<div class="row mb-3">
<div class="col-12">
<ul class="nav nav-tabs" id="agendaTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="my-agenda-tab" data-bs-toggle="tab" data-bs-target="#my-agenda" type="button" role="tab" aria-controls="my-agenda" aria-selected="true">
<i class="fas fa-calendar-check me-1"></i>Mon Agenda
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="colleagues-agenda-tab" data-bs-toggle="tab" data-bs-target="#colleagues-agenda" type="button" role="tab" aria-controls="colleagues-agenda" aria-selected="false">
<i class="fas fa-users me-1"></i>Agenda de mes collègues
</button>
</li>
</ul>
</div>
</div>
<!-- Contenu des onglets -->
<div class="tab-content" id="agendaTabsContent">
<!-- Onglet Mon Agenda -->
<div class="tab-pane fade show active" id="my-agenda" role="tabpanel" aria-labelledby="my-agenda-tab">
<section class="agenda-container">
<!-- Filtres -->
<section class="filters-container">
<form class="filters" method="get" onsubmit="return false;">
<div class="filter">
<label for="local">Local</label>
<select id="local" name="local" class="select2">
<option value="">Tous</option>
</select>
</div>
<div class="filter">
<label for="beneficiaire">Bénéficiaire</label>
<select id="beneficiaire" name="beneficiaire" class="select2">
<option value="">Tous</option>
</select>
</div>
<div class="filter">
<label for="type_rdv">Type de RDV</label>
<select id="type_rdv" name="type_rdv" class="select2">
<option value="">Tous</option>
<option value="individuel">Individuel</option>
<option value="groupe">Groupe</option>
</select>
</div>
<div class="filter">
<label for="type_intervention">Type d'intervention</label>
<select id="type_intervention" name="type_intervention" class="select2">
<option value="">Tous</option>
</select>
</div>
<div class="filter">
<label for="langue">Langue</label>
<select id="langue" name="langue" class="select2">
<option value="">Toutes</option>
</select>
</div>
<div class="filter">
<label>
<input type="checkbox" id="permanences_non_assignees" name="permanences_non_assignees" value="1">
Permanences non assignées uniquement
</label>
</div>
<div class="filter" style="display: none;">
<button type="button" id="resetFiltersBtn" class="btn btn-secondary">
<i class="fas fa-times"></i> Réinitialiser
</button>
</div>
<div class="filter">
<button type="button" id="addEventBtn" class="btn btn-success">
<i class="fas fa-plus"></i> Ajouter un événement
</button>
</div>
<div class="filter">
<button type="button" id="filterBtn" class="btn btn-primary">
<i class="fas fa-filter"></i> Filtrer
</button>
</div>
</form>
</section>
<!-- Calendrier Mon Agenda -->
<section class="agenda-inner-container" id="agenda-calendar">
<div id="loading-indicator" style="text-align: center; padding: 20px;">
<p>Chargement du calendrier...</p>
</div>
</section>
</section>
</div>
<!-- Onglet Agenda des collègues -->
<div class="tab-pane fade" id="colleagues-agenda" role="tabpanel" aria-labelledby="colleagues-agenda-tab">
<section class="agenda-container">
<!-- Filtres pour l'agenda des collègues -->
<section class="filters-container">
<form class="filters filters-colleagues" method="get" onsubmit="return false;">
<div class="filter">
<label for="local-colleagues">Local</label>
<select id="local-colleagues" name="local" class="select2">
<option value="">Tous</option>
</select>
</div>
<div class="filter">
<label for="beneficiaire-colleagues">Bénéficiaire</label>
<select id="beneficiaire-colleagues" name="beneficiaire" class="select2">
<option value="">Tous</option>
</select>
</div>
<div class="filter">
<label for="type_rdv-colleagues">Type de RDV</label>
<select id="type_rdv-colleagues" name="type_rdv" class="select2">
<option value="">Tous</option>
<option value="individuel">Individuel</option>
<option value="groupe">Groupe</option>
</select>
</div>
<div class="filter">
<label for="type_intervention-colleagues">Type d'intervention</label>
<select id="type_intervention-colleagues" name="type_intervention" class="select2">
<option value="">Tous</option>
</select>
</div>
<div class="filter">
<label for="langue-colleagues">Langue</label>
<select id="langue-colleagues" name="langue" class="select2">
<option value="">Toutes</option>
</select>
</div>
<div class="filter" style="display: none;">
<button type="button" id="resetFiltersBtn-colleagues" class="btn btn-secondary">
<i class="fas fa-times"></i> Réinitialiser
</button>
</div>
<div class="filter">
<button type="button" id="filterBtn-colleagues" class="btn btn-primary">
<i class="fas fa-filter"></i> Filtrer
</button>
</div>
</form>
</section>
<!-- Calendrier Agenda des collègues -->
<section class="agenda-inner-container" id="agenda-calendar-colleagues">
<div id="loading-indicator-colleagues" style="text-align: center; padding: 20px;">
<p>Chargement du calendrier des collègues...</p>
</div>
</section>
</section>
</div>
</div>
</div>
</div>
<style>
.crvi-intervenant-agenda {
min-height: 60vh;
}
#agenda-calendar {
min-height: 600px;
}
.card {
border: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.card-header.bg-light {
background-color: #f8f9fa !important;
border-bottom: 1px solid #dee2e6;
}
@media (max-width: 768px) {
.col-md-3 {
margin-bottom: 1rem;
}
#agenda-calendar {
min-height: 400px;
}
}
</style>
<!-- Le modal est injecté automatiquement dans le footer via wp_footer par Intervenant_Space_Controller::maybe_render_rdv_modal() -->

View File

@ -0,0 +1,239 @@
<?php
/**
* Template Agenda Intervenant
* Affiche le calendrier complet avec filtres
* Shortcode: [crvi_intervenant_agenda]
*/
if (!defined('ABSPATH')) {
exit;
}
$user = wp_get_current_user();
?>
<div class="crvi-intervenant-agenda" id="intervenant-agenda-container">
<div class="container-fluid py-4">
<!-- En-tête -->
<div class="row mb-4">
<div class="col-12">
<h1 class="h2 mb-2">
<i class="fas fa-calendar-alt me-2"></i>Mon Agenda
</h1>
<nav class="navbar navbar-expand-lg navbar-light bg-light rounded p-3">
<div class="container-fluid">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="<?php echo esc_url(home_url('/espace-intervenant')); ?>">
<i class="fas fa-home me-1"></i>Hub
</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="<?php echo esc_url(home_url('/espace-intervenant-agenda')); ?>">
<i class="fas fa-calendar-alt me-1"></i>Agenda
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="<?php echo esc_url(home_url('/espace-intervenant-profil')); ?>">
<i class="fas fa-user me-1"></i>Mon Profil
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="<?php echo esc_url(home_url('/encodage-permanences')); ?>">
<i class="fas fa-clock me-1"></i>Permanences
</a>
</li>
</ul>
</div>
</nav>
</div>
</div>
<!-- Onglets pour basculer entre mon agenda et celui des collègues -->
<div class="row mb-3">
<div class="col-12">
<ul class="nav nav-tabs" id="agendaTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="my-agenda-tab" data-bs-toggle="tab" data-bs-target="#my-agenda" type="button" role="tab" aria-controls="my-agenda" aria-selected="true">
<i class="fas fa-calendar-check me-1"></i>Mon Agenda
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="colleagues-agenda-tab" data-bs-toggle="tab" data-bs-target="#colleagues-agenda" type="button" role="tab" aria-controls="colleagues-agenda" aria-selected="false">
<i class="fas fa-users me-1"></i>Agenda de mes collègues
</button>
</li>
</ul>
</div>
</div>
<!-- Contenu des onglets -->
<div class="tab-content" id="agendaTabsContent">
<!-- Onglet Mon Agenda -->
<div class="tab-pane fade show active" id="my-agenda" role="tabpanel" aria-labelledby="my-agenda-tab">
<section class="agenda-container">
<!-- Filtres -->
<section class="filters-container">
<form class="filters" method="get" onsubmit="return false;">
<div class="filter">
<label for="local">Local</label>
<select id="local" name="local" class="select2">
<option value="">Tous</option>
</select>
</div>
<div class="filter">
<label for="beneficiaire">Bénéficiaire</label>
<select id="beneficiaire" name="beneficiaire" class="select2">
<option value="">Tous</option>
</select>
</div>
<div class="filter">
<label for="type_rdv">Type de RDV</label>
<select id="type_rdv" name="type_rdv" class="select2">
<option value="">Tous</option>
<option value="individuel">Individuel</option>
<option value="groupe">Groupe</option>
</select>
</div>
<div class="filter">
<label for="type_intervention">Type d'intervention</label>
<select id="type_intervention" name="type_intervention" class="select2">
<option value="">Tous</option>
</select>
</div>
<div class="filter">
<label for="langue">Langue</label>
<select id="langue" name="langue" class="select2">
<option value="">Toutes</option>
</select>
</div>
<div class="filter">
<label>
<input type="checkbox" id="permanences_non_assignees" name="permanences_non_assignees" value="1">
Permanences non assignées uniquement
</label>
</div>
<div class="filter" style="display: none;">
<button type="button" id="resetFiltersBtn" class="btn btn-secondary">
<i class="fas fa-times"></i> Réinitialiser
</button>
</div>
<div class="filter">
<button type="button" id="addEventBtn" class="btn btn-success">
<i class="fas fa-plus"></i> Ajouter un événement
</button>
</div>
<div class="filter">
<button type="button" id="filterBtn" class="btn btn-primary">
<i class="fas fa-filter"></i> Filtrer
</button>
</div>
</form>
</section>
<!-- Calendrier Mon Agenda -->
<section class="agenda-inner-container" id="agenda-calendar">
<div id="loading-indicator" style="text-align: center; padding: 20px;">
<p>Chargement du calendrier...</p>
</div>
</section>
</section>
</div>
<!-- Onglet Agenda des collègues -->
<div class="tab-pane fade" id="colleagues-agenda" role="tabpanel" aria-labelledby="colleagues-agenda-tab">
<section class="agenda-container">
<!-- Filtres pour l'agenda des collègues -->
<section class="filters-container">
<form class="filters filters-colleagues" method="get" onsubmit="return false;">
<div class="filter">
<label for="local-colleagues">Local</label>
<select id="local-colleagues" name="local" class="select2">
<option value="">Tous</option>
</select>
</div>
<div class="filter">
<label for="beneficiaire-colleagues">Bénéficiaire</label>
<select id="beneficiaire-colleagues" name="beneficiaire" class="select2">
<option value="">Tous</option>
</select>
</div>
<div class="filter">
<label for="type_rdv-colleagues">Type de RDV</label>
<select id="type_rdv-colleagues" name="type_rdv" class="select2">
<option value="">Tous</option>
<option value="individuel">Individuel</option>
<option value="groupe">Groupe</option>
</select>
</div>
<div class="filter">
<label for="type_intervention-colleagues">Type d'intervention</label>
<select id="type_intervention-colleagues" name="type_intervention" class="select2">
<option value="">Tous</option>
</select>
</div>
<div class="filter">
<label for="langue-colleagues">Langue</label>
<select id="langue-colleagues" name="langue" class="select2">
<option value="">Toutes</option>
</select>
</div>
<div class="filter" style="display: none;">
<button type="button" id="resetFiltersBtn-colleagues" class="btn btn-secondary">
<i class="fas fa-times"></i> Réinitialiser
</button>
</div>
<div class="filter">
<button type="button" id="filterBtn-colleagues" class="btn btn-primary">
<i class="fas fa-filter"></i> Filtrer
</button>
</div>
</form>
</section>
<!-- Calendrier Agenda des collègues -->
<section class="agenda-inner-container" id="agenda-calendar-colleagues">
<div id="loading-indicator-colleagues" style="text-align: center; padding: 20px;">
<p>Chargement du calendrier des collègues...</p>
</div>
</section>
</section>
</div>
</div>
</div>
</div>
<style>
.crvi-intervenant-agenda {
min-height: 60vh;
}
#agenda-calendar {
min-height: 600px;
}
.card {
border: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.card-header.bg-light {
background-color: #f8f9fa !important;
border-bottom: 1px solid #dee2e6;
}
@media (max-width: 768px) {
.col-md-3 {
margin-bottom: 1rem;
}
#agenda-calendar {
min-height: 400px;
}
}
</style>
<!-- Modal de création/édition d'événements -->
<?php require_once dirname(__DIR__, 1) . '/modules/agenda-modal.php'; ?>

View File

@ -0,0 +1,191 @@
<?php
/**
* Template Hub Intervenant
* Affiche le tableau de bord avec les RDV du jour et actions rapides
* Shortcode: [crvi_intervenant_hub]
*/
if (!defined('ABSPATH')) {
exit;
}
$user = wp_get_current_user();
$intervenant_nom = trim($user->first_name . ' ' . $user->last_name);
$today = date('d/m/Y');
?>
<div class="crvi-intervenant-hub" id="intervenant-hub-container">
<div class="container-fluid py-4">
<!-- En-tête -->
<div class="row mb-4">
<div class="col-12">
<h1 class="h2 mb-2">
<i class="fas fa-home me-2"></i>Mon Espace Intervenant
</h1>
<p class="text-muted">Bonjour <?php echo esc_html($intervenant_nom); ?></p>
</div>
</div>
<!-- Section RDV du jour -->
<div class="row mb-4">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h3 class="h5 mb-0">
<i class="fas fa-calendar-day me-2"></i>
Mes rendez-vous aujourd'hui (<?php echo esc_html($today); ?>)
</h3>
</div>
<div class="card-body">
<div id="rdv-today-container" class="rdv-list">
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
<p class="mt-2 text-muted">Chargement des rendez-vous...</p>
</div>
</div>
<div id="rdv-today-empty" class="alert alert-info" style="display: none;">
<i class="fas fa-info-circle me-2"></i>
Aucun rendez-vous prévu pour aujourd'hui.
</div>
</div>
</div>
</div>
</div>
<!-- Actions rapides -->
<div class="row mb-4">
<div class="col-md-6 mb-3">
<a href="<?php echo esc_url(home_url('/espace-intervenant-agenda')); ?>" class="card card-hover text-decoration-none h-100">
<div class="card-body text-center">
<i class="fas fa-calendar-alt fa-3x text-primary mb-3"></i>
<h4 class="h5">Voir mon agenda complet</h4>
<p class="text-muted mb-0">Consulter tous mes rendez-vous</p>
</div>
</a>
</div>
<div class="col-md-6 mb-3">
<a href="<?php echo esc_url(home_url('/espace-intervenant-profil')); ?>" class="card card-hover text-decoration-none h-100">
<div class="card-body text-center">
<i class="fas fa-user-cog fa-3x text-secondary mb-3"></i>
<h4 class="h5">Modifier mes informations</h4>
<p class="text-muted mb-0">Gérer mon profil</p>
</div>
</a>
</div>
</div>
<!-- Section Permanences -->
<div class="row">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-light">
<h3 class="h5 mb-0">
<i class="fas fa-clock me-2"></i>Mes Permanences
</h3>
</div>
<div class="card-body">
<p class="text-muted mb-3">Gérez vos créneaux de permanence disponibles.</p>
<a href="<?php echo esc_url(home_url('/encodage-permanences')); ?>" class="btn btn-outline-primary">
<i class="fas fa-plus me-2"></i>Encoder mes permanences
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Inclusion du modal de détails RDV (si nécessaire) -->
<?php include plugin_dir_path(__FILE__) . 'intervenant-modal-rdv.php'; ?>
<!-- Template pour un RDV (utilisé en JavaScript) -->
<template id="rdv-item-template">
<div class="rdv-item card mb-3">
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-2">
<div class="rdv-time text-center">
<strong class="d-block fs-5" data-time-start></strong>
<span class="text-muted small" data-time-end></span>
</div>
</div>
<div class="col-md-3">
<p class="mb-1">
<i class="fas fa-map-marker-alt text-primary me-2"></i>
<strong data-local></strong>
</p>
</div>
<div class="col-md-4">
<p class="mb-1">
<i class="fas fa-user text-info me-2"></i>
<span data-beneficiaire></span>
</p>
<p class="mb-0 small text-muted" data-type-intervention></p>
</div>
<div class="col-md-3 text-end">
<div class="btn-group" role="group">
<button type="button" class="btn btn-success btn-sm mark-presence" data-statut="present" data-event-id>
<i class="fas fa-check me-1"></i>Présent
</button>
<button type="button" class="btn btn-danger btn-sm mark-presence" data-statut="absent" data-event-id>
<i class="fas fa-times me-1"></i>Absent
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<style>
.crvi-intervenant-hub {
min-height: 60vh;
}
.card-hover {
transition: transform 0.2s, box-shadow 0.2s;
}
.card-hover:hover {
transform: translateY(-5px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
}
.rdv-item {
border-left: 4px solid #0d6efd;
transition: all 0.2s;
}
.rdv-item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.rdv-time strong {
color: #0d6efd;
}
.btn-group .btn {
min-width: 100px;
}
@media (max-width: 768px) {
.rdv-item .row > div {
margin-bottom: 1rem;
}
.rdv-item .text-end {
text-align: left !important;
}
.btn-group {
width: 100%;
}
.btn-group .btn {
flex: 1;
}
}
</style>

View File

@ -0,0 +1,175 @@
<?php
/**
* Modal de détails RDV pour l'espace intervenant
* Affiche les détails d'un rendez-vous (lecture seule)
* Les commentaires ne sont pas affichés pour les RDV des collègues
*/
if (!defined('ABSPATH')) {
exit;
}
?>
<!-- Modal Détails RDV -->
<div class="modal fade" id="rdvModal" tabindex="-1" aria-labelledby="rdvModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="rdvModalLabel">
<i class="fas fa-calendar-check me-2"></i>Détails du rendez-vous
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fermer"></button>
</div>
<div class="modal-body">
<div class="row">
<!-- Colonne gauche -->
<div class="col-md-6">
<!-- Date et heure -->
<div class="mb-3">
<p class="mb-1 text-muted small">
<i class="fas fa-calendar me-2"></i>Date
</p>
<p class="fw-bold" id="modal-date-rdv">-</p>
</div>
<div class="mb-3">
<p class="mb-1 text-muted small">
<i class="fas fa-clock me-2"></i>Heure
</p>
<p class="fw-bold" id="modal-heure-rdv">-</p>
</div>
<!-- Type de RDV -->
<div class="mb-3">
<p class="mb-1 text-muted small">
<i class="fas fa-users me-2"></i>Type
</p>
<p class="fw-bold" id="modal-type">-</p>
</div>
<!-- Langue -->
<div class="mb-3">
<p class="mb-1 text-muted small">
<i class="fas fa-language me-2"></i>Langue
</p>
<p class="fw-bold" id="modal-langue">-</p>
</div>
<!-- Bénéficiaire -->
<div class="mb-3">
<p class="mb-1 text-muted small">
<i class="fas fa-user me-2"></i>Bénéficiaire
</p>
<p class="fw-bold" id="modal-beneficiaire">-</p>
</div>
<!-- Intervenant -->
<div class="mb-3">
<p class="mb-1 text-muted small">
<i class="fas fa-user-md me-2"></i>Intervenant
</p>
<p class="fw-bold" id="modal-intervenant">-</p>
</div>
</div>
<!-- Colonne droite -->
<div class="col-md-6">
<!-- Local -->
<div class="mb-3">
<p class="mb-1 text-muted small">
<i class="fas fa-map-marker-alt me-2"></i>Local
</p>
<p class="fw-bold" id="modal-local">-</p>
</div>
<!-- Traducteur -->
<div class="mb-3">
<p class="mb-1 text-muted small">
<i class="fas fa-language me-2"></i>Traducteur
</p>
<p class="fw-bold" id="modal-traducteur">-</p>
</div>
<!-- Département -->
<div class="mb-3">
<p class="mb-1 text-muted small">
<i class="fas fa-building me-2"></i>Département
</p>
<p class="fw-bold" id="modal-departement">-</p>
</div>
<!-- Type d'intervention -->
<div class="mb-3">
<p class="mb-1 text-muted small">
<i class="fas fa-tools me-2"></i>Type d'intervention
</p>
<p class="fw-bold" id="modal-type-intervention">-</p>
</div>
<!-- Statut -->
<div class="mb-3">
<p class="mb-1 text-muted small">
<i class="fas fa-info-circle me-2"></i>Statut
</p>
<p>
<span class="badge" id="modal-statut-badge">-</span>
</p>
</div>
<!-- Commentaire (seulement si is_mine = true) -->
<div class="mb-3" id="modal-commentaire-container" style="display: none;">
<p class="mb-1 text-muted small">
<i class="fas fa-comment me-2"></i>Commentaire
</p>
<p class="fw-normal" id="modal-commentaire">-</p>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" id="rdv-edit-btn" style="display: none;">
<i class="fas fa-edit me-1"></i>Modifier
</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fas fa-times me-1"></i>Fermer
</button>
</div>
</div>
</div>
</div>
<style>
/* Forcer la modale RDV à passer au-dessus des éléments Avada (toTop, cadres, etc.) */
/* #rdvModal.modal {
z-index: 200001 !important;
}
.modal-backdrop {
z-index: 200000 !important;
} */
#rdvModal .modal-body .text-muted {
font-size: 0.875rem;
}
#rdvModal .modal-body .fw-bold {
color: #212529;
font-size: 1rem;
}
#rdvModal .badge {
font-size: 0.875rem;
padding: 0.5rem 0.75rem;
}
@media (max-width: 768px) {
.modal-dialog.modal-lg {
max-width: 95%;
}
.col-md-6 {
margin-bottom: 1rem;
}
}
</style>

View File

@ -0,0 +1,552 @@
<?php
/**
* Template Permanences Intervenant
* Permet à l'intervenant d'encoder ses permanences
* Shortcode: [crvi_intervenant_permanences]
*/
if (!defined('ABSPATH')) {
exit;
}
// Récupérer toutes les langues disponibles depuis la taxonomie
$langues_terms = get_terms([
'taxonomy' => 'langue',
'hide_empty' => false,
'orderby' => 'name',
'order' => 'ASC',
]);
$langues = [];
if (!is_wp_error($langues_terms) && !empty($langues_terms)) {
foreach ($langues_terms as $term) {
$langues[] = [
'id' => $term->slug,
'nom' => $term->name,
];
}
}
?>
<div class="crvi-intervenant-permanences" id="intervenant-permanences-container">
<div class="container-fluid py-4">
<!-- En-tête -->
<div class="row mb-4">
<div class="col-12">
<h1 class="h2 mb-2">
<i class="fas fa-clock me-2"></i>Mes Permanences
</h1>
<nav class="navbar navbar-expand-lg navbar-light bg-light rounded p-3">
<div class="container-fluid">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="<?php echo esc_url(home_url('/espace-intervenant')); ?>">
<i class="fas fa-home me-1"></i>Hub
</a>
</li>
<li class="nav-item">
<a class="nav-link"
href="<?php echo esc_url(home_url('/espace-intervenant-agenda')); ?>">
<i class="fas fa-calendar-alt me-1"></i>Agenda
</a>
</li>
<li class="nav-item">
<a class="nav-link"
href="<?php echo esc_url(home_url('/espace-intervenant-profil')); ?>">
<i class="fas fa-user me-1"></i>Mon Profil
</a>
</li>
<li class="nav-item">
<a class="nav-link active"
href="<?php echo esc_url(home_url('/encodage-permanences')); ?>">
<i class="fas fa-clock me-1"></i>Permanences
</a>
</li>
</ul>
</div>
</nav>
</div>
</div>
<div class="row">
<div class="col-12">
<!-- Navigation par onglets -->
<ul class="nav nav-tabs mb-4" id="permanences-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="creation-manuelle-tab" data-bs-toggle="tab"
data-bs-target="#creation-manuelle" type="button" role="tab"
aria-controls="creation-manuelle" aria-selected="true">
<i class="fas fa-plus-circle me-2"></i>Création manuelle
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="import-csv-tab" data-bs-toggle="tab" data-bs-target="#import-csv"
type="button" role="tab" aria-controls="import-csv" aria-selected="false">
<i class="fas fa-file-csv me-2"></i>Import CSV
</button>
</li>
</ul>
<!-- Contenu des onglets -->
<div class="tab-content" id="permanences-tab-content">
<!-- Onglet 1 : Création manuelle -->
<div class="tab-pane show active" id="creation-manuelle" role="tabpanel"
aria-labelledby="creation-manuelle-tab">
<form id="permanences-form">
<!-- Section 1 : Sélection des permanences -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-primary text-white">
<h3 class="h5 mb-0">
<i class="fas fa-calendar-check me-2"></i>SECTION 1 : Sélection des permanences
</h3>
</div>
<div class="card-body">
<!-- Mois de début -->
<div class="mb-4">
<label for="mois-debut" class="form-label fw-bold">Mois de début :</label>
<input type="month" class="form-control form-control-lg" id="mois-debut"
name="mois_debut" required>
<small class="form-text text-muted">
Sélectionnez le mois et l'année à partir desquels les permanences seront
créées.
</small>
</div>
<!-- Période -->
<div class="mb-4">
<label class="form-label fw-bold">Période :</label>
<div class="btn-group" role="group" aria-label="Période">
<input type="radio" class="btn-check" name="periode" id="periode-3"
value="3" checked>
<label class="btn btn-outline-primary" for="periode-3">3 mois</label>
<input type="radio" class="btn-check" name="periode" id="periode-6"
value="6">
<label class="btn btn-outline-primary" for="periode-6">6 mois</label>
</div>
</div>
<!-- Jours de la semaine -->
<div class="mb-4">
<label class="form-label fw-bold">Jours de la semaine :</label>
<div class="row g-2">
<div class="col-md-3 col-sm-4 col-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="jours[]"
value="lundi" id="jour-lundi">
<label class="form-check-label" for="jour-lundi">Lundi</label>
</div>
</div>
<div class="col-md-3 col-sm-4 col-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="jours[]"
value="mardi" id="jour-mardi">
<label class="form-check-label" for="jour-mardi">Mardi</label>
</div>
</div>
<div class="col-md-3 col-sm-4 col-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="jours[]"
value="mercredi" id="jour-mercredi">
<label class="form-check-label" for="jour-mercredi">Mercredi</label>
</div>
</div>
<div class="col-md-3 col-sm-4 col-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="jours[]"
value="jeudi" id="jour-jeudi">
<label class="form-check-label" for="jour-jeudi">Jeudi</label>
</div>
</div>
<div class="col-md-3 col-sm-4 col-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="jours[]"
value="vendredi" id="jour-vendredi">
<label class="form-check-label" for="jour-vendredi">Vendredi</label>
</div>
</div>
<div class="col-md-3 col-sm-4 col-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="jours[]"
value="samedi" id="jour-samedi">
<label class="form-check-label" for="jour-samedi">Samedi</label>
</div>
</div>
<div class="col-md-3 col-sm-4 col-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="jours[]"
value="dimanche" id="jour-dimanche">
<label class="form-check-label" for="jour-dimanche">Dimanche</label>
</div>
</div>
</div>
</div>
<!-- Durée de permanence -->
<div class="mb-4">
<label class="form-label fw-bold">Durée de permanence :</label>
<div class="btn-group mb-3" role="group" aria-label="Durée">
<input type="radio" class="btn-check" name="duree_permanence" id="duree-1h"
value="1h" checked>
<label class="btn btn-outline-primary" for="duree-1h">1 heure</label>
<input type="radio" class="btn-check" name="duree_permanence" id="duree-15min"
value="15min">
<label class="btn btn-outline-primary" for="duree-15min">1/4 d'heure (15 min)</label>
</div>
<small class="form-text text-muted d-block mb-3">
Choisissez la durée de chaque permanence.
</small>
</div>
<!-- Nombre de tranches (si 1/4 d'heure) -->
<div class="mb-4" id="nb-tranches-container" style="display: none;">
<label for="nb-tranches" class="form-label fw-bold">Nombre de tranches par heure :</label>
<select class="form-select form-select-lg" id="nb-tranches" name="nb_tranches">
<option value="1">1 tranche (ex: 11h00 11h15)</option>
<option value="2">2 tranches (ex: 11h00 11h15, 11h15 11h30)</option>
<option value="3">3 tranches (ex: 11h00 11h15, 11h15 11h30, 11h30 11h45)</option>
<option value="4">4 tranches (ex: 11h00 11h15, 11h15 11h30, 11h30 11h45, 11h45 12h00)</option>
</select>
<small class="form-text text-muted">
Si vous choisissez 1/4 d'heure, indiquez combien de tranches de 15 minutes créer pour chaque heure sélectionnée.
</small>
</div>
<!-- Heures de permanence -->
<div class="mb-4">
<label class="form-label fw-bold">Heures de permanence :</label>
<small class="form-text text-muted d-block mb-3" id="heures-description">
Sélectionnez les heures de début. Chaque heure sélectionnée créera une
tranche d'1 heure (ex: 09:00 10:00).
</small>
<div class="row g-2">
<div class="col-12">
<label class="form-label fw-semibold text-muted">Matin :</label>
</div>
<?php
// Plage matin : 09:00 à 12:00
for ($h = 9; $h <= 12; $h++):
$heure = str_pad($h, 2, '0', STR_PAD_LEFT) . ':00';
$heureFin = str_pad($h + 1, 2, '0', STR_PAD_LEFT) . ':00';
?>
<div class="col-md-3 col-sm-4 col-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="heures[]"
value="<?php echo esc_attr($heure); ?>"
id="heure-<?php echo $h; ?>">
<label class="form-check-label" for="heure-<?php echo $h; ?>">
<?php echo $heure; ?> → <?php echo $heureFin; ?>
</label>
</div>
</div>
<?php endfor; ?>
<div class="col-12 mt-2">
<label class="form-label fw-semibold text-muted">Après-midi :</label>
</div>
<?php
// Plage après-midi : 13:00 à 16:00
for ($h = 13; $h <= 16; $h++):
$heure = str_pad($h, 2, '0', STR_PAD_LEFT) . ':00';
$heureFin = str_pad($h + 1, 2, '0', STR_PAD_LEFT) . ':00';
?>
<div class="col-md-3 col-sm-4 col-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="heures[]"
value="<?php echo esc_attr($heure); ?>"
id="heure-<?php echo $h; ?>">
<label class="form-check-label" for="heure-<?php echo $h; ?>">
<?php echo $heure; ?> → <?php echo $heureFin; ?>
</label>
</div>
</div>
<?php endfor; ?>
</div>
</div>
<!-- Aperçu des tranches générées -->
<div class="mb-3">
<label class="form-label fw-bold">Aperçu des tranches générées :</label>
<div id="tranches-preview" class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
Veuillez sélectionner une plage horaire pour voir l'aperçu.
</div>
</div>
<!-- Estimation -->
<div class="mb-3">
<div id="estimation-container" class="alert alert-warning"
style="display: none;">
<i class="fas fa-calculator me-2"></i>
<strong>Estimation :</strong> <span id="estimation-count">0</span>
événements seront créés
</div>
</div>
</div>
</div>
<!-- Section 2 : Langues -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-info text-white">
<h3 class="h5 mb-0">
<i class="fas fa-language me-2"></i>SECTION 2 : Langues des permanences
</h3>
</div>
<div class="card-body">
<div class="mb-3">
<label for="langues-permanences" class="form-label fw-bold">Langues disponibles (optionnel) :</label>
<select class="form-select form-select-lg" id="langues-permanences" name="langues[]" multiple>
<?php if (!empty($langues)): ?>
<?php foreach ($langues as $langue): ?>
<option value="<?php echo esc_attr($langue['id']); ?>">
<?php echo esc_html($langue['nom']); ?>
</option>
<?php endforeach; ?>
<?php else: ?>
<option value="">Aucune langue disponible</option>
<?php endif; ?>
</select>
<small class="form-text text-muted">
Sélectionnez une ou plusieurs langues pour ces permanences.
</small>
</div>
</div>
</div>
<!-- Section 3 : Informations complémentaires -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-secondary text-white">
<h3 class="h5 mb-0">
<i class="fas fa-sticky-note me-2"></i>SECTION 3 : Informations complémentaires
</h3>
</div>
<div class="card-body">
<div class="mb-3">
<label for="informations-complementaires" class="form-label">Notes ou
commentaires (optionnel)</label>
<textarea class="form-control" id="informations-complementaires"
name="informations_complementaires" rows="3"
placeholder="Ajoutez des notes ou commentaires sur ces permanences..."></textarea>
<small class="form-text text-muted">
Ces informations seront associées aux permanences créées.
</small>
</div>
</div>
</div>
<!-- Bouton de soumission -->
<div class="card shadow-sm">
<div class="card-body text-center">
<button type="submit" class="btn btn-primary btn-lg" id="submit-permanences-btn">
<i class="fas fa-save me-2"></i>Enregistrer mes permanences
</button>
<button type="reset" class="btn btn-outline-secondary btn-lg ms-2"
id="reset-permanences-btn">
<i class="fas fa-redo me-2"></i>Réinitialiser
</button>
</div>
</div>
</form>
</div>
<!-- Onglet 2 : Import CSV -->
<div class="tab-pane" id="import-csv" role="tabpanel" aria-labelledby="import-csv-tab">
<div class="card shadow-sm">
<div class="card-header bg-info text-white">
<h3 class="h5 mb-0">
<i class="fas fa-file-csv me-2"></i>Import de permanences via CSV
</h3>
</div>
<div class="card-body">
<div class="alert alert-info mb-4">
<i class="fas fa-info-circle me-2"></i>
<strong>Format CSV attendu :</strong> Le fichier CSV doit contenir les colonnes
suivantes :
<ul class="mt-2 mb-0">
<li><code>date_debut</code> : Date de début (format YYYY-MM-DD)</li>
<li><code>date_fin</code> : Date de fin (format YYYY-MM-DD)</li>
<li><code>heure_debut</code> : Heure de début (format HH:MM)</li>
<li><code>heure_fin</code> : Heure de fin (format HH:MM)</li>
<li><code>informations_complementaires</code> : Notes (optionnel)</li>
</ul>
<p class="mt-2 mb-0"><small><em>Note : Les permanences seront créées pour votre
compte.</em></small></p>
</div>
<form id="import-csv-form" enctype="multipart/form-data">
<div class="mb-3">
<label for="csv-file" class="form-label fw-bold">Sélectionner un fichier CSV
:</label>
<input type="file" class="form-control form-control-lg" id="csv-file"
name="csv_file" accept=".csv" required>
<small class="form-text text-muted">
Format accepté : CSV (séparateur virgule)
</small>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-info btn-lg" id="submit-csv-import-btn">
<i class="fas fa-upload me-2"></i>Importer le CSV
</button>
<button type="reset" class="btn btn-outline-secondary btn-lg"
id="reset-csv-import-btn">
<i class="fas fa-redo me-2"></i>Réinitialiser
</button>
</div>
</form>
<div id="csv-import-result" class="mt-4" style="display: none;"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.crvi-intervenant-permanences {
min-height: 60vh;
}
/* Augmentation de la largeur de la page */
.crvi-intervenant-permanences .container-fluid {
max-width: 100%;
padding-left: 20px;
padding-right: 20px;
}
.btn-group .btn-check:checked+.btn {
background-color: #0d6efd;
border-color: #0d6efd;
color: white;
}
.form-check-input:checked {
background-color: #0d6efd;
border-color: #0d6efd;
}
#tranches-preview {
min-height: 60px;
}
#tranches-preview ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
#tranches-preview li {
background-color: #e7f3ff;
padding: 0.5rem 1rem;
border-radius: 4px;
border: 1px solid #b3d9ff;
}
.alert {
border-left: 4px solid;
}
.alert-info {
border-left-color: #0dcaf0;
}
.alert-warning {
border-left-color: #ffc107;
}
/* Styles pour les onglets */
.nav-tabs {
border-bottom: 2px solid #dee2e6;
}
.nav-tabs .nav-link {
color: #495057;
border: none;
border-bottom: 3px solid transparent;
padding: 0.75rem 1.5rem;
transition: all 0.3s ease;
}
.nav-tabs .nav-link:hover {
border-color: #dee2e6;
background-color: #f8f9fa;
}
.nav-tabs .nav-link.active {
color: #0d6efd;
background-color: transparent;
border-color: #0d6efd;
font-weight: 600;
}
.tab-content {
padding-top: 1.5rem;
}
/* Amélioration de l'affichage des résultats d'import */
#csv-import-result {
margin-top: 1.5rem;
}
#csv-import-result .alert {
border-radius: 0.375rem;
}
@media (max-width: 768px) {
.col-lg-10 {
padding: 0 1rem;
}
.btn-lg {
width: 100%;
margin-bottom: 0.5rem;
}
.ms-2 {
margin-left: 0 !important;
}
}
</style>
<script>
// Gérer l'affichage conditionnel du champ nombre de tranches
document.addEventListener('DOMContentLoaded', function() {
const duree1h = document.getElementById('duree-1h');
const duree15min = document.getElementById('duree-15min');
const nbTranchesContainer = document.getElementById('nb-tranches-container');
const heuresDescription = document.getElementById('heures-description');
function updateDescription() {
if (duree15min && duree15min.checked) {
const nbTranches = document.getElementById('nb-tranches')?.value || '1';
heuresDescription.textContent = `Sélectionnez les heures de début. Pour chaque heure sélectionnée, ${nbTranches} tranche(s) de 15 minutes seront créées (ex: 11h00 → 11h15).`;
} else {
heuresDescription.textContent = `Sélectionnez les heures de début. Chaque heure sélectionnée créera une tranche d'1 heure (ex: 09:00 → 10:00).`;
}
}
if (duree1h && duree15min && nbTranchesContainer) {
duree1h.addEventListener('change', function() {
nbTranchesContainer.style.display = 'none';
updateDescription();
});
duree15min.addEventListener('change', function() {
nbTranchesContainer.style.display = 'block';
updateDescription();
});
const nbTranches = document.getElementById('nb-tranches');
if (nbTranches) {
nbTranches.addEventListener('change', updateDescription);
}
}
});
</script>

View File

@ -0,0 +1,403 @@
<?php
/**
* Template Profil Intervenant
* Affiche le formulaire de gestion du compte et des informations personnelles
* Shortcode: [crvi_intervenant_profil]
*/
if (!defined('ABSPATH')) {
exit;
}
$user = wp_get_current_user();
$user_id = $user->ID;
// Récupérer tous les champs ACF pour pré-remplir le formulaire
$telephone = '';
$jours_disponibilite = [];
$indisponibilites_ponctuelles = [];
$heures_de_permanences = [];
if (function_exists('get_field')) {
$telephone = get_field('telephone', 'user_' . $user_id);
if (!is_string($telephone)) {
$telephone = '';
}
$jours_disponibilite = get_field('jours_de_disponibilite', 'user_' . $user_id);
if (!is_array($jours_disponibilite)) {
$jours_disponibilite = [];
}
$indisponibilites_ponctuelles = get_field('indisponibilitee_ponctuelle', 'user_' . $user_id);
if (!is_array($indisponibilites_ponctuelles)) {
$indisponibilites_ponctuelles = [];
}
$heures_de_permanences = get_field('heures_de_permanences', 'user_' . $user_id);
if (!is_array($heures_de_permanences)) {
$heures_de_permanences = [];
}
}
// Labels des types d'indisponibilité
$types_indisponibilite = [
'conge' => 'Congé',
'absence' => 'Absence',
'maladie' => 'Maladie'
];
// Fonction pour convertir une date au format d/m/Y vers YYYY-MM-DD pour les champs date
function convert_date_to_input($date_str) {
if (empty($date_str)) {
return '';
}
// Si déjà au format YYYY-MM-DD, retourner tel quel
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date_str)) {
return $date_str;
}
// Si au format d/m/Y, convertir
if (preg_match('/^(\d{2})\/(\d{2})\/(\d{4})$/', $date_str, $matches)) {
return $matches[3] . '-' . $matches[2] . '-' . $matches[1];
}
return '';
}
// Labels des jours
$jours_labels = [
'lundi' => 'Lundi',
'mardi' => 'Mardi',
'mercredi' => 'Mercredi',
'jeudi' => 'Jeudi',
'vendredi' => 'Vendredi',
'samedi' => 'Samedi',
'dimanche' => 'Dimanche'
];
?>
<div class="crvi-intervenant-profile" id="intervenant-profile-container">
<div class="container-fluid py-4">
<!-- En-tête -->
<div class="row mb-4">
<div class="col-12">
<h1 class="h2 mb-2">
<i class="fas fa-user me-2"></i>Mon Profil
</h1>
<nav class="navbar navbar-expand-lg navbar-light bg-light rounded p-3">
<div class="container-fluid">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="<?php echo esc_url(home_url('/espace-intervenant')); ?>">
<i class="fas fa-home me-1"></i>Hub
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="<?php echo esc_url(home_url('/espace-intervenant-agenda')); ?>">
<i class="fas fa-calendar-alt me-1"></i>Agenda
</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="<?php echo esc_url(home_url('/espace-intervenant-profil')); ?>">
<i class="fas fa-user me-1"></i>Mon Profil
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="<?php echo esc_url(home_url('/encodage-permanences')); ?>">
<i class="fas fa-clock me-1"></i>Permanences
</a>
</li>
</ul>
</div>
</nav>
</div>
</div>
<!-- Afficher les messages de conflit d'indisponibilités -->
<?php
if (class_exists('ESI_CRVI_AGENDA\controllers\CRVI_Plugin')) {
\ESI_CRVI_AGENDA\controllers\CRVI_Plugin::display_intervenant_conflicts_frontend();
}
?>
<div class="row">
<div class="col-lg-8 offset-lg-2">
<!-- Section Informations modifiables -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-primary text-white">
<h3 class="h5 mb-0">
<i class="fas fa-edit me-2"></i>Mes informations personnelles
</h3>
</div>
<div class="card-body">
<form id="profile-form">
<!-- Informations non modifiables (affichage seul) -->
<div class="row mb-3">
<div class="col-md-4">
<label class="form-label text-muted">Nom</label>
<input type="text" class="form-control" id="profile-nom" value="<?php echo esc_attr($user->last_name); ?>" readonly>
</div>
<div class="col-md-4">
<label class="form-label text-muted">Prénom</label>
<input type="text" class="form-control" id="profile-prenom" value="<?php echo esc_attr($user->first_name); ?>" readonly>
</div>
<div class="col-md-4">
<label class="form-label text-muted">Email</label>
<input type="email" class="form-control" id="profile-email" value="<?php echo esc_attr($user->user_email); ?>" readonly>
</div>
</div>
<!-- Téléphone (modifiable) -->
<div class="mb-3">
<label for="profile-telephone" class="form-label">
Téléphone <span class="text-danger">*</span>
</label>
<div class="input-group">
<input type="tel" class="form-control" id="profile-telephone" name="telephone" value="<?php echo esc_attr($telephone); ?>" required>
<button class="btn btn-outline-secondary" type="button" id="edit-telephone-btn">
<i class="fas fa-edit"></i> Modifier
</button>
</div>
<small class="form-text text-muted">Vous pouvez modifier votre numéro de téléphone</small>
</div>
<!-- Boutons d'action -->
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>Enregistrer les modifications
</button>
<button type="button" class="btn btn-outline-secondary" id="cancel-profile-btn">
Annuler
</button>
</div>
</form>
</div>
</div>
<!-- Section Disponibilités -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-primary text-white">
<h3 class="h5 mb-0">
<i class="fas fa-calendar-check me-2"></i>Mes disponibilités
</h3>
</div>
<div class="card-body">
<form id="disponibilites-form">
<!-- Jours de disponibilité (checkboxes) -->
<div class="mb-4">
<label class="form-label fw-bold">Jours de disponibilité</label>
<div id="profile-jours-disponibilite-checkboxes" class="row g-3">
<?php foreach ($jours_labels as $jour_value => $jour_label) :
$is_checked = in_array($jour_value, $jours_disponibilite, true);
?>
<div class="col-md-3 col-sm-4 col-6">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
value="<?php echo esc_attr($jour_value); ?>"
id="jour-<?php echo esc_attr($jour_value); ?>"
name="jours_disponibilite[]"
<?php echo $is_checked ? 'checked' : ''; ?>
>
<label class="form-check-label" for="jour-<?php echo esc_attr($jour_value); ?>">
<?php echo esc_html($jour_label); ?>
</label>
</div>
</div>
<?php endforeach; ?>
</div>
<small class="form-text text-muted">Cochez les jours vous êtes disponible</small>
</div>
<!-- Heures de permanences (checkboxes) -->
<div class="mb-4">
<label class="form-label fw-bold">Tranches horaires</label>
<div id="profile-heures-permanences-checkboxes" class="row g-3">
<?php
$heures_choices = [
'09:00' => '09:00 → 10:00',
'10:00' => '10:00 → 11:00',
'11:00' => '11:00 → 12:00',
'12:00' => '12:00 → 13:00',
'13:00' => '13:00 → 14:00',
'14:00' => '14:00 → 15:00',
'15:00' => '15:00 → 16:00',
'16:00' => '16:00 → 17:00',
];
foreach ($heures_choices as $hval => $hlabel) :
$is_checked = in_array($hval, $heures_de_permanences, true);
?>
<div class="col-md-3 col-sm-4 col-6">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
value="<?php echo esc_attr($hval); ?>"
id="heure-<?php echo esc_attr($hval); ?>"
name="heures_permanences[]"
<?php echo $is_checked ? 'checked' : ''; ?>
>
<label class="form-check-label" for="heure-<?php echo esc_attr($hval); ?>">
<?php echo esc_html($hlabel); ?>
</label>
</div>
</div>
<?php endforeach; ?>
</div>
<small class="form-text text-muted">Cochez les créneaux horaires vous êtes disponible</small>
</div>
<!-- Indisponibilités ponctuelles -->
<div class="mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<label class="form-label fw-bold mb-0">Mes indisponibilités ponctuelles</label>
<button type="button" class="btn btn-sm btn-outline-primary" id="add-indisponibilite-btn">
<i class="fas fa-plus me-1"></i>Ajouter une indisponibilité
</button>
</div>
<!-- Liste des indisponibilités existantes -->
<div id="profile-indisponibilites-list" class="indisponibilites-list mb-3">
<?php if (!empty($indisponibilites_ponctuelles)) : ?>
<?php foreach ($indisponibilites_ponctuelles as $index => $indispo) :
$debut = $indispo['debut'] ?? '';
$fin = $indispo['fin'] ?? '';
$type = $indispo['type'] ?? 'conge';
$commentaire = $indispo['commentaire'] ?? '';
$type_label = $types_indisponibilite[$type] ?? $type;
// Convertir les dates pour l'affichage
$debut_formatted = $debut;
$fin_formatted = $fin;
// Si les dates sont au format d/m/Y, les formater pour l'affichage
if (preg_match('/^(\d{2})\/(\d{2})\/(\d{4})$/', $debut)) {
$debut_formatted = $debut;
}
if (preg_match('/^(\d{2})\/(\d{2})\/(\d{4})$/', $fin)) {
$fin_formatted = $fin;
}
// Afficher la période
$periode_text = '';
if ($debut_formatted && $fin_formatted) {
if ($debut_formatted === $fin_formatted) {
$periode_text = 'Le ' . $debut_formatted;
} else {
$periode_text = 'Du ' . $debut_formatted . ' au ' . $fin_formatted;
}
} elseif ($debut_formatted) {
$periode_text = 'Le ' . $debut_formatted;
}
?>
<div class="indisponibilite-item indispo-<?php echo esc_attr($type); ?>" data-index="<?php echo esc_attr($index); ?>">
<div>
<strong><?php echo esc_html($periode_text); ?></strong>
<?php
$badge_class = 'badge-indispo-unknown';
if ($type === 'conge') $badge_class = 'badge-indispo-conge';
elseif ($type === 'absence') $badge_class = 'badge-indispo-absence';
elseif ($type === 'maladie') $badge_class = 'badge-indispo-maladie';
?>
<span class="badge <?php echo esc_attr($badge_class); ?> ms-2"><?php echo esc_html($type_label); ?></span>
<?php if ($commentaire) : ?>
<p class="mb-0 text-muted mt-1"><?php echo esc_html($commentaire); ?></p>
<?php endif; ?>
</div>
<button type="button" class="btn-remove" onclick="removeIndisponibilite(<?php echo esc_attr($index); ?>)" title="Supprimer">
<i class="fas fa-times"></i>
</button>
</div>
<?php endforeach; ?>
<?php else : ?>
<div id="profile-indisponibilites-empty" class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>Aucune indisponibilité prévue.
</div>
<?php endif; ?>
</div>
<!-- Zone pour ajouter une nouvelle indisponibilité -->
<div id="indisponibilite-form-container" style="display: none;">
<div class="card border-primary">
<div class="card-header bg-light">
<h6 class="mb-0">Nouvelle indisponibilité</h6>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label for="indispo-debut" class="form-label">Date de début <span class="text-danger">*</span></label>
<input type="date" class="form-control" id="indispo-debut">
</div>
<div class="col-md-6">
<label for="indispo-fin" class="form-label">Date de fin <span class="text-danger">*</span></label>
<input type="date" class="form-control" id="indispo-fin">
</div>
<div class="col-md-6">
<label for="indispo-type" class="form-label">Type <span class="text-danger">*</span></label>
<select class="form-select" id="indispo-type">
<option value="">Sélectionnez un type</option>
<option value="conge">Congé</option>
<option value="absence">Absence</option>
<option value="maladie">Maladie</option>
</select>
</div>
<div class="col-md-12">
<label for="indispo-commentaire" class="form-label">Commentaire</label>
<textarea class="form-control" id="indispo-commentaire" rows="2" placeholder="Optionnel"></textarea>
</div>
</div>
<div class="mt-3 d-flex gap-2">
<button type="button" class="btn btn-primary btn-sm" id="save-indisponibilite-btn">
<i class="fas fa-save me-1"></i>Enregistrer
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="cancel-indisponibilite-btn">
Annuler
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Bouton de sauvegarde -->
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>Enregistrer les disponibilités
</button>
</div>
</form>
</div>
</div>
<!-- Section Départements et spécialisations (consultation seule) -->
<div class="card shadow-sm">
<div class="card-header bg-light">
<h3 class="h5 mb-0">
<i class="fas fa-building me-2"></i>Départements et spécialisations
</h3>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Départements</label>
<div id="profile-departements" class="badge-container">
<!-- Sera rempli par JavaScript -->
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Spécialisations</label>
<div id="profile-specialisations" class="badge-container">
<!-- Sera rempli par JavaScript -->
</div>
</div>
</div>
<small class="text-muted">
<i class="fas fa-lock me-1"></i>
Ces informations sont gérées par l'administrateur.
</small>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,45 @@
<?php
// Template principal qui inclut tous les modales de l'agenda
// Contexte : "admin" (par défaut) ou "front_intervenant" (défini côté front).
/** @var string|null $crvi_agenda_context */
$crvi_agenda_context = isset($crvi_agenda_context) ? $crvi_agenda_context : 'admin';
$crvi_is_front_context = ($crvi_agenda_context !== 'admin');
// Définir le chemin de base pour les includes
$modals_dir = dirname(__FILE__) . '/modals';
?>
<?php
// Modal principal d'événement
include $modals_dir . '/event-modal.php';
?>
<?php
// Modal de création de bénéficiaire
include $modals_dir . '/create-beneficiaire-modal.php';
?>
<?php
// Modal de création d'intervenant
include $modals_dir . '/create-intervenant-modal.php';
?>
<?php
// Modal de création de traducteur
include $modals_dir . '/create-traducteur-modal.php';
?>
<?php
// Modal de création de local
include $modals_dir . '/create-local-modal.php';
?>
<?php
// Modal de déclaration d'incident
include $modals_dir . '/declaration-incident-modal.php';
?>
<?php
// Modal d'historique du bénéficiaire
include $modals_dir . '/beneficiaire-historique-modal.php';
?>

View File

@ -0,0 +1,346 @@
<?php
/**
* Template Profil Intervenant
* Affiche le formulaire de gestion du compte et des informations personnelles
* Shortcode: [crvi_intervenant_profil]
*/
if (!defined('ABSPATH')) {
exit;
}
$user = wp_get_current_user();
$user_id = $user->ID;
// Récupérer tous les champs ACF pour pré-remplir le formulaire
$telephone = '';
$jours_disponibilite = [];
$indisponibilites_ponctuelles = [];
if (function_exists('get_field')) {
$telephone = get_field('telephone', 'user_' . $user_id);
if (!is_string($telephone)) {
$telephone = '';
}
$jours_disponibilite = get_field('jours_de_disponibilite', 'user_' . $user_id);
if (!is_array($jours_disponibilite)) {
$jours_disponibilite = [];
}
$indisponibilites_ponctuelles = get_field('indisponibilitee_ponctuelle', 'user_' . $user_id);
if (!is_array($indisponibilites_ponctuelles)) {
$indisponibilites_ponctuelles = [];
}
}
// Labels des types d'indisponibilité
$types_indisponibilite = [
'conge' => 'Congé',
'absence' => 'Absence',
'maladie' => 'Maladie'
];
// Fonction pour convertir une date au format d/m/Y vers YYYY-MM-DD pour les champs date
function convert_date_to_input($date_str) {
if (empty($date_str)) {
return '';
}
// Si déjà au format YYYY-MM-DD, retourner tel quel
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date_str)) {
return $date_str;
}
// Si au format d/m/Y, convertir
if (preg_match('/^(\d{2})\/(\d{2})\/(\d{4})$/', $date_str, $matches)) {
return $matches[3] . '-' . $matches[2] . '-' . $matches[1];
}
return '';
}
// Labels des jours
$jours_labels = [
'lundi' => 'Lundi',
'mardi' => 'Mardi',
'mercredi' => 'Mercredi',
'jeudi' => 'Jeudi',
'vendredi' => 'Vendredi',
'samedi' => 'Samedi',
'dimanche' => 'Dimanche'
];
?>
<div class="crvi-intervenant-profile" id="intervenant-profile-container">
<div class="container-fluid py-4">
<!-- En-tête -->
<div class="row mb-4">
<div class="col-12">
<h1 class="h2 mb-2">
<i class="fas fa-user me-2"></i>Mon Profil
</h1>
<nav class="navbar navbar-expand-lg navbar-light bg-light rounded p-3">
<div class="container-fluid">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="<?php echo esc_url(home_url('/espace-intervenant')); ?>">
<i class="fas fa-home me-1"></i>Hub
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="<?php echo esc_url(home_url('/espace-intervenant-agenda')); ?>">
<i class="fas fa-calendar-alt me-1"></i>Agenda
</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="<?php echo esc_url(home_url('/espace-intervenant-profil')); ?>">
<i class="fas fa-user me-1"></i>Mon Profil
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="<?php echo esc_url(home_url('/encodage-permanences')); ?>">
<i class="fas fa-clock me-1"></i>Permanences
</a>
</li>
</ul>
</div>
</nav>
</div>
</div>
<div class="row">
<div class="col-lg-8 offset-lg-2">
<!-- Section Informations modifiables -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-primary text-white">
<h3 class="h5 mb-0">
<i class="fas fa-edit me-2"></i>Mes informations personnelles
</h3>
</div>
<div class="card-body">
<form id="profile-form">
<!-- Informations non modifiables (affichage seul) -->
<div class="row mb-3">
<div class="col-md-4">
<label class="form-label text-muted">Nom</label>
<input type="text" class="form-control" id="profile-nom" value="<?php echo esc_attr($user->last_name); ?>" readonly>
</div>
<div class="col-md-4">
<label class="form-label text-muted">Prénom</label>
<input type="text" class="form-control" id="profile-prenom" value="<?php echo esc_attr($user->first_name); ?>" readonly>
</div>
<div class="col-md-4">
<label class="form-label text-muted">Email</label>
<input type="email" class="form-control" id="profile-email" value="<?php echo esc_attr($user->user_email); ?>" readonly>
</div>
</div>
<!-- Téléphone (modifiable) -->
<div class="mb-3">
<label for="profile-telephone" class="form-label">
Téléphone <span class="text-danger">*</span>
</label>
<div class="input-group">
<input type="tel" class="form-control" id="profile-telephone" name="telephone" value="<?php echo esc_attr($telephone); ?>" required>
<button class="btn btn-outline-secondary" type="button" id="edit-telephone-btn">
<i class="fas fa-edit"></i> Modifier
</button>
</div>
<small class="form-text text-muted">Vous pouvez modifier votre numéro de téléphone</small>
</div>
<!-- Boutons d'action -->
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>Enregistrer les modifications
</button>
<button type="button" class="btn btn-outline-secondary" id="cancel-profile-btn">
Annuler
</button>
</div>
</form>
</div>
</div>
<!-- Section Disponibilités -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-primary text-white">
<h3 class="h5 mb-0">
<i class="fas fa-calendar-check me-2"></i>Mes disponibilités
</h3>
</div>
<div class="card-body">
<form id="disponibilites-form">
<!-- Jours de disponibilité (checkboxes) -->
<div class="mb-4">
<label class="form-label fw-bold">Jours de disponibilité</label>
<div id="profile-jours-disponibilite-checkboxes" class="row g-3">
<?php foreach ($jours_labels as $jour_value => $jour_label) :
$is_checked = in_array($jour_value, $jours_disponibilite, true);
?>
<div class="col-md-3 col-sm-4 col-6">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
value="<?php echo esc_attr($jour_value); ?>"
id="jour-<?php echo esc_attr($jour_value); ?>"
name="jours_disponibilite[]"
<?php echo $is_checked ? 'checked' : ''; ?>
>
<label class="form-check-label" for="jour-<?php echo esc_attr($jour_value); ?>">
<?php echo esc_html($jour_label); ?>
</label>
</div>
</div>
<?php endforeach; ?>
</div>
<small class="form-text text-muted">Cochez les jours vous êtes disponible</small>
</div>
<!-- Indisponibilités ponctuelles -->
<div class="mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<label class="form-label fw-bold mb-0">Mes indisponibilités ponctuelles</label>
<button type="button" class="btn btn-sm btn-outline-primary" id="add-indisponibilite-btn">
<i class="fas fa-plus me-1"></i>Ajouter une indisponibilité
</button>
</div>
<!-- Liste des indisponibilités existantes -->
<div id="profile-indisponibilites-list" class="indisponibilites-list mb-3">
<?php if (!empty($indisponibilites_ponctuelles)) : ?>
<?php foreach ($indisponibilites_ponctuelles as $index => $indispo) :
$debut = $indispo['debut'] ?? '';
$fin = $indispo['fin'] ?? '';
$type = $indispo['type'] ?? 'conge';
$commentaire = $indispo['commentaire'] ?? '';
$type_label = $types_indisponibilite[$type] ?? $type;
// Convertir les dates pour l'affichage
$debut_formatted = $debut;
$fin_formatted = $fin;
// Si les dates sont au format d/m/Y, les formater pour l'affichage
if (preg_match('/^(\d{2})\/(\d{2})\/(\d{4})$/', $debut)) {
$debut_formatted = $debut;
}
if (preg_match('/^(\d{2})\/(\d{2})\/(\d{4})$/', $fin)) {
$fin_formatted = $fin;
}
// Afficher la période
$periode_text = '';
if ($debut_formatted && $fin_formatted) {
if ($debut_formatted === $fin_formatted) {
$periode_text = 'Le ' . $debut_formatted;
} else {
$periode_text = 'Du ' . $debut_formatted . ' au ' . $fin_formatted;
}
} elseif ($debut_formatted) {
$periode_text = 'Le ' . $debut_formatted;
}
?>
<div class="indisponibilite-item" data-index="<?php echo esc_attr($index); ?>">
<div>
<strong><?php echo esc_html($periode_text); ?></strong>
<span class="badge bg-warning text-dark ms-2"><?php echo esc_html($type_label); ?></span>
<?php if ($commentaire) : ?>
<p class="mb-0 text-muted mt-1"><?php echo esc_html($commentaire); ?></p>
<?php endif; ?>
</div>
<button type="button" class="btn-remove" onclick="removeIndisponibilite(<?php echo esc_attr($index); ?>)" title="Supprimer">
<i class="fas fa-times"></i>
</button>
</div>
<?php endforeach; ?>
<?php else : ?>
<div id="profile-indisponibilites-empty" class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>Aucune indisponibilité prévue.
</div>
<?php endif; ?>
</div>
<!-- Zone pour ajouter une nouvelle indisponibilité -->
<div id="indisponibilite-form-container" style="display: none;">
<div class="card border-primary">
<div class="card-header bg-light">
<h6 class="mb-0">Nouvelle indisponibilité</h6>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label for="indispo-debut" class="form-label">Date de début <span class="text-danger">*</span></label>
<input type="date" class="form-control" id="indispo-debut" required>
</div>
<div class="col-md-6">
<label for="indispo-fin" class="form-label">Date de fin <span class="text-danger">*</span></label>
<input type="date" class="form-control" id="indispo-fin" required>
</div>
<div class="col-md-6">
<label for="indispo-type" class="form-label">Type <span class="text-danger">*</span></label>
<select class="form-select" id="indispo-type" required>
<option value="">Sélectionnez un type</option>
<option value="conge">Congé</option>
<option value="absence">Absence</option>
<option value="maladie">Maladie</option>
</select>
</div>
<div class="col-md-12">
<label for="indispo-commentaire" class="form-label">Commentaire</label>
<textarea class="form-control" id="indispo-commentaire" rows="2" placeholder="Optionnel"></textarea>
</div>
</div>
<div class="mt-3 d-flex gap-2">
<button type="button" class="btn btn-primary btn-sm" id="save-indisponibilite-btn">
<i class="fas fa-save me-1"></i>Enregistrer
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="cancel-indisponibilite-btn">
Annuler
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Bouton de sauvegarde -->
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>Enregistrer les disponibilités
</button>
</div>
</form>
</div>
</div>
<!-- Section Départements et spécialisations (consultation seule) -->
<div class="card shadow-sm">
<div class="card-header bg-light">
<h3 class="h5 mb-0">
<i class="fas fa-building me-2"></i>Départements et spécialisations
</h3>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Départements</label>
<div id="profile-departements" class="badge-container">
<!-- Sera rempli par JavaScript -->
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Spécialisations</label>
<div id="profile-specialisations" class="badge-container">
<!-- Sera rempli par JavaScript -->
</div>
</div>
</div>
<small class="text-muted">
<i class="fas fa-lock me-1"></i>
Ces informations sont gérées par l'administrateur.
</small>
</div>
</div>
</div>
</div>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More