diff --git a/app/controllers/Event_Controller.php b/app/controllers/Event_Controller.php index 8c25ff3..08decc4 100644 --- a/app/controllers/Event_Controller.php +++ b/app/controllers/Event_Controller.php @@ -661,11 +661,26 @@ class CRVI_Event_Controller { } public static function create_event($request) { $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(); $result = $model->create_event($data); if (is_wp_error($result)) { 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']); } public static function update_event($request) { @@ -689,7 +704,33 @@ class CRVI_Event_Controller { $data = []; } + // Récupérer l'événement existant pour obtenir les valeurs actuelles $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); if (is_wp_error($result)) { $error_code = $result->get_error_code(); @@ -697,6 +738,10 @@ class CRVI_Event_Controller { $status_code = isset($error_data['status']) ? $error_data['status'] : 400; 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']); } public static function delete_event($request) { @@ -706,9 +751,156 @@ class CRVI_Event_Controller { if (is_wp_error($result)) { 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']); } + /** + * 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é * POST /wp-json/crvi/v1/intervenant/permanences @@ -1418,6 +1610,10 @@ class CRVI_Event_Controller { if (is_wp_error($result)) { 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']); } @@ -1428,6 +1624,10 @@ class CRVI_Event_Controller { if (is_wp_error($result)) { 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']); } diff --git a/app/controllers/Plugin.php b/app/controllers/Plugin.php index 668a099..bb88fbd 100644 --- a/app/controllers/Plugin.php +++ b/app/controllers/Plugin.php @@ -328,14 +328,13 @@ class CRVI_Plugin { // 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'); - // 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'); - // 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); - // 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); self::localize_acf_data('crvi_main_agenda'); @@ -468,7 +467,8 @@ class CRVI_Plugin { 'couleurs_rdv' => self::get_couleurs_rdv_acf_data(), 'couleurs_permanence' => self::get_couleurs_permanence_acf_data(), '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); @@ -880,6 +880,27 @@ class CRVI_Plugin { 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 */ diff --git a/app/models/TraductionLangue_Model.php b/app/models/TraductionLangue_Model.php index 9709c33..3213f8b 100644 --- a/app/models/TraductionLangue_Model.php +++ b/app/models/TraductionLangue_Model.php @@ -840,6 +840,195 @@ class CRVI_TraductionLangue_Model extends Main_Model { 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 * À 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( "DELETE FROM {$wpdb->options} WHERE 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_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( "DELETE FROM {$wpdb->options} 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_timeout_crvi_all_langues_dispos_%' 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 diff --git a/assets/css/agenda-visual-filters.css b/assets/css/agenda-visual-filters.css new file mode 100644 index 0000000..0d635ef --- /dev/null +++ b/assets/css/agenda-visual-filters.css @@ -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; +} diff --git a/assets/css/crvi_main.css b/assets/css/crvi_main.css index 889fe23..73c8900 100644 --- a/assets/css/crvi_main.css +++ b/assets/css/crvi_main.css @@ -9,4 +9,6 @@ @import './agenda-events.css'; @import './crvi-agenda.css'; /* Overrides admin calendrier (sera minifié avec le reste via Vite) */ -@import './admin-calendar-overrides.css'; \ No newline at end of file +@import './admin-calendar-overrides.css'; +/* Filtres visuels agenda admin */ +@import './agenda-visual-filters.css'; \ No newline at end of file diff --git a/assets/js/crvi_main.js b/assets/js/crvi_main.js index b7b4de7..02770d8 100644 --- a/assets/js/crvi_main.js +++ b/assets/js/crvi_main.js @@ -11,6 +11,7 @@ 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'; +import AgendaVisualFilters from './modules/agenda-visual-filters.js'; /** * 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'); } + // 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'); if (modal) { // Vérifier que bootstrap est disponible diff --git a/assets/js/modules/agenda-visual-filters.js b/assets/js/modules/agenda-visual-filters.js new file mode 100644 index 0000000..56c98be --- /dev/null +++ b/assets/js/modules/agenda-visual-filters.js @@ -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 = ` + + ${label} + `; + 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 = ` +