ajout liaison capacité traduction - events

This commit is contained in:
theShlavuk 2026-01-20 23:00:56 +01:00
parent 62fa707291
commit f7d1bb29a5
8 changed files with 1060 additions and 8 deletions

View File

@ -661,11 +661,26 @@ class CRVI_Event_Controller {
} }
public static function create_event($request) { public static function create_event($request) {
$data = $request->get_json_params(); $data = $request->get_json_params();
// Valider les capacités de traduction si l'événement a une langue
if (!empty($data['langue']) && !empty($data['date_rdv']) && !empty($data['heure_rdv'])) {
$validation = self::validate_traduction_capacite($data['langue'], $data['date_rdv'], $data['heure_rdv']);
if (!$validation['valid']) {
return Api_Helper::json_error($validation['message'], 400);
}
}
$model = new CRVI_Event_Model(); $model = new CRVI_Event_Model();
$result = $model->create_event($data); $result = $model->create_event($data);
if (is_wp_error($result)) { if (is_wp_error($result)) {
return Api_Helper::json_error($result->get_error_message(), 400); return Api_Helper::json_error($result->get_error_message(), 400);
} }
// Invalider le cache des capacités de traduction si l'événement a une langue
if (!empty($data['langue'])) {
\ESI_CRVI_AGENDA\models\CRVI_TraductionLangue_Model::invalidate_cache();
}
return Api_Helper::json_success(['id' => $result, 'message' => 'Événement créé avec succès']); return Api_Helper::json_success(['id' => $result, 'message' => 'Événement créé avec succès']);
} }
public static function update_event($request) { public static function update_event($request) {
@ -689,7 +704,33 @@ class CRVI_Event_Controller {
$data = []; $data = [];
} }
// Récupérer l'événement existant pour obtenir les valeurs actuelles
$model = new CRVI_Event_Model(); $model = new CRVI_Event_Model();
$existing_event = $model->get_event_enriched($id);
// Fusionner les données existantes avec les nouvelles pour la validation
$merged_data = array_merge(
[
'langue' => $existing_event['langue'] ?? null,
'date_rdv' => $existing_event['date_rdv'] ?? null,
'heure_rdv' => $existing_event['heure_rdv'] ?? null,
],
$data
);
// Valider les capacités de traduction si l'événement a une langue
if (!empty($merged_data['langue']) && !empty($merged_data['date_rdv']) && !empty($merged_data['heure_rdv'])) {
$validation = self::validate_traduction_capacite(
$merged_data['langue'],
$merged_data['date_rdv'],
$merged_data['heure_rdv'],
$id // Exclure l'événement actuel du comptage
);
if (!$validation['valid']) {
return Api_Helper::json_error($validation['message'], 400);
}
}
$result = $model->update_event($id, $data); $result = $model->update_event($id, $data);
if (is_wp_error($result)) { if (is_wp_error($result)) {
$error_code = $result->get_error_code(); $error_code = $result->get_error_code();
@ -697,6 +738,10 @@ class CRVI_Event_Controller {
$status_code = isset($error_data['status']) ? $error_data['status'] : 400; $status_code = isset($error_data['status']) ? $error_data['status'] : 400;
return Api_Helper::json_error($result->get_error_message(), $status_code); return Api_Helper::json_error($result->get_error_message(), $status_code);
} }
// Invalider le cache des capacités de traduction après modification
\ESI_CRVI_AGENDA\models\CRVI_TraductionLangue_Model::invalidate_cache();
return Api_Helper::json_success(['id' => $id, 'message' => 'Événement modifié avec succès']); return Api_Helper::json_success(['id' => $id, 'message' => 'Événement modifié avec succès']);
} }
public static function delete_event($request) { public static function delete_event($request) {
@ -706,9 +751,156 @@ class CRVI_Event_Controller {
if (is_wp_error($result)) { if (is_wp_error($result)) {
return Api_Helper::json_error($result->get_error_message(), 400); return Api_Helper::json_error($result->get_error_message(), 400);
} }
// Invalider le cache des capacités de traduction après suppression
\ESI_CRVI_AGENDA\models\CRVI_TraductionLangue_Model::invalidate_cache();
return Api_Helper::json_success(['id' => $id, 'message' => 'Événement supprimé avec succès']); return Api_Helper::json_success(['id' => $id, 'message' => 'Événement supprimé avec succès']);
} }
/**
* Valide la disponibilité des capacités de traduction pour un événement
*
* @param string $langue_slug Slug de la langue (ex: 'arabe', 'tigrinya')
* @param string $date_rdv Date du RDV (format: Y-m-d)
* @param string $heure_rdv Heure du RDV (format: H:i:s)
* @param int|null $exclude_event_id ID de l'événement à exclure du comptage (pour les modifications)
* @return array ['valid' => bool, 'message' => string, 'details' => array]
*/
private static function validate_traduction_capacite($langue_slug, $date_rdv, $heure_rdv, $exclude_event_id = null) {
// Récupérer le terme de la langue depuis le slug
$langue_term = get_term_by('slug', $langue_slug, 'langue');
if (!$langue_term || is_wp_error($langue_term)) {
return [
'valid' => true, // Si la langue n'existe pas, on ne bloque pas
'message' => '',
'details' => []
];
}
// Extraire le jour de la semaine depuis la date
$date_obj = new \DateTime($date_rdv);
$jours_map = [
0 => 'dimanche',
1 => 'lundi',
2 => 'mardi',
3 => 'mercredi',
4 => 'jeudi',
5 => 'vendredi',
6 => 'samedi'
];
$jour = $jours_map[(int) $date_obj->format('w')];
// Extraire la période depuis l'heure
$heure_obj = new \DateTime($heure_rdv);
$heure_int = (int) $heure_obj->format('H');
$periode = $heure_int < 12 ? 'matin' : 'apres_midi';
// Récupérer les capacités disponibles pour cette langue
$capacites = \ESI_CRVI_AGENDA\models\CRVI_TraductionLangue_Model::getActiveCapacites([
'langue' => $langue_term->term_id
], false);
if (empty($capacites)) {
return [
'valid' => false,
'message' => sprintf(
'Aucune capacité de traduction configurée pour la langue "%s".',
$langue_term->name
),
'details' => [
'langue' => $langue_term->name,
'jour' => $jour,
'periode' => $periode
]
];
}
// Vérifier si une capacité correspond au jour et à la période
$capacite_trouvee = null;
foreach ($capacites as $capacite) {
// Vérifier le jour (si défini dans la capacité)
$jour_match = empty($capacite->jour) || $capacite->jour === $jour;
// Vérifier la période (si définie dans la capacité)
$periode_match = empty($capacite->periode) ||
$capacite->periode === 'journee' ||
$capacite->periode === $periode;
if ($jour_match && $periode_match) {
$capacite_trouvee = $capacite;
break;
}
}
if (!$capacite_trouvee) {
return [
'valid' => false,
'message' => sprintf(
'Aucune capacité de traduction disponible pour la langue "%s" le %s en %s.',
$langue_term->name,
$jour,
$periode === 'matin' ? 'matinée' : 'après-midi'
),
'details' => [
'langue' => $langue_term->name,
'jour' => $jour,
'periode' => $periode
]
];
}
// Vérifier si la capacité a encore des créneaux disponibles
$capacite_disponible = \ESI_CRVI_AGENDA\models\CRVI_TraductionLangue_Model::isAvailable(
$capacite_trouvee->id,
$date_rdv,
$capacite_trouvee
);
if (!$capacite_disponible) {
// Récupérer les statistiques pour un message détaillé
$used = \ESI_CRVI_AGENDA\models\CRVI_TraductionLangue_Model::countUsed(
$capacite_trouvee->id,
$date_rdv,
$capacite_trouvee
);
return [
'valid' => false,
'message' => sprintf(
'Capacité de traduction atteinte pour la langue "%s" le %s en %s. ' .
'Créneaux utilisés : %d/%d pour cette %s.',
$langue_term->name,
$jour,
$periode === 'matin' ? 'matinée' : 'après-midi',
$used,
$capacite_trouvee->limite,
$capacite_trouvee->limite_par === 'semaine' ? 'semaine' : 'mois'
),
'details' => [
'langue' => $langue_term->name,
'jour' => $jour,
'periode' => $periode,
'used' => $used,
'limite' => $capacite_trouvee->limite,
'limite_par' => $capacite_trouvee->limite_par
]
];
}
// Tout est OK
return [
'valid' => true,
'message' => 'Capacité de traduction disponible',
'details' => [
'langue' => $langue_term->name,
'jour' => $jour,
'periode' => $periode,
'capacite_id' => $capacite_trouvee->id
]
];
}
/** /**
* Crée des permanences pour l'intervenant connecté * Crée des permanences pour l'intervenant connecté
* POST /wp-json/crvi/v1/intervenant/permanences * POST /wp-json/crvi/v1/intervenant/permanences
@ -1418,6 +1610,10 @@ class CRVI_Event_Controller {
if (is_wp_error($result)) { if (is_wp_error($result)) {
return Api_Helper::json_error($result->get_error_message(), 400); return Api_Helper::json_error($result->get_error_message(), 400);
} }
// Invalider le cache des capacités de traduction après restauration
\ESI_CRVI_AGENDA\models\CRVI_TraductionLangue_Model::invalidate_cache();
return Api_Helper::json_success(['id' => $id, 'message' => 'Événement restauré avec succès']); return Api_Helper::json_success(['id' => $id, 'message' => 'Événement restauré avec succès']);
} }
@ -1428,6 +1624,10 @@ class CRVI_Event_Controller {
if (is_wp_error($result)) { if (is_wp_error($result)) {
return Api_Helper::json_error($result->get_error_message(), 400); return Api_Helper::json_error($result->get_error_message(), 400);
} }
// Invalider le cache des capacités de traduction après suppression définitive
\ESI_CRVI_AGENDA\models\CRVI_TraductionLangue_Model::invalidate_cache();
return Api_Helper::json_success(['id' => $id, 'message' => 'Événement supprimé définitivement']); return Api_Helper::json_success(['id' => $id, 'message' => 'Événement supprimé définitivement']);
} }

View File

@ -328,14 +328,13 @@ class CRVI_Plugin {
// CSS des librairies (Bootstrap, Toastr, Select2) - compilé par Vite // CSS des librairies (Bootstrap, Toastr, Select2) - compilé par Vite
wp_enqueue_style('crvi_libraries_css', ESI_CRVI_AGENDA_URL . 'assets/js/dist/crvi_libraries.min.css', [], '1.0.0'); wp_enqueue_style('crvi_libraries_css', ESI_CRVI_AGENDA_URL . 'assets/js/dist/crvi_libraries.min.css', [], '1.0.0');
// CSS principal du plugin - compilé par Vite // CSS principal du plugin - compilé par Vite (inclut déjà agenda-visual-filters.css)
wp_enqueue_style('crvi_main_css', ESI_CRVI_AGENDA_URL . 'assets/js/dist/crvi_main.min.css', ['crvi_libraries_css'], '1.0.0'); wp_enqueue_style('crvi_main_css', ESI_CRVI_AGENDA_URL . 'assets/js/dist/crvi_main.min.css', ['crvi_libraries_css'], '1.0.0');
// Librairies externes (Bootstrap, Toastr, FullCalendar) - chargées en premier // Librairies externes (Bootstrap, Toastr, FullCalendar) - chargées en premier
wp_enqueue_script('crvi_libraries', ESI_CRVI_AGENDA_URL . 'assets/js/dist/crvi_libraries.min.js', ['jquery', 'select2'], '1.0.0', true); wp_enqueue_script('crvi_libraries', ESI_CRVI_AGENDA_URL . 'assets/js/dist/crvi_libraries.min.js', ['jquery', 'select2'], '1.0.0', true);
// Script principal du plugin (modules locaux) - dépend des librairies // Script principal du plugin (modules locaux, inclut déjà agenda-visual-filters.js) - dépend des librairies
wp_enqueue_script('crvi_main_agenda', ESI_CRVI_AGENDA_URL . 'assets/js/dist/crvi_main.min.js', ['crvi_libraries'], '1.0.0', true); wp_enqueue_script('crvi_main_agenda', ESI_CRVI_AGENDA_URL . 'assets/js/dist/crvi_main.min.js', ['crvi_libraries'], '1.0.0', true);
self::localize_acf_data('crvi_main_agenda'); self::localize_acf_data('crvi_main_agenda');
@ -468,7 +467,8 @@ class CRVI_Plugin {
'couleurs_rdv' => self::get_couleurs_rdv_acf_data(), 'couleurs_rdv' => self::get_couleurs_rdv_acf_data(),
'couleurs_permanence' => self::get_couleurs_permanence_acf_data(), 'couleurs_permanence' => self::get_couleurs_permanence_acf_data(),
'indisponibilites_intervenants' => self::get_intervenants_disponibilites(), 'indisponibilites_intervenants' => self::get_intervenants_disponibilites(),
'beneficiaires' => self::get_beneficiaires_acf_data() 'beneficiaires' => self::get_beneficiaires_acf_data(),
'traductions_capacites' => self::get_traductions_capacites_data()
]; ];
wp_localize_script($script_handle, 'crviACFData', $acf_data); wp_localize_script($script_handle, 'crviACFData', $acf_data);
@ -880,6 +880,27 @@ class CRVI_Plugin {
return $beneficiaires_data; return $beneficiaires_data;
} }
/**
* Récupère les capacités de traduction par langue avec ventilation par période et par jour
* Format : langue_slug => {id, name, slug, total, remaining, by_periode: {matin/apres_midi: {total, remaining}}, by_jour: {lundi->dimanche: {total, remaining}}}
*
* @return array Capacités de traduction par langue
*/
private static function get_traductions_capacites_data() {
// Vérifier que la classe du modèle existe
if (!class_exists('\ESI_CRVI_AGENDA\models\CRVI_TraductionLangue_Model')) {
return [];
}
// Récupérer la date actuelle
$date = current_time('Y-m-d');
// Utiliser la méthode du modèle qui gère le cache
$capacites = \ESI_CRVI_AGENDA\models\CRVI_TraductionLangue_Model::getAllLanguesCapacitesForACF($date, true);
return $capacites;
}
/** /**
* crvi+script loader * crvi+script loader
*/ */

View File

@ -840,6 +840,195 @@ class CRVI_TraductionLangue_Model extends Main_Model {
return $result; return $result;
} }
/**
* Récupère les disponibilités par langue avec ventilation par jour
* Structure : langue -> total, remaining, by_periode (am/pm avec total/remaining), by_jour (lundi->dimanche avec total/remaining)
*
* @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, 'remaining' => int, 'by_periode' => [...], 'by_jour' => [...]]
*/
public static function getDisponibilitesByLangueWithJours(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
$cache_key = 'crvi_dispos_langue_jours_' . $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;
}
}
// Obtenir les disponibilités globales par langue
$dispos = self::getDisponibilitesByLangue($langue_term_id, $date, $use_cache);
// Initialiser la structure de résultat
$result = [
'total' => $dispos['total'],
'remaining' => $dispos['total_available'],
'by_periode' => [
'matin' => [
'total' => $dispos['by_periode']['matin_summary']['total'] ?? 0,
'remaining' => $dispos['by_periode']['matin_summary']['available'] ?? 0,
],
'apres_midi' => [
'total' => $dispos['by_periode']['apres_midi_summary']['total'] ?? 0,
'remaining' => $dispos['by_periode']['apres_midi_summary']['available'] ?? 0,
],
],
'by_jour' => [
'lundi' => ['total' => 0, 'remaining' => 0],
'mardi' => ['total' => 0, 'remaining' => 0],
'mercredi' => ['total' => 0, 'remaining' => 0],
'jeudi' => ['total' => 0, 'remaining' => 0],
'vendredi' => ['total' => 0, 'remaining' => 0],
'samedi' => ['total' => 0, 'remaining' => 0],
'dimanche' => ['total' => 0, 'remaining' => 0],
],
];
// 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 ventiler par jour
foreach ($capacites as $capacite) {
// Vérifier que la langue correspond
$capacite_langues = is_array($capacite->langue) ? $capacite->langue : [$capacite->langue];
if (!in_array($langue_term_id, $capacite_langues)) {
continue;
}
// Si un jour est spécifié, ajouter la capacité à ce jour
if (!empty($capacite->jour) && isset($result['by_jour'][$capacite->jour])) {
// Calculer la limite mensuelle
$limite_mensuelle = self::convertToMonthlyLimit(
$capacite->limite,
$capacite->limite_par,
$nb_semaines
);
// Compter les événements utilisés
$used = self::countUsed($capacite->id, $date, $capacite);
// Ajouter au total du jour
$result['by_jour'][$capacite->jour]['total'] += $limite_mensuelle;
$result['by_jour'][$capacite->jour]['remaining'] += max(0, $limite_mensuelle - $used);
// Si la capacité a une exception avec un jour différent, l'ajouter aussi
if ($capacite->ajouter_exception && !empty($capacite->exceptions_disponibilites)) {
$exceptions = $capacite->exceptions_disponibilites;
if (!empty($exceptions['frequence']) && !empty($exceptions['frequence_periode'])) {
$limite_exception = self::convertToMonthlyLimit(
(int) $exceptions['frequence'],
$exceptions['frequence_periode'],
$nb_semaines
);
// Compter les événements pour l'exception
$temp_capacite = clone $capacite;
if (!empty($exceptions['periode'])) {
$temp_capacite->periode = $exceptions['periode'];
}
$used_exception = self::countUsed($capacite->id, $date, $temp_capacite);
// Ajouter au total du jour
$result['by_jour'][$capacite->jour]['total'] += $limite_exception;
$result['by_jour'][$capacite->jour]['remaining'] += max(0, $limite_exception - $used_exception);
}
}
}
}
// Mettre en cache le résultat (2 jours)
if ($use_cache) {
\set_transient($cache_key, $result, 2 * \DAY_IN_SECONDS);
}
return $result;
}
/**
* Récupère toutes les langues avec leurs capacités détaillées (périodes et jours)
* Format optimisé pour crviACFData JavaScript
*
* @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' => ['id' => X, 'name' => '...', 'total' => X, 'remaining' => Y, 'by_periode' => [...], 'by_jour' => [...]]]
*/
public static function getAllLanguesCapacitesForACF(?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_langues_capacites_acf_' . $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 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)) {
// Obtenir les disponibilités avec ventilation par jour
$dispos = self::getDisponibilitesByLangueWithJours($langue_id, $date, $use_cache);
$result[$langue_term->slug] = [
'id' => $langue_id,
'name' => $langue_term->name,
'slug' => $langue_term->slug,
'total' => $dispos['total'],
'remaining' => $dispos['remaining'],
'by_periode' => $dispos['by_periode'],
'by_jour' => $dispos['by_jour'],
];
}
}
// Trier par nom de langue
uasort($result, function($a, $b) {
return strcmp($a['name'], $b['name']);
});
// Mettre en cache le résultat (2 jours)
if ($use_cache) {
\set_transient($cache_key, $result, 2 * \DAY_IN_SECONDS);
}
return $result;
}
/** /**
* Invalide le cache pour une capacité donnée * Invalide le cache pour une capacité donnée
* À appeler lors de la création/modification/suppression d'événements ou de capacités * À appeler lors de la création/modification/suppression d'événements ou de capacités
@ -862,9 +1051,13 @@ class CRVI_TraductionLangue_Model extends Main_Model {
$wpdb->prepare( $wpdb->prepare(
"DELETE FROM {$wpdb->options} "DELETE FROM {$wpdb->options}
WHERE option_name LIKE %s WHERE option_name LIKE %s
OR option_name LIKE %s
OR option_name LIKE %s
OR option_name LIKE %s", OR option_name LIKE %s",
'_transient_crvi_dispos_langue_' . $langue_id . '_%', '_transient_crvi_dispos_langue_' . $langue_id . '_%',
'_transient_timeout_crvi_dispos_langue_' . $langue_id . '_%' '_transient_timeout_crvi_dispos_langue_' . $langue_id . '_%',
'_transient_crvi_dispos_langue_jours_' . $langue_id . '_%',
'_transient_timeout_crvi_dispos_langue_jours_' . $langue_id . '_%'
) )
); );
} }
@ -873,7 +1066,9 @@ class CRVI_TraductionLangue_Model extends Main_Model {
$wpdb->query( $wpdb->query(
"DELETE FROM {$wpdb->options} "DELETE FROM {$wpdb->options}
WHERE option_name LIKE '_transient_crvi_all_langues_dispos_%' WHERE option_name LIKE '_transient_crvi_all_langues_dispos_%'
OR option_name LIKE '_transient_timeout_crvi_all_langues_dispos_%'" OR option_name LIKE '_transient_timeout_crvi_all_langues_dispos_%'
OR option_name LIKE '_transient_crvi_langues_capacites_acf_%'
OR option_name LIKE '_transient_timeout_crvi_langues_capacites_acf_%'"
); );
} }
@ -888,7 +1083,9 @@ class CRVI_TraductionLangue_Model extends Main_Model {
OR option_name LIKE '_transient_crvi_all_langues_dispos_%' OR option_name LIKE '_transient_crvi_all_langues_dispos_%'
OR option_name LIKE '_transient_timeout_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_crvi_count_used_%'
OR option_name LIKE '_transient_timeout_crvi_count_used_%'" OR option_name LIKE '_transient_timeout_crvi_count_used_%'
OR option_name LIKE '_transient_crvi_langues_capacites_acf_%'
OR option_name LIKE '_transient_timeout_crvi_langues_capacites_acf_%'"
); );
// Invalider le cache général des capacités // Invalider le cache général des capacités

View File

@ -0,0 +1,276 @@
/**
* Styles pour les filtres visuels de l'agenda admin
* Boutons de départements et capacités de traduction
*/
/* Conteneur principal */
.visual-filters-container {
margin-bottom: 20px;
}
.visual-filters-container .card {
border: 1px solid #dee2e6;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.visual-filters-container .card-header {
background-color: #f8f9fa;
border-bottom: 2px solid #007bff;
padding: 12px 20px;
transition: background-color 0.2s ease;
}
.visual-filters-container .card-header:hover {
background-color: #e9ecef;
}
.visual-filters-container .card-header h5 {
color: #333;
font-weight: 600;
font-size: 1.1rem;
}
.visual-filters-container .card-header .btn-link {
color: #333;
font-size: 1rem;
}
.visual-filters-container .card-header .btn-link:hover {
color: #007bff;
}
#visual-filters-toggle-icon {
transition: transform 0.3s ease;
}
.visual-filters-container .card-body {
padding: 20px;
}
/* Groupes de filtres */
.filter-group {
margin-bottom: 20px;
}
.filter-group:last-child {
margin-bottom: 0;
}
.filter-group-title {
font-size: 0.95rem;
font-weight: 600;
color: #495057;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #e9ecef;
}
/* Conteneur des boutons */
.button-filters {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
/* Bouton de filtre commun */
.filter-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background-color: #fff;
border: 2px solid #dee2e6;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.9rem;
color: #495057;
font-weight: 500;
}
.filter-btn i {
font-size: 1rem;
}
.filter-btn:hover:not(.disabled) {
border-color: #007bff;
background-color: #f8f9fa;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,123,255,0.2);
}
.filter-btn.active {
background-color: #007bff;
border-color: #007bff;
color: #fff;
}
.filter-btn.active i {
color: #fff !important;
}
.filter-btn.disabled {
background-color: #e9ecef;
border-color: #dee2e6;
color: #6c757d;
cursor: not-allowed;
opacity: 0.6;
}
/* Bouton de capacité de traduction */
.trad-btn {
min-width: 200px;
padding: 12px 16px;
flex-direction: column;
align-items: stretch;
}
.trad-btn-content {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.trad-btn-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.trad-btn-label {
font-weight: 600;
font-size: 0.95rem;
}
.trad-btn-stats {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.trad-stat {
font-size: 0.85rem;
font-weight: 600;
color: #495057;
}
.trad-btn.active .trad-stat {
color: #fff;
}
/* Barre de progression */
.trad-progress {
width: 100%;
height: 8px;
background-color: #e9ecef;
border-radius: 4px;
overflow: hidden;
margin: 4px 0;
}
.trad-progress-bar {
height: 100%;
transition: width 0.3s ease;
border-radius: 4px;
}
/* Détails (matin/après-midi) */
.trad-btn-details {
display: flex;
justify-content: space-between;
gap: 8px;
margin-top: 4px;
}
.trad-detail {
font-size: 0.75rem;
color: #6c757d;
background-color: #f8f9fa;
padding: 2px 8px;
border-radius: 3px;
}
.trad-btn.active .trad-detail {
background-color: rgba(255,255,255,0.2);
color: rgba(255,255,255,0.9);
}
.trad-btn.disabled .trad-detail {
background-color: #e9ecef;
color: #adb5bd;
}
/* Animation de chargement */
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.filter-btn.loading {
animation: pulse 1.5s ease-in-out infinite;
pointer-events: none;
}
/* Responsive */
@media (max-width: 768px) {
.button-filters {
gap: 8px;
}
.filter-btn {
font-size: 0.85rem;
padding: 6px 12px;
}
.trad-btn {
min-width: 150px;
padding: 10px 12px;
}
.trad-btn-label {
font-size: 0.85rem;
}
.trad-stat {
font-size: 0.8rem;
}
.trad-detail {
font-size: 0.7rem;
padding: 2px 6px;
}
}
/* États spéciaux */
.filter-btn.warning {
border-color: #ffc107;
}
.filter-btn.danger {
border-color: #dc3545;
}
/* Tooltip pour informations supplémentaires */
.filter-btn[data-bs-toggle="tooltip"] {
position: relative;
}
/* Badge de notification */
.filter-btn .badge {
position: absolute;
top: -8px;
right: -8px;
background-color: #dc3545;
color: #fff;
font-size: 0.7rem;
padding: 2px 6px;
border-radius: 10px;
font-weight: 600;
}

View File

@ -10,3 +10,5 @@
@import './crvi-agenda.css'; @import './crvi-agenda.css';
/* Overrides admin calendrier (sera minifié avec le reste via Vite) */ /* Overrides admin calendrier (sera minifié avec le reste via Vite) */
@import './admin-calendar-overrides.css'; @import './admin-calendar-overrides.css';
/* Filtres visuels agenda admin */
@import './agenda-visual-filters.css';

View File

@ -11,6 +11,7 @@ import { initializeProfile } from './modules/agenda-intervenant-profile.js';
import { initializePermanences } from './modules/agenda-intervenant-permanences.js'; import { initializePermanences } from './modules/agenda-intervenant-permanences.js';
import { initializeAdminPermanences } from './modules/agenda-admin-permanences.js'; import { initializeAdminPermanences } from './modules/agenda-admin-permanences.js';
import { initStatsTable } from './modules/agenda-stats-table.js'; import { initStatsTable } from './modules/agenda-stats-table.js';
import AgendaVisualFilters from './modules/agenda-visual-filters.js';
/** /**
* FIX GLOBAL : Corriger le stacking context de TOUTES les modales Bootstrap * FIX GLOBAL : Corriger le stacking context de TOUTES les modales Bootstrap
@ -126,6 +127,13 @@ document.addEventListener('DOMContentLoaded', function() {
console.warn('⚠️ Échec de l\'initialisation du calendrier'); console.warn('⚠️ Échec de l\'initialisation du calendrier');
} }
// Initialiser les filtres visuels si présents
const visualFiltersEl = document.getElementById('departements-filter-buttons');
if (visualFiltersEl) {
console.log('🔍 Initialisation des filtres visuels...');
window.agendaVisualFilters = new AgendaVisualFilters();
}
const modal = document.getElementById('eventModal'); const modal = document.getElementById('eventModal');
if (modal) { if (modal) {
// Vérifier que bootstrap est disponible // Vérifier que bootstrap est disponible

View File

@ -0,0 +1,290 @@
/**
* Gestion des filtres visuels de l'agenda admin
* Filtres par département et capacités de traduction
*/
class AgendaVisualFilters {
constructor() {
this.departements = window.crviACFData?.departements || {};
this.traductions = window.crviACFData?.traductions_capacites || {};
this.selectedDepartement = null;
this.selectedTraduction = null;
this.departementsContainer = document.getElementById('departements-filter-buttons');
this.traductionsContainer = document.getElementById('traductions-filter-buttons');
if (this.departementsContainer && this.traductionsContainer) {
this.init();
}
}
init() {
this.renderDepartementsButtons();
this.renderTraductionsButtons();
this.setupEventListeners();
}
/**
* Génère les boutons pour les départements
*/
renderDepartementsButtons() {
const container = this.departementsContainer;
container.innerHTML = '';
// Bouton "Tous"
const btnTous = this.createButton({
id: 'dept-all',
label: 'Tous les départements',
icon: 'fas fa-th',
color: '#6c757d',
active: this.selectedDepartement === null
});
btnTous.addEventListener('click', () => this.selectDepartement(null));
container.appendChild(btnTous);
// Boutons pour chaque département
Object.entries(this.departements).forEach(([slug, dept]) => {
const btn = this.createButton({
id: `dept-${slug}`,
label: dept.nom,
icon: dept.icone || 'fas fa-building',
color: dept.couleur || '#6c757d',
active: this.selectedDepartement === slug
});
btn.addEventListener('click', () => this.selectDepartement(slug));
container.appendChild(btn);
});
}
/**
* Génère les boutons pour les capacités de traduction
*/
renderTraductionsButtons() {
const container = this.traductionsContainer;
container.innerHTML = '';
// Bouton "Tous"
const btnTous = this.createButton({
id: 'trad-all',
label: 'Toutes les langues',
icon: 'fas fa-language',
color: '#6c757d',
active: this.selectedTraduction === null
});
btnTous.addEventListener('click', () => this.selectTraduction(null));
container.appendChild(btnTous);
// Boutons pour chaque langue avec barre de progression
Object.entries(this.traductions).forEach(([slug, langue]) => {
const btn = this.createTraductionButton(slug, langue);
container.appendChild(btn);
});
}
/**
* Crée un bouton de filtre simple
*/
createButton({ id, label, icon, color, active = false }) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `filter-btn ${active ? 'active' : ''}`;
btn.id = id;
btn.innerHTML = `
<i class="${icon}" style="color: ${color};"></i>
<span>${label}</span>
`;
return btn;
}
/**
* Crée un bouton de capacité de traduction avec barre de progression
*/
createTraductionButton(slug, langue) {
const isDisabled = langue.remaining === 0;
const pourcentage = langue.total > 0
? Math.round((langue.remaining / langue.total) * 100)
: 0;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `filter-btn trad-btn ${this.selectedTraduction === slug ? 'active' : ''} ${isDisabled ? 'disabled' : ''}`;
btn.id = `trad-${slug}`;
btn.disabled = isDisabled;
// Couleur de la barre selon le pourcentage
let barColor = '#28a745'; // Vert
if (pourcentage < 50) barColor = '#ffc107'; // Jaune
if (pourcentage < 25) barColor = '#dc3545'; // Rouge
if (pourcentage === 0) barColor = '#6c757d'; // Gris
btn.innerHTML = `
<div class="trad-btn-content">
<div class="trad-btn-header">
<i class="fas fa-language"></i>
<span class="trad-btn-label">${langue.name}</span>
</div>
<div class="trad-btn-stats">
<span class="trad-stat">${langue.remaining}/${langue.total}</span>
</div>
<div class="trad-progress">
<div class="trad-progress-bar" style="width: ${pourcentage}%; background-color: ${barColor};"></div>
</div>
<div class="trad-btn-details">
<span class="trad-detail">Matin: ${langue.by_periode.matin.remaining}/${langue.by_periode.matin.total}</span>
<span class="trad-detail">AM: ${langue.by_periode.apres_midi.remaining}/${langue.by_periode.apres_midi.total}</span>
</div>
</div>
`;
if (!isDisabled) {
btn.addEventListener('click', () => this.selectTraduction(slug));
}
return btn;
}
/**
* Sélectionne un département
*/
selectDepartement(slug) {
this.selectedDepartement = slug;
// Mettre à jour l'UI
this.departementsContainer.querySelectorAll('.filter-btn').forEach(btn => {
btn.classList.remove('active');
});
const btnId = slug ? `dept-${slug}` : 'dept-all';
const activeBtn = document.getElementById(btnId);
if (activeBtn) {
activeBtn.classList.add('active');
}
// Déclencher le filtrage
this.applyFilters();
}
/**
* Sélectionne une capacité de traduction
*/
selectTraduction(slug) {
this.selectedTraduction = slug;
// Mettre à jour l'UI
this.traductionsContainer.querySelectorAll('.filter-btn').forEach(btn => {
btn.classList.remove('active');
});
const btnId = slug ? `trad-${slug}` : 'trad-all';
const activeBtn = document.getElementById(btnId);
if (activeBtn) {
activeBtn.classList.add('active');
}
// Déclencher le filtrage
this.applyFilters();
}
/**
* Applique les filtres sélectionnés
*/
applyFilters() {
const filters = {
departement: this.selectedDepartement,
traduction: this.selectedTraduction
};
console.log('Filtres appliqués:', filters);
// Mettre à jour les filtres dans le formulaire principal si nécessaire
if (filters.departement) {
const deptSelect = document.getElementById('departement');
if (deptSelect) {
// Trouver l'option correspondante par le nom
const deptData = this.departements[filters.departement];
if (deptData) {
Array.from(deptSelect.options).forEach(option => {
if (option.textContent === deptData.nom) {
deptSelect.value = option.value;
}
});
}
}
} else {
const deptSelect = document.getElementById('departement');
if (deptSelect) {
deptSelect.value = '';
}
}
if (filters.traduction) {
const langueSelect = document.getElementById('langue');
if (langueSelect) {
// La valeur doit correspondre au slug de la langue
langueSelect.value = filters.traduction;
}
} else {
const langueSelect = document.getElementById('langue');
if (langueSelect) {
langueSelect.value = '';
}
}
// Déclencher un événement personnalisé pour que le calendrier se mette à jour
document.dispatchEvent(new CustomEvent('visualFiltersChanged', {
detail: filters
}));
// Déclencher le bouton de filtrage du formulaire principal
const filterBtn = document.getElementById('filterBtn');
if (filterBtn) {
filterBtn.click();
}
}
/**
* Configure les écouteurs d'événements globaux
*/
setupEventListeners() {
// Écouter les changements de date pour mettre à jour les capacités
const dateInput = document.getElementById('date');
if (dateInput) {
dateInput.addEventListener('change', () => {
// Recharger les capacités de traduction pour cette date
// (à implémenter si nécessaire)
});
}
// Réinitialiser les filtres visuels quand le bouton reset est cliqué
const resetBtn = document.getElementById('resetFiltersBtn');
if (resetBtn) {
resetBtn.addEventListener('click', () => {
this.selectedDepartement = null;
this.selectedTraduction = null;
this.renderDepartementsButtons();
this.renderTraductionsButtons();
});
}
}
/**
* Rafraîchit l'affichage des boutons
*/
refresh() {
this.renderDepartementsButtons();
this.renderTraductionsButtons();
}
/**
* Obtient les filtres actifs
*/
getActiveFilters() {
return {
departement: this.selectedDepartement,
traduction: this.selectedTraduction
};
}
}
// Exporter pour utilisation dans d'autres modules
export default AgendaVisualFilters;

View File

@ -7,6 +7,7 @@
<section class="agenda-container"> <section class="agenda-container">
<!--filters--> <!--filters-->
<section class="filters-container"> <section class="filters-container">
<form class="filters" method="get" onsubmit="return false;"> <form class="filters" method="get" onsubmit="return false;">
@ -122,6 +123,43 @@
</form> </form>
</section> </section>
<!-- Filtres visuels -->
<section class="visual-filters-container mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center" style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#visualFiltersCollapse" aria-expanded="true" aria-controls="visualFiltersCollapse">
<h5 class="mb-0">
<i class="fas fa-filter me-2"></i>Filtres rapides
</h5>
<button class="btn btn-link text-decoration-none p-0" type="button">
<i class="fas fa-chevron-down" id="visual-filters-toggle-icon"></i>
</button>
</div>
<div id="visualFiltersCollapse" class="collapse show">
<div class="card-body">
<!-- Filtres par département -->
<div class="filter-group mb-3">
<h6 class="filter-group-title">
<i class="fas fa-building me-2"></i>Départements
</h6>
<div id="departements-filter-buttons" class="button-filters">
<!-- Boutons générés dynamiquement -->
</div>
</div>
<!-- Filtres par capacité de traduction -->
<div class="filter-group">
<h6 class="filter-group-title">
<i class="fas fa-language me-2"></i>Capacités de traduction
</h6>
<div id="traductions-filter-buttons" class="button-filters">
<!-- Boutons générés dynamiquement -->
</div>
</div>
</div>
</div>
</div>
</section>
<!--agenda--> <!--agenda-->
<section class="agenda-inner-container" id="agenda-calendar"> <section class="agenda-inner-container" id="agenda-calendar">
<div id="loading-indicator" style="text-align: center; padding: 20px;"> <div id="loading-indicator" style="text-align: center; padding: 20px;">
@ -136,4 +174,24 @@
require_once dirname(__DIR__, 1) . '/modules/agenda-modal.php'; require_once dirname(__DIR__, 1) . '/modules/agenda-modal.php';
?> ?>
</section> </section>
<script>
// Animer l'icône du collapse des filtres visuels
document.addEventListener('DOMContentLoaded', function() {
const collapseElement = document.getElementById('visualFiltersCollapse');
const toggleIcon = document.getElementById('visual-filters-toggle-icon');
if (collapseElement && toggleIcon) {
collapseElement.addEventListener('show.bs.collapse', function() {
toggleIcon.style.transform = 'rotate(180deg)';
toggleIcon.style.transition = 'transform 0.3s ease';
});
collapseElement.addEventListener('hide.bs.collapse', function() {
toggleIcon.style.transform = 'rotate(0deg)';
toggleIcon.style.transition = 'transform 0.3s ease';
});
}
});
</script>
</div> </div>