# 🦙 Source Code PachaFamily > *Généré le 2026-05-05 16:41:29* ### 📄 Fichier : `budget.php` ```php

"; echo "

" . tr('budget_err_file_not_found') . "

"; echo "

" . sprintf(tr('budget_err_file_detail'), htmlspecialchars($tab)) . "

"; echo "
"; } } else { echo "

" . tr('budget_err_not_authorized') . "

"; } ?> ``` --- ### 📄 Fichier : `family-calendar.php` ```php

📸

ALEX LAIA
CPJRAJA CPJRAJA

``` --- ### 📄 Fichier : `footer.php` ```php ``` --- ### 📄 Fichier : `generate_css_prompt.php` ```php isDir()) continue; $filePath = $file->getPathname(); $relativePath = str_replace($root, '', $filePath); $ext = pathinfo($filePath, PATHINFO_EXTENSION); // 1. EXTRACTION DES FICHIERS .css if ($ext === 'css') { $report .= "/* 📄 FICHIER SOURCE : $relativePath */\n"; $report .= file_get_contents($filePath) . "\n"; $report .= str_repeat("-", 40) . "\n\n"; } // 2. EXTRACTION DES BALISES
FR | CA

``` --- ### 📄 Fichier : `holidays.php` ```php '; require __DIR__ . '/footer.php'; ``` --- ### 📄 Fichier : `includes/auth.php` ```php false, 'error' => 'Session expirée. Veuillez vous reconnecter.']); exit; } $currentUrl = $_SERVER['REQUEST_URI'] ?? '/'; $redirectParam = urlencode($currentUrl); $target = ($loginPage ?? '/login.php') . '?redirect=' . $redirectParam; header('Location: ' . $target); exit; } } ``` --- ### 📄 Fichier : `includes/config.php` ```php PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_general_ci", PDO::ATTR_TIMEOUT => 30, ]; try { $pdo = new PDO($dsn, $user, $pass, $options); // Forcer la collation $pdo->exec("SET collation_connection = utf8mb4_general_ci"); $pdo->exec("SET collation_database = utf8mb4_general_ci"); $pdo->exec("SET collation_server = utf8mb4_general_ci"); // Debug optionnel (à commenter en production) // $env = $isLocal ? 'LOCAL' : 'SERVEUR'; // echo ""; } catch (\PDOException $e) { $environment = $isLocal ? 'local (XAMPP)' : 'serveur (OVH)'; die("Erreur de connexion ($environment) : " . $e->getMessage()); } ?> ``` --- ### 📄 Fichier : `includes/i18n.php` ```php 'Proves en curs...', 'test_passed' => 'Prova superada', 'test_failed' => 'Prova fallida', 'tests_title' => 'Laboratori de Proves', 'tests_run_budget' => 'Provar el Pressupost', 'tests_run_all' => 'Iniciar totes les proves', 'tests_copy_report' => 'Copiar l\'informe', 'tests_waiting' => 'Esperant l\'inici...', 'tests_report_copied' => 'Informe copiat al porta-retalls!', // ========================================== // GLOBAL & NAVIGATION // ========================================== 'menu_home' => 'Inici', 'menu_calendar' => 'Calendari', 'menu_budget' => 'Pressupost', 'menu_holidays' => 'Viatges', 'menu_gifts' => 'Regals', 'nav_home' => 'Inici', 'nav_calendar' => 'Calendari', 'nav_budget' => 'Pressupost', 'nav_holidays' => 'Viatges', 'nav_gifts' => 'Regals', 'aria_menu' => 'Menú mòbil', // Boutons et actions globales 'actions' => 'Accions', 'edit' => 'Modificar', 'delete' => 'Suprimir', 'save' => 'Desar', 'cancel' => 'Cancel·lar', 'btn_save' => 'Guardar', 'btn_cancel' => 'Cancel·lar', 'btn_delete' => 'Eliminar', 'btn_close' => 'Tancar', 'btn_back' => '◀ Tornar', 'btn_edit_bases' => '⚙️ Modificar les bases', 'btn_edit' => 'Modificar', 'error_occured' => 'S\'ha produït un error.', // Mots génériques & Erreurs 'error' => 'Error.', 'confirm_delete' => 'Estàs segur que vols suprimir aquest element?', // Mois & Jours 'month_01' => 'Gener', 'month_02' => 'Febrer', 'month_03' => 'Març', 'month_04' => 'Abril', 'month_05' => 'Maig', 'month_06' => 'Juny', 'month_07' => 'Juliol','month_08' => 'Agost', 'month_09' => 'Setembre', 'month_10' => 'Octubre','month_11' => 'Novembre','month_12' => 'Desembre', 'day_mon' => 'Dl', 'day_tue' => 'Dt', 'day_wed' => 'Dc', 'day_thu' => 'Dj', 'day_fri' => 'Dv', 'day_sat' => 'Ds', 'day_sun' => 'Dg', // ========================================== // AUTHENTIFICATION / LOGIN // ========================================== 'login_title' => 'Connexió - PachaFamily', 'login_header' => 'Connexió', 'label_username' => "Nom d'usuari", 'label_password' => 'Contrasenya', 'placeholder_username' => 'ex: pacha', 'btn_login' => 'Connexió', 'btn_login_submit' => 'Connectar-se', 'logout' => 'Tancar sessió', 'btn_logout' => 'Tancar sessió', 'error_missing_fields' => 'Camps obligatoris.', 'error_invalid_credentials' => 'Identificadors incorrectes.', // ========================================== // ACCUEIL / INDEX // ========================================== 'home_title' => 'PachaFamily - Inici', 'home_welcome' => 'Benvinguts a PachaFamily', 'home_subtitle' => "Centre de control de l'organització familiar.", 'home_logged_as' => 'Connectat com a', 'home_modules_title' => 'Mòduls', 'mod_calendar_name' => 'Calendari Familiar', 'mod_calendar_desc' => "Gestioneu les vacances, les modalitats de cura i l'organització setmanal.", 'mod_holidays_name' => 'Vacances', 'mod_holidays_desc' => 'Planifiqueu les vostres properes escapades, destinacions i idees de viatge.', 'mod_gifts_name' => 'Regals', 'mod_gifts_desc' => "Planifiqueu els regals per a cada infant, per Nadal i els aniversaris.", 'mod_budget_name' => 'Pressupost', 'mod_budget_desc' => 'Seguiment de despeses fixes, ingressos i balanç del compte familiar.', 'cta_open' => 'Obrir', 'cta_explore' => 'Explorar', 'cta_view_lists' => 'Veure les llistes', 'cta_manage' => 'Gestionar', // ========================================== // FAMILY CALENDAR // ========================================== 'fc_page_title' => 'PachaFamily - Calendari', 'fc_main_header' => 'Calendari Familiar', 'fc_btn_correct_balances' => 'Corregir els saldos', 'fc_btn_school_holidays' => 'Vacances escolars', 'fc_modal_holidays_title' => 'Vacances Escolars (Zona C)', 'fc_modal_snap_title' => 'Ajustar un saldo', 'fc_col_period' => 'Període', 'fc_col_from' => 'Des del', 'fc_col_to' => 'Fins al', 'fc_label_person' => 'Persona', 'fc_label_leave_type' => 'Tipus de permís', 'fc_label_apply_date' => "Data d'aplicació", 'fc_snap_date_hint' => "Trieu preferiblement el primer dia d'un mes", 'fc_label_remaining_balance'=> 'Saldo restant en aquesta data', 'fc_placeholder_balance' => 'ex: 12.5', 'fc_btn_save_snap' => 'Desar el correctiu', 'fc_view_1m' => '1 mes', 'fc_view_2m' => '2 mesos', 'fc_view_3m' => '3 mesos', 'fc_view_year' => 'Any', 'fc_weekly_planning' => 'Planificació setmanal', 'fc_col_month' => 'Mes', 'fc_col_week_short' => 'Set.', 'fc_col_av' => 'Av.', 'fc_col_use' => 'Use', 'fc_legend_title' => 'Llegenda', 'fc_summary_title' => 'Resum', 'fc_summ_year' => 'Any', 'fc_summ_month' => 'Mes', 'fc_school_year' => 'Curs escolar', 'fc_unit_days' => 'dies', 'fc_menu_carole' => 'Permisos Carole', 'fc_clear' => 'Esborrar', 'fc_menu_kids_leaves' => 'Permisos nens', 'leg_school_holidays' => 'Vacances Escolars', 'leg_public_holiday' => 'Festiu', 'leg_off_carole' => 'Off Carole', 'leg_extra_off' => 'Extra Off', 'leg_centre' => 'Casal', 'leg_avis' => 'Avís', 'leg_pep_sick' => 'Pep Malalt', 'leg_presence' => 'Presència', 'vac_toussaint' => 'Vacances de Tots Sants', 'vac_noel' => 'Vacances de Nadal', 'vac_hiver' => "Vacances d'Hivern", 'vac_printemps' => 'Vacances de Primavera', 'vac_ascension' => "Pont de l'Ascensió", 'vac_ete' => "Vacances d'Estiu", 'btn_off' => 'Off', 'btn_extra' => 'Extra', 'fc_alert_burn_days' => '⚠️ Risc de pèrdua : %s d a gastar abans del %s', 'fc_alert_burn_jra' => '⚠️ JRA: Només 2d es poden reportar després de febrer!', // ========================================== // HOLIDAYS (VOYAGES) // ========================================== 'hdl_title_list' => 'PachaFamily - Les meves vacances', 'hdl_title_detail' => 'PachaFamily - Detall del viatge', 'hdl_main_title' => 'Les meves vacances', 'hdl_filter_all' => 'Mostrar-ho tot', 'hdl_filter_year' => 'Any', 'hdl_left_to_pay_label' => 'Resta per pagar', 'hdl_btn_create' => 'Crear un viatge', 'hdl_no_active_trips' => 'Cap viatge en curs per aquest període. Planifiquem alguna cosa!', 'hdl_history_title' => 'Historial', 'hdl_dates_to_define' => 'Dates per definir', 'hdl_total_budget' => 'Pressupost Total', 'hdl_paid' => 'Pagat', 'hdl_saved' => 'Finançat', 'hdl_left_to_pay' => 'Resta per pagar', 'hdl_status_booked' => 'Confirmat', 'hdl_status_planned' => 'Planificat', 'hdl_status_draft' => 'Esborrany', 'hdl_status_passed' => 'Passat', 'hdl_status_archived' => 'Arxivat', 'hdl_modal_title' => 'Planificar el viatge', 'hdl_label_name' => 'Nom del viatge', 'hdl_ph_name' => 'Ex: Octubre - Portugal', 'hdl_label_status' => 'Estat', 'hdl_label_period' => 'Període (Text lliure)', 'hdl_ph_period' => 'Ex: Octubre 2026', 'hdl_label_from' => 'Des del', 'hdl_label_to' => 'Fins al', 'hdl_cat_transport' => 'Transport', 'hdl_add_transport' => 'Afegir un transport', 'hdl_cat_accommodation' => 'Allotjament', 'hdl_add_accommodation' => 'Afegir un allotjament', 'hdl_cat_activity' => 'Activitat', 'hdl_add_activity' => 'Afegir una activitat', 'hdl_label_budget_food' => 'Pressupost Menjar i Beguda (€)', 'hdl_label_budget_extras' => 'Pressupost Extras (€)', 'hdl_label_notes' => 'Notes', 'hdl_ph_notes' => 'Idees soltes...', 'hdl_label_budget_food_extras' => 'Pressupost Menjar i Extres', 'hdl_map_itinerary' => 'Itinerari', 'hdl_outbound' => 'Anada', 'hdl_return' => 'Tornada', 'hdl_btn_add_step' => 'Afegir una etapa', 'hdl_step_details' => 'Detalls del roadtrip', 'hdl_no_steps' => 'Cap etapa definida. Comenceu per afegir-ne una!', 'hdl_general_costs' => 'Despeses generals i Pressupostos', 'hdl_food_bev' => 'Menjar i Beguda', 'hdl_extras' => 'Extres', 'hdl_view_planning' => 'Veure la planificació', 'hdl_passing_point' => 'Punt de pas (sense despeses)', 'hdl_search_location' => 'Cercar un lloc', 'hdl_ph_search' => 'Ciutat, hotel, adreça...', 'hdl_label_step_name' => "Nom de l'etapa", 'hdl_label_arrival' => 'Arribada', 'hdl_label_departure' => 'Sortida', 'hdl_planned_expenses' => 'Despeses previstes per a aquesta etapa', 'hdl_btn_add_expense' => '+ Afegir una despesa', 'hdl_save_fav' => 'Desar als meus preferits', 'hdl_planning_title' => 'Planificació de l’etapa', 'hdl_to_pay' => 'Per pagar', 'hdl_js_search_loading' => 'Cerca en curs...', 'hdl_js_no_result' => 'No s’han trobat resultats.', 'hdl_js_network_error' => 'Error de connexió al servei de mapes.', 'hdl_js_confirm_del_trip' => 'Estàs segur que vols suprimir aquest viatge sencer?', 'hdl_js_confirm_del_step' => 'Suprimir aquesta etapa?', 'hdl_js_step_label' => 'Etapa', 'hdl_js_ph_expense_name' => 'Ex: Hotel, Peatge...', 'hdl_quick_edit_title' => 'Modificació ràpida', 'hdl_js_edit_step' => 'Modificar l’etapa', 'hdl_js_missing_dates_title'=> 'Dates mancants', 'hdl_js_missing_dates_msg' => 'Cal definir una data d’arribada i sortida per organitzar la planificació.', 'hdl_to_place' => 'Per situar', 'hdl_from' => 'Des del', 'hdl_to' => 'Fins al', 'hdl_btn_add_step' => 'Afegir una etapa', 'hdl_quick_edit_title' => 'Modificació ràpida', 'hdl_paid' => 'Pagat', 'weather_forecast' => 'Previsions meteorològiques', 'weather_not_available' => 'Temps no disponible', 'temp_max' => 'Temp. Màxima', 'weather_loading' => 'Carregant el temps...', 'weather_error' => 'Temps no disponible', 'weather_sunny' => 'Assolellat', 'weather_cloudy' => 'Ennuvolat', 'weather_rainy' => 'Plujós', 'weather_snowy' => 'Nevat', 'weather_historical' => 'Basat en l\'històric (estimació)', 'err_invalid_holiday_id' => 'ID de viatge no vàlid.', 'hdl_default_exp_name' => 'Despesa', 'hdl_select_beneficiary' => '-- Seleccionar --', // Anciennes clés Holidays non préfixées (conservades) 'outbound_trip' => 'Anada', 'return_trip' => 'Tornada', 'period' => 'Període', 'budget_food' => 'Pressupost Menjar i Extres', 'total_cost' => 'Cost Total Estimat', 'paid' => 'Pagat', 'provisioned' => 'Provisionat', 'left_to_pay' => 'Pendent de pagament', 'map_itinerary' => '🗺️ Itinerari', 'place_step' => '📍 Afegir una etapa', 'step_details' => '📝 Detall de les etapes', 'no_steps' => 'Cap etapa planificada.', 'travel_notes' => '📝 Notes del viatge', 'to_be_defined' => 'A definir', 'funded' => 'Finançat (Provisió)', 'view_planning' => 'Veure el planning', 'to_pay' => 'A pagar', 'from_date' => 'Del', 'to_date' => 'al', 'passing_point' => 'Punt de pas (Cap despesa)', 'search_location' => 'Cercar una ubicació geogràfica', 'search_placeholder' => 'Ex: París, Ibis Barcelona...', 'step_name' => 'Nom de l\'etapa', 'arrival_optional' => 'Arribada el (Opcional)', 'departure_optional' => 'Sortida el (Opcional)', 'planned_expenses' => 'Despeses previstes', 'add_expense' => '+ Afegir una despesa', 'save_favorite' => 'Guardar als meus preferits', 'planning_title' => 'Planning', 'modal_plan_trip' => 'Planificar el viatge', 'modal_quick_edit' => 'Edició ràpida', 'err_modal_missing' => 'Error: La modal holidayModal no es troba.', 'placeholder_title' => 'Títol', 'placeholder_price' => 'Preu (€)', 'already_paid' => 'Ja pagat?', 'remove_line' => 'Treure aquesta línia', 'confirm_delete_trip' => 'Vols eliminar definitivament aquest viatge? Aquesta acció és irreversible.', 'step_number' => 'Etapa', 'place_new_step' => '📍 Afegir una nova etapa', 'edit_step' => '✏️ Modificar l\'etapa', 'search_in_progress' => 'Cerca en curs... ⏳', 'no_result_found' => 'Cap resultat trobat.', 'network_error' => 'Error de xarxa.', 'placeholder_label' => 'Etiqueta', 'placeholder_notes_link' => '🔗 Enllaç de reserva o notes (Opcional)...', 'confirm_delete_step' => 'Eliminar aquesta etapa i totes les despeses associades?', 'planning_of' => '📅 Planning : ', 'missing_dates_title' => '⚠️ Dates que falten', 'missing_dates_msg' => 'Si us plau, modifica aquesta etapa (✏️) i omple la seva Data d\'arribada i de sortida.', 'to_place' => '📥 A col·locar', // ========================================== // BUDGET (GLOBAL & CATEGORIES) // ========================================== 'budget_page_title' => 'PachaFamily - Pressupost', 'budget_main_header' => 'Gestió del Pressupost', 'budget_tab_tracking' => 'Seguiment Mensual', 'budget_tab_prev' => 'Pressupost 2026', 'budget_tab_savings' => 'Estalvis', 'budget_tab_recap' => 'Resum', 'budget_err_file_not_found' => 'Vaja! Fitxer no trobat.', 'budget_err_file_detail' => 'Comproveu que el fitxer views/%s.php existeix.', 'budget_err_not_authorized' => 'Pàgina no autoritzada.', 'cat_income' => 'Ingressos', 'cat_fmcg' => 'Compres (FMCG)', 'cat_fuel' => 'Benzina', 'cat_school' => 'Escola / Llar d’infants', 'cat_fixed' => 'Despeses Fixes', 'cat_others' => 'Altres / Imprevistos', 'cat_savings' => 'Estalvis', // --- BUDGET : SUIVI --- 'bud_rem_school' => 'Escola (Resta estimada)', 'bud_rem_fmcg' => 'Compres (Resta estimada)', 'bud_rem_fuel' => 'Benzina (Resta estimada)', 'bud_closed_archived' => 'Aquest mes està tancat i arxivat.', 'bud_budget_of' => 'Pressupost:', 'bud_confirm_close' => 'Tancar aquest mes? Això congelarà els saldos actuals i canviarà el seguiment per defecte al mes vinent.', 'bud_close_btn' => 'Tancar', 'bud_confirm_reopen' => 'Reobrir aquest mes? Els seus saldos es recalcularan en directe.', 'bud_reopen_btn' => 'Reobrir', 'bud_active_month_btn' => 'Mes Actiu', 'bud_bank_balance' => 'Saldo bancari', 'bud_frozen' => 'Congelat', 'bud_current' => 'Actual', 'bud_update_btn' => 'Act.', 'bud_theoretical_balance' => 'Saldo teòric final de mes', 'bud_upcoming_charges' => 'Càrrecs pendents', 'bud_all_paid' => 'Tot està pagat!', 'bud_capacity_tooltip' => 'Càlcul: Report Mes Anterior (%s€) + Sous (%s€) + Altres ingressos (%s€)', 'bud_max_capacity' => 'Capacitat Màx', 'bud_actual' => 'Actual', 'bud_theoretical' => 'Teòric', 'bud_import_csv' => 'Importar CSV', 'bud_csv_file' => 'Fitxer CSV (Punt i coma)', 'bud_preview' => 'Previsualitzar', 'bud_validate_import' => 'Validar importació', 'bud_assign_month' => 'Assignar al mes', 'bud_op_date' => 'Data Operació', 'bud_label' => 'Concepte', 'bud_amount' => 'Import', 'bud_category' => 'Categoria', 'bud_ignore' => 'Ignorar', 'bud_to_define' => 'Per definir', 'bud_is_charge' => 'Despesa?', 'bud_is_income' => 'Ingrés?', 'btn_import' => 'Importar', 'bud_no_lines' => 'Cap línia.', 'bud_confirm_delete' => 'Suprimir aquesta línia?', 'bud_new_transaction' => 'Nova transacció', 'bud_assign_to_month' => 'Assignar al mes de gestió', 'bud_real_date' => 'Data real de l\'operació', 'bud_type' => 'Tipus', 'bud_type_expense' => 'Despesa', 'bud_type_income' => 'Ingrés / Reemborsament', 'bud_beneficiary' => 'Beneficiari', 'bud_fixed_charge' => 'Despesa Fixa', 'bud_expected_income' => 'Ingrés Esperat', 'bud_amount_eur' => 'Import (€)', 'bud_update_balance' => 'Act. Saldo Bancari', 'bud_balance_eur' => 'Saldo (€)', 'bud_add_title' => 'Afegir', 'bud_edit_title' => 'Modificar la transacció', 'bud_to_define_js' => 'per definir', 'bud_bar_actual' => "Actual", 'bud_bar_theoretical' => "Teòric", 'bud_bar_max_cap' => "Capacitat Màx.", // --- MODULE BUDGET : IMPORT CSV --- 'bud_import_csv' => "Importar CSV", 'bud_csv_file' => "Seleccioneu el fitxer del vostre banc (.csv)", 'bud_preview' => "Previsualitzar", 'bud_validate_import' => "Si us plau, comproveu i categoritzeu les línies abans de validar.", 'bud_assign_month' => "Mes de gestió", 'bud_op_date' => "Data", 'bud_amount' => "Import", 'bud_ignore' => "Ignorar (Crèdit)", 'bud_to_define' => "Per definir", 'bud_is_charge' => "És un càrrec fix...", 'bud_is_income' => "És un ingrés...", // --- BUDGET : RECAP --- 'bud_recap_monthly_title' => 'Resum Mensual', 'bud_recap_btn_add' => 'Afegir una despesa / ingrés', 'bud_planned_amount' => 'Import Previst', 'bud_day' => 'Dia', 'bud_month_state' => 'Estat del mes', 'bud_est_short' => 'Est.', 'bud_reg_planned_in' => 'Regularització prevista al %s', 'bud_bonus' => 'Bonificació', 'bud_overrun' => 'Excés', 'bud_remaining' => 'Resta', 'bud_exact_amount' => 'Import exacte', 'bud_per_month_short' => 'És a dir', 'bud_month_short' => 'mes', 'bud_freq_mensuel' => 'Mensual', 'bud_freq_annuel' => 'Anual', 'bud_state_validated' => 'Validat', 'bud_state_partial' => 'Parcial', 'bud_state_waiting' => 'En espera', 'bud_total_income_smoothed' => 'Total Ingressos (Prorratejats)', 'bud_total_expenses_smoothed'=> 'Total Despeses (Prorratejades)', 'bud_theoretical_balance_recap'=> 'Equilibri teòric', 'bud_recap_footer_note' => 'L\'estat del pagament s\'actualitza automàticament en funció de les operacions importades a la pestanya "Seguiment Mensual".', 'bud_recap_modal_add' => 'Afegir un element', 'bud_recap_modal_edit' => 'Modificar', 'bud_label_name' => 'Nom', 'bud_ph_item_name' => 'Ex: Lloguer, Sou...', 'bud_label_keywords' => 'Paraules clau (Detecció Auto)', 'bud_ph_keywords' => 'Ex: NETFLIX, PRIME VIDEO (separades per coma)', 'bud_help_keywords' => 'Si una despesa del mes conté aquesta paraula, la línia passarà a "Validat".', 'bud_label_day' => 'Dia (1-31)', 'bud_cat_expense' => 'Despesa', 'bud_label_frequency' => 'Freqüència', 'bud_label_amount_type' => 'Tipus d\'import', 'bud_type_fixed' => 'Fix (Factura)', 'bud_type_variable' => 'Variable (Estimació)', 'bud_label_regularization' => 'Regularització', 'bud_reg_none' => 'Cap', 'bud_recap_confirm_delete' => 'Segur que vols suprimir aquest element?', 'bud_err_delete' => 'Error en suprimir.', 'bud_sav_modal_title_add' => 'Introduir un mes', 'bud_sav_ph_name' => 'Nom (ex: Vacances)', 'bud_prev_label_name' => 'Nom', // --- BUDGET : EPARGNE --- 'bud_sav_add_month' => 'Introduir un mes', 'bud_sav_add_one_month' => '+1 Mes', 'bud_sav_no_data' => 'Cap dada per a %s.', 'bud_sav_post_month' => 'Concepte / Mes', 'bud_sav_from_date' => 'Des del %s', 'bud_sav_edit_modal' => 'Modificar amb Modal', 'bud_sav_delete_month' => 'Suprimir tot el mes', 'bud_sav_total_bank' => 'Total Banc', 'bud_sav_extra' => 'Extra', 'bud_sav_modal_title_add' => 'Introduir un mes', 'bud_sav_modal_title_edit' => 'Modificar:', 'bud_sav_month_concerned' => 'Mes afectat', 'bud_sav_total_bank_eur' => 'Total al Banc (€)', 'bud_sav_ventilation' => 'Desglossament', 'bud_sav_adj_help' => 'Utilitzeu l\'ajust (+/-) per recalcular automàticament.', 'bud_sav_add_line' => 'Línia', 'bud_sav_current' => 'Actual', 'bud_sav_adjust' => 'Ajust (+/-)', 'bud_sav_new' => 'Nou', 'bud_sav_sum_mode_title' => 'Activar la suma ràpida', 'bud_sav_selection' => 'Selecció:', 'bud_sav_ph_name' => 'Nom (ex: Vacances)', 'bud_sav_saving' => 'Desant...', 'bud_err_tech' => 'S\'ha produït un error tècnic.', 'bud_sav_confirm_delete_month' => 'Suprimir TOT el mes de %m per a %o?', 'bud_sav_prompt_duplicate' => "Vols copiar les dades de %s a %t1?\n\nIntrodueix el nou TOTAL al banc (€) per a %t2:", 'bud_err_server' => 'Error del servidor: ', 'bud_err_network_dup' => 'Error de xarxa en duplicar.', // --- BUDGET : PREVISIONNEL --- 'bud_prev_incomes' => 'Ingressos', 'bud_prev_person' => 'Persona', 'bud_prev_salary' => 'Sou', 'bud_prev_monthly_payment' => 'Mensualitat', 'bud_prev_joint_account' => 'Cte Comú', 'bud_prev_func_expenses' => 'Desp. Func.', 'bud_prev_perso_savings' => 'Est. Perso', 'bud_prev_family_savings' => 'Est. Família', 'bud_prev_available' => 'Dispo', 'bud_prev_perso_remaining' => 'Restant Perso', 'bud_prev_budget_alloc' => 'Distribució Pressupostària', 'bud_prev_today' => 'Avui', 'bud_prev_new_line' => 'Nova Línia', 'bud_prev_remaining' => 'Restant', 'bud_prev_confirm_del_line' => 'Suprimir aquesta línia i tot el seu historial?', 'bud_prev_notes_for' => 'Notes per a', 'bud_prev_saved' => 'Desat', 'bud_prev_notes_ph' => 'Escriu les teves observacions, recordatoris o comentaris per a aquest mes aquí...', 'bud_prev_save_note' => 'Desar la nota', 'bud_prev_transfers_to_make' => 'Transferències a fer', 'bud_prev_destination' => 'Destinació', 'bud_prev_done' => 'FET', 'bud_prev_validate' => 'Validar', 'bud_prev_grand_total' => 'GRAN TOTAL', 'bud_prev_new_line_title' => 'Nova Línia de Pressupost', 'bud_prev_edit_line_title' => 'Modificar la línia', 'bud_prev_label_target' => 'Objectiu (Destinació)', 'bud_prev_choose' => 'Tria', 'bud_prev_link_holiday' => 'Enllaçar a un viatge (Opcional)', 'bud_prev_no_link' => 'No associar', 'bud_prev_err_no_history' => 'Impossible: no hi ha historial disponible per copiar.', 'bud_prev_confirm_copy' => "Vols copiar les dades de %s a %t?\n\n⚠️ Això sobreescriurà tots els valors ja presents per a %t.", 'bud_prev_confirm_transfers' => "Confirmes que %p ha fet totes les transferències per a %m?\n\nAixò actualitzarà l'Estalvi automàticament.", // ========================================== // CADEAUX (GIFTS) // ========================================== 'gift_page_title' => 'PachaFamily - Llista de regals', 'gift_occ_tio' => 'Tió', 'gift_occ_noel' => 'Nadal', 'gift_occ_rois' => 'Reis', 'gift_occ_anniv' => 'Aniversari', 'gift_occ_sant' => 'Sant', 'gift_main_title' => 'Llista de regals %s', 'gift_aria_change_view' => 'Canvia la vista', 'gift_view_nadal' => 'Nadal', 'gift_view_anniv' => 'Aniversaris', 'gift_view_by_party' => 'Vista per festa', 'gift_no_gifts' => 'No hi ha cap regal registrat per a %s en aquesta vista.', 'gift_add_gift' => 'Afegeix un regal', 'gift_paid_by' => 'pagat per %s', 'gift_summary_title' => 'Resum del pressupost', 'gift_col_adult' => 'Adult', 'gift_col_child' => 'Infant', 'gift_col_party' => 'Festa', 'gift_debtor' => 'Deutor', 'gift_creditor' => 'Creditor', 'gift_liquidations' => 'Liquidacions', 'gift_no_debt' => 'Cap deute pendent.', 'gift_owes' => 'ha de pagar', 'gift_to' => 'a', 'gift_detailed_list' => 'Llista detallada de regals', 'gift_col_gift' => 'Regal', 'gift_col_link' => 'Enllaç', 'gift_modal_title_add' => 'Afegeix un regal per a %s', 'gift_modal_title_edit' => 'Edita el regal', 'gift_modal_payer' => 'Pagat per', 'gift_modal_gift_name' => 'Nom del regal', 'gift_modal_ph_name' => 'p. ex., Lego Star Wars', 'gift_modal_price' => 'Preu (€)', 'gift_modal_link' => 'Enllaç (opcional)', 'gift_confirm_delete' => 'Vols eliminar aquest regal?', ]; ``` --- ### 📄 Fichier : `includes/lang/fr.php` ```php 'Tests en cours...', 'test_passed' => 'Test réussi', 'test_failed' => 'Test échoué', 'tests_title' => 'Laboratoire de Tests', 'tests_run_budget' => 'Tester le Budget', 'tests_run_all' => 'Lancer tous les tests', 'tests_copy_report' => 'Copier le rapport', 'tests_waiting' => 'En attente du lancement...', 'tests_report_copied' => 'Rapport copié dans le presse-papier !', // ========================================== // GLOBAL & NAVIGATION // ========================================== 'menu_home' => 'Accueil', 'menu_calendar' => 'Calendrier', 'menu_budget' => 'Budget', 'menu_holidays' => 'Voyages', 'menu_gifts' => 'Cadeaux', 'nav_home' => 'Accueil', 'nav_calendar' => 'Calendrier', 'nav_budget' => 'Budget', 'nav_holidays' => 'Voyages', 'nav_gifts' => 'Cadeaux', 'aria_menu' => 'Menu mobile', // Boutons et actions globales 'actions' => 'Actions', 'edit' => 'Modifier', 'delete' => 'Supprimer', 'save' => 'Enregistrer', 'cancel' => 'Annuler', 'btn_save' => 'Enregistrer', 'btn_cancel' => 'Annuler', 'btn_delete' => 'Supprimer', 'btn_close' => 'Fermer', 'btn_back' => '◀ Retour', 'btn_edit_bases' => '⚙️ Modifier les bases', 'btn_edit' => 'Modifier', 'error_occured' => 'Une erreur est survenue.', // Mots génériques & Erreurs 'error' => 'Erreur.', 'confirm_delete' => 'Es-tu sûr de vouloir supprimer cet élément ?', // Mois & Jours 'month_01' => 'Janvier', 'month_02' => 'Février', 'month_03' => 'Mars', 'month_04' => 'Avril', 'month_05' => 'Mai', 'month_06' => 'Juin', 'month_07' => 'Juillet', 'month_08' => 'Août', 'month_09' => 'Septembre', 'month_10' => 'Octobre', 'month_11' => 'Novembre','month_12' => 'Décembre', 'day_mon' => 'Lun', 'day_tue' => 'Mar', 'day_wed' => 'Mer', 'day_thu' => 'Jeu', 'day_fri' => 'Ven', 'day_sat' => 'Sam', 'day_sun' => 'Dim', // ========================================== // AUTHENTIFICATION / LOGIN // ========================================== 'login_title' => 'Connexion - PachaFamily', 'login_header' => 'Connexion', 'label_username' => 'Identifiant', 'label_password' => 'Mot de passe', 'placeholder_username' => 'ex: pacha', 'btn_login' => 'Connexion', 'btn_login_submit' => 'Se connecter', 'logout' => 'Déconnexion', 'btn_logout' => 'Déconnexion', 'error_missing_fields' => 'Champs obligatoires.', 'error_invalid_credentials' => 'Identifiants incorrects.', // ========================================== // ACCUEIL / INDEX // ========================================== 'home_title' => 'PachaFamily - Accueil', 'home_welcome' => 'Bienvenue sur PachaFamily', 'home_subtitle' => "Centre de contrôle de l'organisation familiale.", 'home_logged_as' => 'Connecté en tant que', 'home_modules_title' => 'Modules', 'mod_calendar_name' => 'Calendrier Familial', 'mod_calendar_desc' => "Gérez les congés, les modes de garde et l'organisation hebdomadaire.", 'mod_holidays_name' => 'Vacances', 'mod_holidays_desc' => 'Planifiez vos prochaines escapades, destinations et idées de voyage.', 'mod_gifts_name' => 'Cadeaux', 'mod_gifts_desc' => 'Planifiez les cadeaux pour chaque enfant, pour Noël et les anniversaires.', 'mod_budget_name' => 'Budget', 'mod_budget_desc' => 'Suivi des dépenses fixes, revenus et équilibre du compte familial.', 'cta_open' => 'Ouvrir', 'cta_explore' => 'Explorer', 'cta_view_lists' => 'Voir les listes', 'cta_manage' => 'Gérer', // ========================================== // FAMILY CALENDAR // ========================================== 'fc_page_title' => 'PachaFamily - Calendrier', 'fc_main_header' => 'Calendrier Familial', 'fc_btn_correct_balances' => 'Corriger les soldes', 'fc_btn_school_holidays' => 'Vacances scolaires', 'fc_modal_holidays_title' => 'Vacances Scolaires (Zone C)', 'fc_modal_snap_title' => 'Ajuster un solde', 'fc_col_period' => 'Période', 'fc_col_from' => 'Du', 'fc_col_to' => 'Au', 'fc_label_person' => 'Personne', 'fc_label_leave_type' => 'Type de congé', 'fc_label_apply_date' => "Date d'application", 'fc_snap_date_hint' => "Choisissez de préférence le 1er jour d'un mois", 'fc_label_remaining_balance'=> 'Solde restant à cette date', 'fc_placeholder_balance' => 'ex: 12.5', 'fc_btn_save_snap' => 'Enregistrer le correctif', 'fc_view_1m' => '1 mois', 'fc_view_2m' => '2 mois', 'fc_view_3m' => '3 mois', 'fc_view_year' => 'Année', 'fc_weekly_planning' => 'Planning hebdomadaire', 'fc_col_month' => 'Mois', 'fc_col_week_short' => 'Sem.', 'fc_col_av' => 'Av.', 'fc_col_use' => 'Use', 'fc_legend_title' => 'Légende', 'fc_summary_title' => 'Récapitulatif', 'fc_summ_year' => 'Année', 'fc_summ_month' => 'Mois', 'fc_school_year' => 'Année scolaire', 'fc_unit_days' => 'jours', 'fc_menu_carole' => 'Congés Carole', 'fc_clear' => 'Effacer', 'fc_menu_kids_leaves' => 'Congés Enfants', 'leg_school_holidays' => 'Vacances Scolaires', 'leg_public_holiday' => 'Férié', 'leg_off_carole' => 'Off Carole', 'leg_extra_off' => 'Extra Off', 'leg_centre' => 'Centre', 'leg_avis' => 'Avis', 'leg_pep_sick' => 'Pep Malade', 'leg_presence' => 'Présence', 'vac_toussaint' => 'Vacances de la Toussaint', 'vac_noel' => 'Vacances de Noël', 'vac_hiver' => "Vacances d'Hiver", 'vac_printemps' => 'Vacances de Printemps', 'vac_ascension' => 'Pont de l’Ascension', 'vac_ete' => "Vacances d'Été", 'btn_off' => 'Off', 'btn_extra' => 'Extra', 'fc_alert_burn_days' => '⚠️ Risque de perte : %s j à poser avant le %s', 'fc_alert_burn_jra' => '⚠️ JRA : Seuls 2j sont reportables après février !', // ========================================== // HOLIDAYS (VOYAGES) // ========================================== 'hdl_title_list' => 'PachaFamily - Mes Vacances', 'hdl_title_detail' => 'PachaFamily - Détail du voyage', 'hdl_main_title' => 'Mes Vacances', 'hdl_filter_all' => 'Tout afficher', 'hdl_filter_year' => 'Année', 'hdl_left_to_pay_label' => 'Reste à payer', 'hdl_btn_create' => 'Créer un voyage', 'hdl_no_active_trips' => 'Aucun voyage en cours pour cette période. Planifions quelque chose !', 'hdl_history_title' => 'Historique', 'hdl_dates_to_define' => 'Dates à définir', 'hdl_total_budget' => 'Budget Total', 'hdl_paid' => 'Payé', 'hdl_saved' => 'Financé', 'hdl_left_to_pay' => 'Reste à payer', 'hdl_status_booked' => 'Confirmé', 'hdl_status_planned' => 'Planifié', 'hdl_status_draft' => 'Brouillon', 'hdl_status_passed' => 'Terminé', 'hdl_status_archived' => 'Archivé', 'hdl_modal_title' => 'Planifier le voyage', 'hdl_label_name' => 'Nom du voyage', 'hdl_ph_name' => 'Ex: Octobre - Portugal', 'hdl_label_status' => 'Statut', 'hdl_label_period' => 'Période (Texte libre)', 'hdl_ph_period' => 'Ex: Octobre 2026', 'hdl_label_from' => 'Du', 'hdl_label_to' => 'Au', 'hdl_cat_transport' => 'Transport', 'hdl_add_transport' => 'Ajouter un transport', 'hdl_cat_accommodation' => 'Hébergement', 'hdl_add_accommodation' => 'Ajouter un hébergement', 'hdl_cat_activity' => 'Activité', 'hdl_add_activity' => 'Ajouter une activité', 'hdl_label_budget_food' => 'Budget Food & Bev (€)', 'hdl_label_budget_extras' => 'Budget Extras (€)', 'hdl_label_notes' => 'Notes', 'hdl_ph_notes' => 'Idées en vrac...', 'hdl_label_budget_food_extras' => 'Budget Food & Extras', 'hdl_map_itinerary' => 'Itinéraire', 'hdl_outbound' => 'Aller', 'hdl_return' => 'Retour', 'hdl_btn_add_step' => 'Ajouter une étape', 'hdl_step_details' => 'Détails du roadtrip', 'hdl_no_steps' => 'Aucune étape définie. Commencez par en ajouter une !', 'hdl_general_costs' => 'Frais généraux & Budgets', 'hdl_food_bev' => 'Food & Bev', 'hdl_extras' => 'Extras', 'hdl_view_planning' => 'Voir le planning', 'hdl_passing_point' => 'Point de passage (sans frais)', 'hdl_search_location' => 'Chercher un lieu', 'hdl_ph_search' => 'Ville, hôtel, adresse...', 'hdl_label_step_name' => "Nom de l'étape", 'hdl_label_arrival' => 'Arrivée', 'hdl_label_departure' => 'Départ', 'hdl_planned_expenses' => 'Dépenses prévues pour cette étape', 'hdl_btn_add_expense' => '+ Ajouter une dépense', 'hdl_save_fav' => 'Enregistrer dans mes favoris', 'hdl_planning_title' => 'Planning de l’étape', 'hdl_to_pay' => 'À payer', 'hdl_js_search_loading' => 'Recherche en cours...', 'hdl_js_no_result' => 'Aucun résultat trouvé.', 'hdl_js_network_error' => 'Erreur de connexion au service de carte.', 'hdl_js_confirm_del_trip' => 'Es-tu sûr de vouloir supprimer ce voyage entier ?', 'hdl_js_confirm_del_step' => 'Supprimer cette étape ?', 'hdl_js_step_label' => 'Étape', 'hdl_js_ph_expense_name' => 'Ex: Hôtel, Péage...', 'hdl_quick_edit_title' => 'Modification rapide', 'hdl_js_edit_step' => 'Modifier l’étape', 'hdl_js_missing_dates_title'=> 'Dates manquantes', 'hdl_js_missing_dates_msg' => 'Veuillez définir une date d’arrivée et de départ pour organiser le planning.', 'hdl_to_place' => 'À placer', 'hdl_from' => 'Du', 'hdl_to' => 'Au', 'hdl_btn_add_step' => 'Ajouter une étape', 'hdl_quick_edit_title' => 'Modification rapide', 'hdl_paid' => 'Payé', 'weather_forecast' => 'Prévisions météo', 'weather_not_available' => 'Météo indisponible', 'temp_max' => 'Temp. Max', 'weather_loading' => 'Chargement météo...', 'weather_error' => 'Météo indisponible', 'weather_sunny' => 'Ensoleillé', 'weather_cloudy' => 'Nuageux', 'weather_rainy' => 'Pluvieux', 'weather_snowy' => 'Neigeux', 'weather_historical' => 'Basé sur l\'historique (estimation)', 'err_invalid_holiday_id' => 'ID de voyage invalide.', 'hdl_default_exp_name' => 'Dépense', 'hdl_select_beneficiary' => '-- Sélectionner --', // Anciennes clés Holidays non préfixées (conservées pour compatibilité si utilisées) 'outbound_trip' => 'Aller', 'return_trip' => 'Retour', 'period' => 'Période', 'budget_food' => 'Budget Food & Extras', 'total_cost' => 'Coût Total Estimé', 'paid' => 'Payé', 'provisioned' => 'Provisionné', 'left_to_pay' => 'Reste à payer', 'map_itinerary' => '🗺️ Itinéraire', 'place_step' => '📍 Placer une étape', 'step_details' => '📝 Détail des étapes', 'no_steps' => 'Aucune étape planifiée.', 'travel_notes' => '📝 Notes du voyage', 'to_be_defined' => 'À définir', 'funded' => 'Financé (Provision)', 'view_planning' => 'Voir le planning', 'to_pay' => 'À payer', 'from_date' => 'Du', 'to_date' => 'au', 'passing_point' => 'Point de passage (Aucune dépense)', 'search_location' => 'Rechercher un lieu géographique', 'search_placeholder' => 'Ex: Paris, Ibis Barcelone...', 'step_name' => 'Nom de l\'étape', 'arrival_optional' => 'Arrivée le (Optionnel)', 'departure_optional' => 'Départ le (Optionnel)', 'planned_expenses' => 'Dépenses prévues', 'add_expense' => '+ Ajouter une dépense', 'save_favorite' => 'Sauvegarder dans mes favoris', 'planning_title' => 'Planning', 'modal_plan_trip' => 'Planifier le voyage', 'modal_quick_edit' => 'Modification rapide', 'err_modal_missing' => 'Erreur : La modale holidayModal est introuvable.', 'placeholder_title' => 'Intitulé', 'placeholder_price' => 'Prix (€)', 'already_paid' => 'Déjà payé ?', 'remove_line' => 'Retirer cette ligne', 'confirm_delete_trip' => 'Voulez-vous vraiment supprimer définitivement ce voyage ? Cette action est irréversible.', 'step_number' => 'Étape', 'place_new_step' => '📍 Placer une nouvelle étape', 'edit_step' => '✏️ Modifier l\'étape', 'search_in_progress' => 'Recherche en cours... ⏳', 'no_result_found' => 'Aucun résultat trouvé.', 'network_error' => 'Erreur réseau.', 'placeholder_label' => 'Libellé', 'placeholder_notes_link' => '🔗 Lien de réservation ou notes (Optionnel)...', 'confirm_delete_step' => 'Supprimer cette étape et toutes les dépenses associées ?', 'planning_of' => '📅 Planning : ', 'missing_dates_title' => '⚠️ Dates manquantes', 'missing_dates_msg' => 'Veuillez modifier cette étape (✏️) et renseigner sa Date d\'arrivée et de départ.', 'to_place' => '📥 À Placer', // ========================================== // BUDGET (GLOBAL & CATEGORIES) // ========================================== 'budget_page_title' => 'PachaFamily - Budget', 'budget_main_header' => 'Gestion du Budget', 'budget_tab_tracking' => 'Suivi Mensuel', 'budget_tab_prev' => 'Budget 2026', 'budget_tab_savings' => 'Épargne', 'budget_tab_recap' => 'Récapitulatif', 'budget_err_file_not_found' => 'Oups ! Fichier introuvable.', 'budget_err_file_detail' => 'Vérifiez que le fichier views/%s.php existe bien.', 'budget_err_not_authorized' => 'Page non autorisée.', 'cat_income' => 'Revenus', 'cat_fmcg' => 'Courses (FMCG)', 'cat_fuel' => 'Essence', 'cat_school' => 'École / Garde', 'cat_fixed' => 'Charges Fixes', 'cat_others' => 'Autres / Imprévus', 'cat_savings' => 'Épargne', // --- BUDGET : SUIVI --- 'bud_rem_school' => 'École (Reste estimé)', 'bud_rem_fmcg' => 'Courses (Reste estimé)', 'bud_rem_fuel' => 'Essence (Reste estimé)', 'bud_closed_archived' => 'Ce mois est clôturé et archivé.', 'bud_budget_of' => 'Budget :', 'bud_confirm_close' => 'Clôturer ce mois ? Cela figera les soldes actuels et basculera le suivi par défaut sur le mois prochain.', 'bud_close_btn' => 'Clôturer', 'bud_confirm_reopen' => 'Rouvrir ce mois ? Ses soldes seront recalculés en direct.', 'bud_reopen_btn' => 'Rouvrir', 'bud_active_month_btn' => 'Mois Actif', 'bud_bank_balance' => 'Solde bancaire', 'bud_frozen' => 'Figé', 'bud_current' => 'Actuel', 'bud_update_btn' => 'MàJ', 'bud_theoretical_balance' => 'Solde théorique fin de mois', 'bud_upcoming_charges' => 'Charges à venir', 'bud_all_paid' => 'Tout est payé !', 'bud_capacity_tooltip' => 'Calcul : Report Mois Précédent (%s€) + Salaires (%s€) + Autres rentrées (%s€)', 'bud_max_capacity' => 'Capacité Max', 'bud_actual' => 'Actuel', 'bud_theoretical' => 'Théorique', 'bud_import_csv' => 'Importer CSV', 'bud_csv_file' => 'Fichier CSV (Point-virgule)', 'bud_preview' => 'Prévisualiser', 'bud_validate_import' => 'Valider l\'importation', 'bud_assign_month' => 'Imputer au mois', 'bud_op_date' => 'Date Opération', 'bud_label' => 'Libellé', 'bud_amount' => 'Montant', 'bud_category' => 'Catégorie', 'bud_ignore' => 'Ignorer', 'bud_to_define' => 'À définir', 'bud_is_charge' => 'Charge ?', 'bud_is_income' => 'Revenu ?', 'btn_import' => 'Importer', 'bud_no_lines' => 'Aucune ligne.', 'bud_confirm_delete' => 'Supprimer cette ligne ?', 'bud_new_transaction' => 'Nouvelle transaction', 'bud_assign_to_month' => 'Imputer au mois de gestion', 'bud_real_date' => 'Date réelle de l\'opération', 'bud_type' => 'Type', 'bud_type_expense' => 'Dépense', 'bud_type_income' => 'Revenu / Remboursement', 'bud_beneficiary' => 'Bénéficiaire', 'bud_fixed_charge' => 'Charge Fixe', 'bud_expected_income' => 'Revenu Attendu', 'bud_amount_eur' => 'Montant (€)', 'bud_update_balance' => 'MàJ Solde Bancaire', 'bud_balance_eur' => 'Solde (€)', 'bud_add_title' => 'Ajouter', 'bud_edit_title' => 'Modifier la transaction', 'bud_to_define_js' => 'à définir', 'bud_bar_actual' => "Actuel", 'bud_bar_theoretical' => "Théorique", 'bud_bar_max_cap' => "Capacité Max", 'bud_import_csv' => "Importer CSV", 'bud_csv_file' => "Sélectionnez le fichier de votre banque (.csv)", 'bud_preview' => "Prévisualiser", 'bud_validate_import' => "Veuillez vérifier et catégoriser les lignes avant validation.", 'bud_assign_month' => "Mois de gestion", 'bud_op_date' => "Date", 'bud_amount' => "Montant", 'bud_ignore' => "Ignorer (Crédit)", 'bud_to_define' => "À définir", 'bud_is_charge' => "Est une charge fixe...", 'bud_is_income' => "Est un revenu...", // --- BUDGET : RECAP --- 'bud_recap_monthly_title' => 'Récapitulatif Mensuel', 'bud_recap_btn_add' => 'Ajouter un frais / revenu', 'bud_planned_amount' => 'Montant Prévu', 'bud_day' => 'Jour', 'bud_month_state' => 'État du mois', 'bud_est_short' => 'Est.', 'bud_reg_planned_in' => 'Régul. prévue en %s', 'bud_bonus' => 'Bonus', 'bud_overrun' => 'Dépassement', 'bud_remaining' => 'Reste', 'bud_exact_amount' => 'Montant exact', 'bud_per_month_short' => 'Soit', 'bud_month_short' => 'mois', 'bud_freq_mensuel' => 'Mensuel', 'bud_freq_annuel' => 'Annuel', 'bud_state_validated' => 'Validé', 'bud_state_partial' => 'Partiel', 'bud_state_waiting' => 'En attente', 'bud_total_income_smoothed' => 'Total Revenus (Lissés)', 'bud_total_expenses_smoothed'=> 'Total Dépenses (Lissées)', 'bud_theoretical_balance_recap'=> 'Équilibre théorique', 'bud_recap_footer_note' => 'L\'état de paiement se met à jour automatiquement en fonction des opérations importées dans l\'onglet "Suivi Mensuel".', 'bud_recap_modal_add' => 'Ajouter un élément', 'bud_recap_modal_edit' => 'Modifier', 'bud_label_name' => 'Nom', 'bud_ph_item_name' => 'Ex: Loyer, Salaire...', 'bud_label_keywords' => 'Mots-clés (Détection Auto)', 'bud_ph_keywords' => 'Ex: NETFLIX, PRIME VIDEO (séparés par virgule)', 'bud_help_keywords' => 'Si une dépense du mois contient ce mot, la ligne passera en "Validé".', 'bud_label_day' => 'Jour (1-31)', 'bud_cat_expense' => 'Dépense (Frais)', 'bud_label_frequency' => 'Fréquence', 'bud_label_amount_type' => 'Type de montant', 'bud_type_fixed' => 'Fixe (Facture)', 'bud_type_variable' => 'Variable (Estimation)', 'bud_label_regularization' => 'Régularisation', 'bud_reg_none' => 'Aucune', 'bud_recap_confirm_delete' => 'Voulez-vous vraiment supprimer cet élément ?', 'bud_err_delete' => 'Erreur lors de la suppression.', // --- BUDGET : EPARGNE --- 'bud_sav_add_month' => 'Saisir un mois', 'bud_sav_add_one_month' => '+1 Mois', 'bud_sav_no_data' => 'Aucune donnée pour %s.', 'bud_sav_post_month' => 'Poste / Mois', 'bud_sav_from_date' => 'Dès le %s', 'bud_sav_edit_modal' => 'Modifier avec Modale', 'bud_sav_delete_month' => 'Supprimer tout le mois', 'bud_sav_total_bank' => 'Total Banque', 'bud_sav_extra' => 'Extra', 'bud_sav_modal_title_add' => 'Saisir un mois', 'bud_sav_modal_title_edit' => 'Modifier :', 'bud_sav_month_concerned' => 'Mois concerné', 'bud_sav_total_bank_eur' => 'Total en Banque (€)', 'bud_sav_ventilation' => 'Ventilation', 'bud_sav_adj_help' => 'Utilisez l\'ajustement (+/-) pour recalculer automatiquement.', 'bud_sav_add_line' => 'Ligne', 'bud_sav_current' => 'Actuel', 'bud_sav_adjust' => 'Ajust (+/-)', 'bud_sav_new' => 'Nouveau', 'bud_sav_sum_mode_title' => 'Activer l\'addition rapide', 'bud_sav_selection' => 'Sélection :', 'bud_sav_ph_name' => 'Nom (ex: Vacances)', 'bud_sav_saving' => 'Enregistrement...', 'bud_err_tech' => 'Une erreur technique est survenue.', 'bud_sav_confirm_delete_month' => 'Supprimer TOUT le mois de %m pour %o ?', 'bud_sav_prompt_duplicate' => "Voulez-vous copier les données de %s vers %t1 ?\n\nSaisissez le nouveau TOTAL en banque (€) pour %t2 :", 'bud_err_server' => 'Erreur serveur : ', 'bud_err_network_dup' => 'Erreur réseau lors de la duplication.', // --- BUDGET : PREVISIONNEL --- 'bud_prev_incomes' => 'Revenus', 'bud_prev_person' => 'Personne', 'bud_prev_salary' => 'Salaire', 'bud_prev_monthly_payment' => 'Mensualité', 'bud_prev_joint_account' => 'Cpt Commun', 'bud_prev_func_expenses' => 'Frais Func.', 'bud_prev_perso_savings' => 'Eco Perso', 'bud_prev_family_savings' => 'Eco Family', 'bud_prev_available' => 'Dispo', 'bud_prev_perso_remaining' => 'Restant Perso', 'bud_prev_budget_alloc' => 'Répartition Budgétaire', 'bud_prev_today' => 'Auj.', 'bud_prev_new_line' => 'Nouvelle Ligne', 'bud_prev_remaining' => 'Restant', 'bud_prev_confirm_del_line' => 'Supprimer cette ligne et tout son historique ?', 'bud_prev_notes_for' => 'Notes pour', 'bud_prev_saved' => 'Enregistré', 'bud_prev_notes_ph' => 'Écrivez vos remarques, rappels ou commentaires pour ce mois ici...', 'bud_prev_save_note' => 'Enregistrer la note', 'bud_prev_transfers_to_make' => 'Virements à effectuer', 'bud_prev_destination' => 'Destination', 'bud_prev_done' => 'FAIT', 'bud_prev_validate' => 'Valider', 'bud_prev_grand_total' => 'GRAND TOTAL', 'bud_prev_new_line_title' => 'Nouvelle Ligne de Budget', 'bud_prev_edit_line_title' => 'Modifier la ligne', 'bud_prev_label_target' => 'Cible (Destination)', 'bud_prev_choose' => 'Choisir', 'bud_prev_link_holiday' => 'Lier à un voyage (Optionnel)', 'bud_prev_no_link' => 'Ne pas associer', 'bud_prev_err_no_history' => 'Impossible : pas d\'historique disponible pour copier.', 'bud_prev_confirm_copy' => "Voulez-vous copier les données de %s vers %t ?\n\n⚠️ Cela écrasera toutes les valeurs déjà présentes pour %t.", 'bud_prev_confirm_transfers' => "Confirmer que %p a bien effectué tous ses virements pour %m ?\n\nCela mettra à jour l'Épargne automatiquement.", 'bud_sav_modal_title_add' => 'Saisir un mois', 'bud_sav_ph_name' => 'Nom (ex: Vacances)', 'bud_prev_label_name' => 'Nom', // ========================================== // CADEAUX (GIFTS) // ========================================== 'gift_page_title' => 'PachaFamily - Liste de cadeaux', 'gift_occ_tio' => 'Tió', 'gift_occ_noel' => 'Noël', 'gift_occ_rois' => 'Rois', 'gift_occ_anniv' => 'Anniversaire', 'gift_occ_sant' => 'Saint', 'gift_main_title' => 'Liste de cadeaux %s', 'gift_aria_change_view' => 'Changer la vue', 'gift_view_nadal' => 'Noël', 'gift_view_anniv' => 'Anniversaires', 'gift_view_by_party' => 'Vue par fête', 'gift_no_gifts' => 'Aucun cadeau enregistré pour %s dans cette vue.', 'gift_add_gift' => 'Ajouter un cadeau', 'gift_paid_by' => 'payé par %s', 'gift_summary_title' => 'Résumé du budget', 'gift_col_adult' => 'Adulte', 'gift_col_child' => 'Enfant', 'gift_col_party' => 'Fête', 'gift_debtor' => 'Débiteur', 'gift_creditor' => 'Créancier', 'gift_liquidations' => 'Liquidations', 'gift_no_debt' => 'Aucune dette en cours.', 'gift_owes' => 'doit', 'gift_to' => 'à', 'gift_detailed_list' => 'Liste détaillée des cadeaux', 'gift_col_gift' => 'Cadeau', 'gift_col_link' => 'Lien', 'gift_modal_title_add' => 'Ajouter un cadeau pour %s', 'gift_modal_title_edit' => 'Modifier le cadeau', 'gift_modal_payer' => 'Payé par', 'gift_modal_gift_name' => 'Nom du cadeau', 'gift_modal_ph_name' => 'ex: Lego Star Wars', 'gift_modal_price' => 'Prix (€)', 'gift_modal_link' => 'Lien (optionnel)', 'gift_confirm_delete' => 'Voulez-vous vraiment supprimer ce cadeau ?', ]; ``` --- ### 📄 Fichier : `index.php` ```php

``` --- ### 📄 Fichier : `login.php` ```php prepare("SELECT id, username, password_hash, display_name FROM pf_users WHERE username = ?"); $stmt->execute([$username]); $user = $stmt->fetch(PDO::FETCH_ASSOC); if ($user && password_verify($password, $user['password_hash'])) { $_SESSION['user'] = [ 'id' => (int)$user['id'], 'username' => $user['username'], 'display_name' => $user['display_name'], ]; $redirectTo = $_GET['redirect'] ?? '/index.php'; header('Location: ' . $redirectTo); exit; } else { $error = tr('error_invalid_credentials'); } } } require __DIR__ . '/header.php'; ?>

``` --- ### 📄 Fichier : `logout.php` ```php div[style*="justify-content: space-between"] { display: flex !important; flex-direction: row !important; justify-content: space-between !important; align-items: center !important; float: none !important; position: relative !important; width: 100% !important; gap: 15px !important; } .prev-salary-table { width: 100%; max-width: 900px; background: var(--bg-panel); border-radius: var(--radius); box-shadow: var(--shadow-sm); border-collapse: separate; border-spacing: 0; overflow: hidden; border: 1px solid #e2e8f0; } .prev-salary-table th { background: #f8fafc; color: var(--text-muted); font-weight: 600; text-transform: uppercase; font-size: 0.8rem; padding: 12px; text-align: center; border-bottom: 1px solid #e2e8f0; } .prev-salary-table th:first-child { text-align: left; } .prev-salary-table td { padding: 8px 12px; border-bottom: 1px solid #f1f5f9; text-align: center; } .prev-timeline-wrapper { overflow-x: auto; border: 1px solid #e2e8f0; border-radius: var(--radius); background: var(--bg-panel); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); margin-top: 15px; } .prev-alloc-table { width: 100%; border-collapse: separate; border-spacing: 0; min-width: 100%; } .prev-alloc-table th { padding: 6px 10px; border-right: 1px solid #f1f5f9; white-space: nowrap; text-align: center; font-size: 0.9rem; } .prev-alloc-table td { padding: 3px 10px; border-bottom: 1px solid #f1f5f9; border-right: 1px solid #f1f5f9; white-space: nowrap; text-align: center; font-size: 0.9rem; } .col-sticky { position: sticky !important; left: 0 !important; z-index: 10; background-color: var(--bg-panel); border-right: 2px solid #e2e8f0; text-align: left !important; min-width: 220px; font-weight: 600; color: var(--text-main); } .col-sticky.header-cell { top: 0; z-index: 30 !important; background-color: #f8fafc; color: var(--text-muted); } .col-sticky small { display: block; font-weight: normal; color: var(--text-muted); font-style: italic; font-size: 0.75rem; } .th-month { background: #1e293b !important; color: white !important; font-size: 0.85rem !important; border-left: 2px solid white; padding: 8px !important; } .th-month.current { background: var(--primary) !important; } .th-sub { font-size: 0.7rem !important; text-transform: uppercase; background: #f8fafc; color: var(--text-muted); } .row-total td { background: #f8fafc; font-weight: 700; color: #334155; border-top: 2px solid #e2e8f0; } .row-restant td { background: #eff6ff; font-weight: 700; border-bottom: 2px solid #bfdbfe; } .txt-alex { color: #0891b2; } .txt-laia { color: #f59e0b; } .txt-global { color: var(--text-muted); font-weight: 700; background: #f8fafc; } .val-ok { color: var(--success); } .val-ko { color: var(--danger); } .nav-group { display: flex; gap: 5px; } .btn-nav { background: white; border: 1px solid #cbd5e1; padding: 4px 10px; border-radius: 6px; text-decoration: none; color: var(--text-muted); font-size: 0.85rem; font-weight: 500; transition: 0.2s; } .btn-nav:hover { background: #f1f5f9; color: var(--text-main); border-color: #94a3b8; } .row-actions { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); z-index: 50 !important; opacity: 0; pointer-events: none; display: flex; gap: 8px; transition: opacity 0.2s ease-in-out; } tr:hover .row-actions { opacity: 1; pointer-events: auto !important; } .btn-icon-action { border: none; background: transparent; cursor: pointer; font-size: 1.1rem; line-height: 1; padding: 4px; border-radius: 4px; transition: all 0.2s; text-decoration: none; display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; } .btn-icon-action.edit { color: #3b82f6; } .btn-icon-action.edit:hover { background: #eff6ff; } .btn-icon-action.delete { color: #ef4444; } .btn-icon-action.delete:hover { background: #fef2f2; } /* --- 8. RÉCAPITULATIF VIREMENTS --- */ .recap-wrapper { margin-top: 30px; padding-top: 20px; border-top: 1px solid #e2e8f0; display: flex; justify-content: center; } .recap-card { background: white; border: 1px solid #e2e8f0; border-radius: 12px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); overflow: hidden; width: 100%; max-width: 600px; } .recap-header { background: #1e293b; color: white; padding: 12px 20px; font-size: 1rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; text-align: center; } .recap-table { width: 100%; border-collapse: collapse; } .recap-table th, .recap-table td { padding: 10px 15px; border-bottom: 1px solid #f1f5f9; text-align: center; font-size: 0.95rem; } .recap-table th { font-weight: 700; text-transform: uppercase; font-size: 0.8rem; color: #64748b; background: #f8fafc; border-bottom: 2px solid #e2e8f0; } .recap-table td:first-child { text-align: left; font-weight: 600; color: #1e293b; } .col-alex { background-color: #ecfeff; color: #0891b2; border-left: 1px solid #cffafe; } th.col-alex { background-color: #cffafe; border-bottom-color: #a5f3fc; } .col-laia { background-color: #fffbeb; color: #d97706; border-left: 1px solid #fef3c7; } th.col-laia { background-color: #fef3c7; border-bottom-color: #fde68a; } .col-global { background-color: #f8fafc; color: #475569; font-weight: 700; border-left: 2px solid #e2e8f0; } .row-grand-total td { background-color: #1e293b; color: white; font-weight: 800; border: none; padding: 12px 15px; } .row-grand-total .col-alex { background: #164e63; color: #67e8f9; border: none; } .row-grand-total .col-laia { background: #78350f; color: #fcd34d; border: none; } .row-grand-total .col-global { background: #334155; color: white; border: none; } /* --- 9. STYLES SPÉCIFIQUES AU SUIVI MENSUEL --- */ .cat-card { background: var(--bg-panel); border-radius: var(--radius); border: 1px solid #e2e8f0; display: flex; flex-direction: column; overflow: hidden; position: relative; } .btn-add-item { background: rgba(255, 255, 255, 0.6); color: inherit; border: 1px solid rgba(0, 0, 0, 0.1); width: 28px; height: 28px; border-radius: 50%; font-size: 18px; line-height: 1; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); } .btn-add-item:hover { background: var(--bg-panel); transform: rotate(90deg) scale(1.1); box-shadow: var(--shadow-sm); border-color: currentColor; } .progress-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-top: 20px; } .fade-pulse { animation: pulseText 2s infinite; } @keyframes pulseText { 0% { opacity: 0.8; } 50% { opacity: 1; } 100% { opacity: 0.8; } } .suivi-nav-group { display: flex; background: #e2e8f0; border-radius: 6px; overflow: hidden; } .suivi-btn-nav { padding: 6px 12px; color: var(--text-muted); text-decoration: none; font-size: 0.85rem; font-weight: bold; border-right: 1px solid #cbd5e1; transition: background 0.2s, color 0.2s; } .suivi-btn-nav:last-child { border-right: none; } .suivi-btn-nav:hover { background: var(--bg-panel); color: var(--primary); } input[type="month"].pf-input { font-family: inherit; color: var(--text-main); background: white; border: 1px solid #cbd5e1; border-radius: 6px; } /* --- 10. EPARGNE --- */ .epargne-inline-input { width: 100%; min-width: 60px; max-width: 85px; border: 1px solid transparent; background: transparent; text-align: center; padding: 2px 4px; border-radius: 4px; transition: 0.2s; font-size: 0.85rem; margin: 0 auto; } .epargne-inline-input:hover { border-color: #cbd5e1; background: #f8fafc; } .epargne-inline-input:focus { border-color: var(--primary); background: #fff; outline: none; box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1); } /* --- 11. CALCULATRICE FLOTTANTE --- */ .pf-fab-sum { position: fixed; right: 30px; bottom: 30px; width: 56px; height: 56px; border-radius: 50%; background-color: #2563eb; color: white; font-size: 1.5rem; display: flex; align-items: center; justify-content: center; border: none; box-shadow: 0 4px 15px rgba(37, 99, 235, 0.4); cursor: pointer; z-index: 9000; transition: all 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275); } .pf-fab-sum:hover { transform: scale(1.1); background-color: #1d4ed8; } .pf-fab-sum.active { background-color: #10b981; transform: scale(1.1); box-shadow: 0 4px 20px rgba(16, 185, 129, 0.5); } .pf-sum-bar { position: fixed; bottom: 30px; left: 50%; transform: translateX(-50%) translateY(100px); opacity: 0; background: white; padding: 12px 24px; border-radius: 30px; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15); display: flex; align-items: center; gap: 15px; z-index: 8999; border: 1px solid #e2e8f0; transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); font-family: inherit; } .pf-sum-bar.visible { transform: translateX(-50%) translateY(0); opacity: 1; } .pf-sum-label { font-size: 0.9rem; color: #64748b; font-weight: 600; } .pf-sum-value { font-size: 1.2rem; color: #0f172a; font-weight: bold; } .pf-sum-close { background: none; border: none; color: #94a3b8; font-size: 1.5rem; cursor: pointer; margin-left: 10px; padding: 0; line-height: 1; transition: color 0.2s; } .pf-sum-close:hover { color: #ef4444; } body.sum-mode-active { cursor: cell !important; } body.sum-mode-active input, body.sum-mode-active .sum-target { cursor: cell !important; } .sum-selected { outline: 2px dashed #10b981 !important; outline-offset: -2px; background-color: #ecfdf5 !important; color: #065f46 !important; transition: background-color 0.2s; } /* --- 12. RESPONSIVE MOBILE BUDGET --- */ @media (max-width: 768px) { .budget-view { padding: 10px !important; max-width: 100vw !important; box-sizing: border-box; } .budget-tabs-container { display: flex !important; flex-wrap: nowrap !important; overflow-x: auto !important; justify-content: flex-start !important; -webkit-overflow-scrolling: touch; width: 100%; padding-bottom: 5px; scrollbar-width: none; } .budget-tabs-container::-webkit-scrollbar { display: none; } .tab-item { flex: 0 0 auto !important; padding: 8px 16px !important; } .budget-view > div[style*="display:grid"] { gap: 10px !important; margin-bottom: 15px !important; } .budget-view > div[style*="display:grid"] > div { padding: 12px 10px !important; } .budget-view > div[style*="justify-content: space-between"]:not(.view-header) { flex-direction: column !important; align-items: stretch !important; } .budget-view > div[style*="justify-content: space-between"]:not(.view-header) { flex-direction: column !important; align-items: stretch !important; gap: 12px !important; margin-top: 20px !important; } .budget-view > div[style*="justify-content: space-between"]:not(.view-header) > div:first-child { width: 100% !important; } .budget-view > div[style*="justify-content: space-between"]:not(.view-header) > div:last-child { display: flex !important; flex-direction: row !important; flex-wrap: nowrap !important; width: 100% !important; gap: 10px !important; } .budget-view > div[style*="justify-content: space-between"]:not(.view-header) > div:last-child .pf-btn { flex: 1 1 50% !important; padding: 0 5px !important; font-size: 0.8rem !important; justify-content: center; } .prev-section-header { flex-direction: column !important; align-items: stretch !important; gap: 12px !important; } .prev-section-header > div { display: flex !important; flex-direction: row !important; flex-wrap: wrap !important; width: 100% !important; gap: 10px !important; } .prev-section-header .nav-group { display: flex !important; width: 100% !important; justify-content: space-between !important; } .prev-section-header .nav-group .btn-nav { flex: 1; text-align: center; } .prev-section-header .pf-btn { flex: 1 1 calc(50% - 10px) !important; width: auto !important; padding: 0 5px !important; margin: 0 !important; font-size: 0.85rem !important; } .prev-salary-table, .prev-alloc-table, .pf-table, .savings-table, .recap-wrapper { display: block !important; width: 100% !important; overflow-x: auto !important; -webkit-overflow-scrolling: touch; white-space: nowrap; border-radius: 8px !important; margin-bottom: 15px; } .recap-card { min-width: 100% !important; width: 100% !important; } .recap-table th, .recap-table td { padding: 8px 4px !important; font-size: 0.8rem !important; } .recap-header { font-size: 0.9rem !important; padding: 8px 10px !important; } .row-grand-total td { padding: 10px 4px !important; } .row-actions { position: static !important; display: inline-flex !important; opacity: 1 !important; box-shadow: none !important; background: transparent !important; border-left: none !important; padding: 1px 0 !important; transform: none !important; justify-content: flex-start; width: auto !important; } .btn-icon-action { font-size: 1.1rem !important; padding: 4px !important; width: 20px !important; height: 20px !important; background: #f1f5f9 !important; border-radius: 6px; margin-right: 2px; } .col-sticky, .sticky-col { min-width: 120px !important; position: sticky !important; left: 0; z-index: 20 !important; white-space: normal !important; background: white !important; } th.sticky-col, th.col-sticky { z-index: 30 !important; background: #f8fafc !important; } .action-toolbar { flex-wrap: wrap !important; width: 100% !important; } .suivi-nav-group { width: 100%; justify-content: space-between; } .suivi-btn-nav { flex: 1; text-align: center; } .month-actions { opacity: 1 !important; } .btn-cell-delete { display: flex !important; opacity: 0.7; position: static; transform: none; right: auto; top: auto; } } ``` --- ### 📄 Fichier : `modules/budget/includes/api/get-fixed-costs.php` ```php ``` --- ### 📄 Fichier : `modules/budget/includes/api/manage-item.php` ```php prepare("UPDATE pf_budget_items SET name=?, amount=?, category=?, type=?, payment_day=?, is_estimate=?, reg_month=?, mapping_keywords=?, holiday_id=? WHERE id=?"); $stmt->execute([$name, $amount, $category, $type, $payment_day, $is_estimate, $reg_month, $keywords, $holiday_id, $id]); } else { // INSERT $stmt = $pdo->prepare("INSERT INTO pf_budget_items (name, amount, category, type, payment_day, is_estimate, reg_month, mapping_keywords, holiday_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"); $stmt->execute([$name, $amount, $category, $type, $payment_day, $is_estimate, $reg_month, $keywords, $holiday_id]); } // Redirection header('Location: /budget.php?tab=recap'); exit; } // --- ACTION : COCHER/DÉCOCHER RAPIDE (VIA JS FETCH) --- if ($action === 'toggle-check') { $id = $_POST['id']; $status = $_POST['status']; $sql = "UPDATE pf_budget_items SET is_checked = ? WHERE id = ?"; $stmt = $pdo->prepare($sql); $stmt->execute([$status, $id]); header('Content-Type: application/json'); echo json_encode(['success' => true]); exit; } // --- ACTION : SUPPRIMER --- if ($action === 'delete') { $id = $_POST['id']; $pdo->prepare("DELETE FROM pf_budget_items WHERE id = ?")->execute([$id]); if (isset($_POST['ajax'])) { header('Content-Type: application/json'); echo json_encode(['success' => true]); } else { header('Location: /budget.php?tab=recap'); } exit; } // --- ACTION : SUPPRIMER UNE DÉPENSE RÉELLE (Depuis l'onglet Suivi) --- if ($action === 'delete_expense') { $id = (int)$_POST['id']; $pdo->prepare("DELETE FROM pf_expenses WHERE id = ?")->execute([$id]); header('Content-Type: application/json'); echo json_encode(['success' => true]); exit; } // --- ACTION : SAUVEGARDER UNE DÉPENSE MANUELLE (FETCH) --- if ($action === 'save_expense_manual') { header('Content-Type: application/json'); // On force le retour JSON pour notre script JS try { $id = !empty($_POST['expense_id']) ? (int)$_POST['expense_id'] : null; $cat = $_POST['category'] ?? ''; $amount = floatval($_POST['amount'] ?? 0); $date = $_POST['date'] ?? date('Y-m-d'); // 🛡️ SÉCURITÉ : Format attendu pour gestion_month (qui vient de input type="month") : YYYY-MM. $gestionMonthRaw = $_POST['gestion_month'] ?? ''; // Si la valeur est vide ou '0000-00-00' if (empty($gestionMonthRaw) || strpos($gestionMonthRaw, '0000') !== false) { // On force le mois de gestion au 1er jour de la date de la dépense $gestionMonth = date('Y-m-01', strtotime($date)); } else { // Si la valeur vient d'un input "month" (ex: "2026-04"), on rajoute "-01" $gestionMonth = (strlen($gestionMonthRaw) === 7) ? $gestionMonthRaw . '-01' : $gestionMonthRaw; } $label = trim($_POST['label'] ?? ''); $budgetItemId = null; $holidayId = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null; if ($cat === 'School' && !empty($_POST['label_select'])) { $label = trim($_POST['label_select']); } elseif (($cat === 'Frais' || $cat === 'Income') && !empty($_POST['budget_item_id'])) { $budgetItemId = (int)$_POST['budget_item_id']; } // Vérification de sécurité if (empty($label) || $amount <= 0) { echo json_encode(['success' => false, 'error' => 'Le label et un montant supérieur à 0 sont obligatoires.']); exit; } $is_credit = isset($_POST['is_credit']) ? (int)$_POST['is_credit'] : 0; $finalAmount = $is_credit ? abs($amount) : -abs($amount); if ($id) { // UPDATE $pdo->prepare("UPDATE pf_expenses SET date_exp=?, gestion_month=?, category=?, label=?, amount=?, budget_item_id=?, holiday_id=? WHERE id=?") ->execute([$date, $gestionMonth, $cat, $label, $finalAmount, $budgetItemId, $holidayId, $id]); } else { // INSERT $uniqueRef = "MANUAL_" . uniqid(); $pdo->prepare("INSERT INTO pf_expenses (date_exp, gestion_month, category, label, amount, import_ref, budget_item_id, holiday_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)") ->execute([$date, $gestionMonth, $cat, $label, $finalAmount, $uniqueRef, $budgetItemId, $holidayId]); } echo json_encode(['success' => true]); exit; } catch (PDOException $e) { echo json_encode(['success' => false, 'error' => 'Erreur base de données.']); exit; } } } ``` --- ### 📄 Fichier : `modules/budget/includes/api/save-budget.php` ```php prepare("INSERT INTO pf_notes (note_type, reference_id, content) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE content = VALUES(content)"); $stmt->execute([$noteType, $refId, $content]); echo json_encode(['success' => true]); } catch (\Throwable $e) { echo json_encode(['success' => false, 'error' => 'Erreur SQL: ' . $e->getMessage()]); } exit; } // ================================================================= // 1. MISE A JOUR TABLEAU SALAIRES (AJAX) if ($action === 'update_salary_config') { header('Content-Type: application/json'); // On précise JSON ici $year = $_POST['year']; $person = $_POST['person']; $field = $_POST['field']; // salary, mensualite, etc. $value = floatval($_POST['value']); // Liste des champs autorisés pour éviter les injections $allowed = ['salary', 'mensualite', 'frais_func', 'eco_perso', 'eco_family']; if (!in_array($field, $allowed)) { echo json_encode(['error'=>'Champ invalide']); exit; } $stmt = $pdo->prepare("INSERT INTO pf_salary_config (year, person, $field) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE $field = VALUES($field)"); $stmt->execute([$year, $person, $value]); echo json_encode(['success' => true]); exit; } // 2. MISE A JOUR TABLEAU REPARTITION (AJAX) if ($action === 'update_allocation') { header('Content-Type: application/json'); $date = $_POST['month_date']; $catId = $_POST['cat_id']; $person = $_POST['person']; // 'amount_alex' ou 'amount_laia' $value = floatval($_POST['value']); $stmt = $pdo->prepare("INSERT INTO pf_alloc_values (month_date, cat_id, $person) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE $person = VALUES($person)"); $stmt->execute([$date, $catId, $value]); echo json_encode(['success' => true]); exit; } // 3. GESTION DES CATEGORIES (Ajout) if ($action === 'add_category') { $name = trim($_POST['name']); $target = trim($_POST['target']); $holiday_id = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null; if (!empty($name)) { $stmt = $pdo->prepare("INSERT INTO pf_alloc_categories (name, target, holiday_id) VALUES (?, ?, ?)"); $stmt->execute([$name, $target, $holiday_id]); } // --- NOUVEAU : Réponse AJAX --- if (isset($_POST['ajax'])) { header('Content-Type: application/json'); echo json_encode(['success' => true]); exit; } header("Location: " . $_SERVER['HTTP_REFERER']); exit; } // 4. MODIFICATION D'UNE CATEGORIE if ($action === 'update_category') { $id = (int)$_POST['cat_id']; $name = trim($_POST['name']); $target = trim($_POST['target']); $holiday_id = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null; if ($id > 0 && !empty($name)) { $stmt = $pdo->prepare("UPDATE pf_alloc_categories SET name = ?, target = ?, holiday_id = ? WHERE id = ?"); $stmt->execute([$name, $target, $holiday_id, $id]); } // --- NOUVEAU : Réponse AJAX --- if (isset($_POST['ajax'])) { header('Content-Type: application/json'); echo json_encode(['success' => true]); exit; } header("Location: " . $_SERVER['HTTP_REFERER']); exit; } // 5. SUPPRESSION CATEGORIE if ($action === 'delete_category') { $id = (int)($_POST['id'] ?? $_GET['id'] ?? 0); if ($id > 0) { $pdo->prepare("DELETE FROM pf_alloc_categories WHERE id = ?")->execute([$id]); $pdo->prepare("DELETE FROM pf_alloc_values WHERE cat_id = ?")->execute([$id]); } if (isset($_POST['ajax']) || isset($_GET['ajax'])) { header('Content-Type: application/json'); echo json_encode(['success' => true]); exit; } header("Location: " . ($_SERVER['HTTP_REFERER'] ?? '/budget.php')); exit; } // 6. VALIDATION DES VIREMENTS (Complex Business Logic) if ($action === 'validate_transfers') { header('Content-Type: application/json'); $person = $_POST['person']; $monthDate = $_POST['month_date']; try { $pdo->beginTransaction(); $stmt = $pdo->prepare(" SELECT v.*, c.name as cat_name, c.target, c.holiday_id FROM pf_alloc_values v JOIN pf_alloc_categories c ON v.cat_id = c.id WHERE v.month_date = ? "); $stmt->execute([$monthDate]); $budgetLines = $stmt->fetchAll(PDO::FETCH_ASSOC); $transfersToDo = []; foreach ($budgetLines as $line) { $amount = ($person === 'Alex') ? $line['amount_alex'] : $line['amount_laia']; if ($amount <= 0) continue; $target = trim($line['target']); $catName = trim($line['cat_name']); $holidayId = $line['holiday_id']; $targetOwner = null; if ($target === 'vers L.Perso') { $targetOwner = $person; } elseif ($target === 'vers L.Pol') { $targetOwner = 'Pol'; } elseif ($target === 'vers L.Pep') { $targetOwner = 'Pep'; } elseif ($target === 'vers commune') { continue; } if ($targetOwner) { if (!isset($transfersToDo[$targetOwner])) { $transfersToDo[$targetOwner] = ['total_add' => 0, 'cats' => []]; } $transfersToDo[$targetOwner]['total_add'] += $amount; if (!isset($transfersToDo[$targetOwner]['cats'][$catName])) { $transfersToDo[$targetOwner]['cats'][$catName] = ['amount' => 0, 'holiday_id' => $holidayId]; } $transfersToDo[$targetOwner]['cats'][$catName]['amount'] += $amount; } } foreach ($transfersToDo as $owner => $data) { // A. VERIFIER EXISTENCE (Inchangé) $stmtCheck = $pdo->prepare("SELECT COUNT(*) FROM pf_savings WHERE owner = ? AND month_date = ? AND category = 'TOTAL_BANQUE'"); $stmtCheck->execute([$owner, $monthDate]); $exists = $stmtCheck->fetchColumn() > 0; if (!$exists) { $prevDate = date('Y-m-d', strtotime($monthDate . ' -1 month')); $stmtPrev = $pdo->prepare("SELECT category, amount, holiday_id FROM pf_savings WHERE owner = ? AND month_date = ?"); $stmtPrev->execute([$owner, $prevDate]); $prevLines = $stmtPrev->fetchAll(PDO::FETCH_ASSOC); if (empty($prevLines)) { $prevLines = [['category' => 'TOTAL_BANQUE', 'amount' => 0, 'holiday_id' => null]]; } $stmtInsert = $pdo->prepare("INSERT INTO pf_savings (month_date, owner, category, amount, holiday_id) VALUES (?, ?, ?, ?, ?)"); foreach ($prevLines as $row) { $stmtInsert->execute([$monthDate, $owner, $row['category'], $row['amount'], $row['holiday_id']]); } } // B. UPDATE TOTAL (Inchangé) $stmtUpdTotal = $pdo->prepare("UPDATE pf_savings SET amount = amount + ? WHERE owner = ? AND month_date = ? AND category = 'TOTAL_BANQUE'"); $stmtUpdTotal->execute([$data['total_add'], $owner, $monthDate]); // C. UPDATE CATÉGORIES (Modifié pour gérer le holiday_id) foreach ($data['cats'] as $catName => $catInfo) { $catAmount = $catInfo['amount']; $catHolidayId = $catInfo['holiday_id']; // NOUVEAU if ($catName === 'Eco Alex' || $catName === 'Eco Laia') { continue; } $stmtCheckCat = $pdo->prepare("SELECT id FROM pf_savings WHERE owner = ? AND month_date = ? AND category = ?"); $stmtCheckCat->execute([$owner, $monthDate, $catName]); $catId = $stmtCheckCat->fetchColumn(); if ($catId) { // Update : On actualise aussi le holiday_id au cas où il aurait changé $stmtUpdateCat = $pdo->prepare("UPDATE pf_savings SET amount = amount + ?, holiday_id = ? WHERE id = ?"); $stmtUpdateCat->execute([$catAmount, $catHolidayId, $catId]); } else { // Insert $stmtInsertCat = $pdo->prepare("INSERT INTO pf_savings (month_date, owner, category, amount, holiday_id) VALUES (?, ?, ?, ?, ?)"); $stmtInsertCat->execute([$monthDate, $owner, $catName, $catAmount, $catHolidayId]); } } } // 3. ENREGISTRER LA VALIDATION (Mise à jour table existante) // a. Trouver l'ID de la catégorie système $stmtSys = $pdo->prepare("SELECT id FROM pf_alloc_categories WHERE name = 'SYSTEM_VALIDATION' LIMIT 1"); $stmtSys->execute(); $sysCatId = $stmtSys->fetchColumn(); if ($sysCatId) { if ($person === 'Alex') { $sql = "INSERT INTO pf_alloc_values (month_date, cat_id, amount_alex, amount_laia) VALUES (?, ?, 1, 0) ON DUPLICATE KEY UPDATE amount_alex = 1"; } else { $sql = "INSERT INTO pf_alloc_values (month_date, cat_id, amount_alex, amount_laia) VALUES (?, ?, 0, 1) ON DUPLICATE KEY UPDATE amount_laia = 1"; } $stmtVal = $pdo->prepare($sql); $stmtVal->execute([$monthDate, $sysCatId]); } $pdo->commit(); echo json_encode(['success' => true]); } catch (Exception $e) { $pdo->rollBack(); echo json_encode(['success' => false, 'error' => $e->getMessage()]); } exit; } ``` --- ### 📄 Fichier : `modules/budget/includes/api/save-savings.php` ```php prepare("DELETE FROM pf_savings WHERE month_date=? AND owner=? AND category=?"); $stmt->execute([$month, $owner, $cat]); } else { // Sinon on insère ou on met à jour $stmt = $pdo->prepare("INSERT INTO pf_savings (month_date, owner, category, amount) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE amount = VALUES(amount)"); $stmt->execute([$month, $owner, $cat, $amount]); } echo json_encode(['success' => true]); } catch (Exception $e) { echo json_encode(['success' => false, 'error' => $e->getMessage()]); } exit; } // --- ACTION : SUPPRESSION D'UNE ENTRÉE UNIQUE --- if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delete_entry') { $owner = $_POST['owner']; $date = $_POST['month_date']; $cat = $_POST['category']; $stmt = $pdo->prepare("DELETE FROM pf_savings WHERE owner = ? AND month_date = ? AND category = ?"); $stmt->execute([$owner, $date, $cat]); echo json_encode(['success' => true]); exit; } // --- ACTION : DUPLICATION DE MOIS --- if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'duplicate_month') { $owner = $_POST['owner']; $sourceDate = $_POST['source_date']; $targetDate = $_POST['target_date']; $newTotal = floatval($_POST['new_total']); try { $pdo->beginTransaction(); // 1. Vérifier si le mois cible existe déjà $check = $pdo->prepare("SELECT id FROM pf_savings WHERE owner = ? AND month_date = ? LIMIT 1"); $check->execute([$owner, $targetDate]); if ($check->fetch()) { echo json_encode(['success' => false, 'error' => 'Ce mois existe déjà !']); exit; } // 2. Insérer le nouveau TOTAL BANQUE $stmtIns = $pdo->prepare("INSERT INTO pf_savings (owner, month_date, category, amount) VALUES (?, ?, ?, ?)"); $stmtIns->execute([$owner, $targetDate, 'TOTAL_BANQUE', $newTotal]); // 3. Copier toutes les catégories (sauf TOTAL_BANQUE) du mois source $sqlCopy = "INSERT INTO pf_savings (owner, month_date, category, amount) SELECT owner, ?, category, amount FROM pf_savings WHERE owner = ? AND month_date = ? AND category != 'TOTAL_BANQUE'"; $stmtCopy = $pdo->prepare($sqlCopy); $stmtCopy->execute([$targetDate, $owner, $sourceDate]); $pdo->commit(); echo json_encode(['success' => true]); } catch (Exception $e) { $pdo->rollBack(); echo json_encode(['success' => false, 'error' => $e->getMessage()]); } exit; } // --- ACTION : SUPPRESSION GLOBALE D'UN MOIS --- if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delete_month_global') { $owner = $_POST['owner']; $date = $_POST['month_date']; try { $stmt = $pdo->prepare("DELETE FROM pf_savings WHERE owner = ? AND month_date = ?"); $stmt->execute([$owner, $date]); echo json_encode(['success' => true]); } catch (Exception $e) { echo json_encode(['success' => false, 'error' => $e->getMessage()]); } exit; } // --- ACTION : SAUVEGARDE CLASSIQUE (MODALE) --- if ($_SERVER['REQUEST_METHOD'] === 'POST') { $owner = $_POST['owner']; // CORRECTION ICI : On récupère l'onglet de redirection (Nens, Alex ou Laia) $redirectTab = $_POST['redirect_tab'] ?? $owner; $dateInput = $_POST['month_date']; $dateObj = new DateTime($dateInput); $monthDate = $dateObj->format('Y-m-01'); $values = $_POST['values'] ?? []; // Tableau généré par nos champs JS: values[Catégorie] try { $pdo->beginTransaction(); // On supprime d'abord les anciennes données du mois pour cet utilisateur $stmtDel = $pdo->prepare("DELETE FROM pf_savings WHERE owner = ? AND month_date = ?"); $stmtDel->execute([$owner, $monthDate]); // On réinsère les nouvelles données $stmtIns = $pdo->prepare("INSERT INTO pf_savings (owner, month_date, category, amount) VALUES (?, ?, ?, ?)"); foreach ($values as $category => $amount) { $amount = floatval($amount); // On enregistre si c'est positif OU si c'est le total banque if ($amount > 0 || $category === 'TOTAL_BANQUE') { $stmtIns->execute([$owner, $monthDate, $category, $amount]); } } $pdo->commit(); // Redirection vers le bon onglet header("Location: /budget.php?tab=epargne&owner=" . urlencode($redirectTab)); exit; } catch (Exception $e) { $pdo->rollBack(); die("Erreur : " . $e->getMessage()); } } ``` --- ### 📄 Fichier : `modules/budget/views/budget_prev.php` ```php prepare("SELECT * FROM pf_salary_config WHERE year = ?"); $stmt->execute([$currentYear]); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $salaryConfig[$row['person']] = $row; } foreach (['Alex', 'Laia'] as $p) { if (!isset($salaryConfig[$p])) { $salaryConfig[$p] = ['salary'=>0, 'mensualite'=>0, 'frais_func'=>0, 'eco_perso'=>0, 'eco_family'=>0]; } } // 2. Récupération Catégories $cats = $pdo->query("SELECT * FROM pf_alloc_categories ORDER BY sort_order ASC, id ASC")->fetchAll(PDO::FETCH_ASSOC); // 3. Gestion du mois FOCUS et Navigation $focusDate = isset($_GET['focus_date']) ? $_GET['focus_date'] : date('Y-m-01'); $focusTs = strtotime($focusDate); $months = []; for ($i = 0; $i < 6; $i++) { $months[] = date('Y-m-01', strtotime("-$i months", $focusTs)); } $prevMonthLink = date('Y-m-01', strtotime("-1 month", $focusTs)); $nextMonthLink = date('Y-m-01', strtotime("+1 month", $focusTs)); // NOUVEAU : Récupération des Cycles configurés dans pf_notes $cycleConfigs = []; $stmtNotes = $pdo->query("SELECT reference_id, content FROM pf_notes WHERE note_type = 'month_config'"); while ($row = $stmtNotes->fetch(PDO::FETCH_ASSOC)) { $parts = explode('-', $row['reference_id']); if (count($parts) == 2) { $mKey = $parts[1] . '-' . $parts[0] . '-01'; $cycleConfigs[$mKey] = json_decode($row['content'], true); } } // 4. Récupération Valeurs Répartition $inQuery = implode(',', array_fill(0, count($months), '?')); $stmt = $pdo->prepare("SELECT * FROM pf_alloc_values WHERE month_date IN ($inQuery)"); $stmt->execute($months); $allocs = []; while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $allocs[$row['month_date']][$row['cat_id']] = $row; } // 5. Récupération de l'ID de la catégorie système $sysCatId = null; foreach ($cats as $key => $c) { if ($c['name'] === 'SYSTEM_VALIDATION') { $sysCatId = $c['id']; unset($cats[$key]); break; } } // 6. Lecture des statuts de validation $focusDate = $months[0]; $isValidatedAlex = false; $isValidatedLaia = false; if ($sysCatId && isset($allocs[$focusDate][$sysCatId])) { $row = $allocs[$focusDate][$sysCatId]; $isValidatedAlex = ($row['amount_alex'] == 1); $isValidatedLaia = ($row['amount_laia'] == 1); } $stmtNote = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type = 'budget_prev' AND reference_id = ?"); $stmtNote->execute([$focusDate]); $currentNote = $stmtNote->fetchColumn(); // 7. Récupération des vacances actives $activeHolidays = $pdo->query("SELECT id, title FROM pf_holidays WHERE status IN ('draft', 'planned', 'booked') ORDER BY start_date ASC")->fetchAll(PDO::FETCH_ASSOC); // Helper function pour les mois function getTranslatedMonthName($dateString) { $m = date('m', strtotime($dateString)); $y = date('Y', strtotime($dateString)); return tr('month_' . $m) . ' ' . $y; } ?>

'Alex', ID_LAIA => 'Laia']; foreach ($family_members as $id => $p): $d = $salaryConfig[$p]; $restant = $d['salary'] - ($d['mensualite'] + $d['frais_func'] + $d['eco_perso'] + $d['eco_family']); $borderColor = ($id === ID_ALEX) ? '#0891b2' : '#f59e0b'; ?>

()

()

0, 'amount_laia'=>0]; ?>
" . sprintf(tr('bud_sav_from_date'), $cStart) . ""; } ?>
Global Alex Laia
Total 0 0 0
(Eco Family) - 0 0
Info
0

📝

0, 'Laia' => 0]; foreach ($cats as $cat) { $t = trim($cat['target']); if (empty($t)) continue; $val = $allocs[$focusMonth][$cat['id']] ?? ['amount_alex'=>0, 'amount_laia'=>0]; if (!isset($summaryData[$t])) { $summaryData[$t] = ['Alex' => 0, 'Laia' => 0]; } $summaryData[$t]['Alex'] += $val['amount_alex']; $summaryData[$t]['Laia'] += $val['amount_laia']; } $grandTotalAlex = 0; $grandTotalLaia = 0; $grandTotalGlobal = 0; ?>
-
ALEX
LAIA
Global
0 € 0 € 0 €
0 € 0 € 0 €

0 €
``` --- ### 📄 Fichier : `modules/budget/views/epargne.php` ```php query("SELECT reference_id, content FROM pf_notes WHERE note_type = 'month_config'"); while ($row = $stmtNotes->fetch(PDO::FETCH_ASSOC)) { $parts = explode('-', $row['reference_id']); if (count($parts) == 2) { $mKey = $parts[1] . '-' . $parts[0] . '-01'; $cycleConfigs[$mKey] = json_decode($row['content'], true); } } // Récupération sécurisée du nom des mois (utilise les clés globales existantes) function getMonthName($dateString) { $m = date('m', strtotime($dateString)); $y = date('Y', strtotime($dateString)); return tr('month_' . $m) . ' ' . $y; } ?>
prepare("SELECT month_date, category, amount FROM pf_savings WHERE owner = ? ORDER BY month_date DESC"); $stmt->execute([$currentOwner]); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); $data = []; $months = []; $allCategories = []; foreach ($rows as $row) { $m = $row['month_date']; $cat = $row['category']; $val = $row['amount']; $data[$m][$cat] = $val; if (!in_array($m, $months)) $months[] = $m; if ($cat !== 'TOTAL_BANQUE' && !in_array($cat, $allCategories)) $allCategories[] = $cat; } $months = array_slice($months, 0, 7); sort($allCategories); // Définition de la classe couleur selon le propriétaire $ownerTextClass = ''; if ($currentOwner === 'Alex') $ownerTextClass = 'txt-alex'; elseif ($currentOwner === 'Laia') $ownerTextClass = 'txt-laia'; else $ownerTextClass = 'txt-global'; // Pour Pol et Pep ?>

" . sprintf(tr('bud_sav_from_date'), $cStart) . ""; } ?>

0 €
``` --- ### 📄 Fichier : `modules/budget/views/recap.php` ```php query("SELECT * FROM pf_budget_items ORDER BY category DESC, sort_order ASC, name ASC"); $items = $stmt->fetchAll(); // 2. Déterminer quel est le mois de gestion "ouvert" par défaut $stmtActive = $pdo->query("SELECT content FROM pf_notes WHERE note_type = 'active_gestion_month' LIMIT 1"); $defaultActiveMonth = $stmtActive->fetchColumn(); if (!$defaultActiveMonth) { $defaultActiveMonth = date('Y-m-01'); } // 3. Assigner le mois et l'année en fonction du mois ouvert $currentMonth = isset($_GET['m']) ? str_pad((int)$_GET['m'], 2, '0', STR_PAD_LEFT) : date('m', strtotime($defaultActiveMonth)); $currentYear = isset($_GET['y']) ? (int)$_GET['y'] : date('Y', strtotime($defaultActiveMonth)); $viewMonthDate = "$currentYear-$currentMonth-01"; $sqlReal = "SELECT budget_item_id, SUM(amount) as total_real FROM pf_expenses WHERE gestion_month = ? AND budget_item_id IS NOT NULL GROUP BY budget_item_id"; $stmtReal = $pdo->prepare($sqlReal); $stmtReal->execute([$viewMonthDate]); $realTotals = $stmtReal->fetchAll(PDO::FETCH_KEY_PAIR); // Retourne un tableau [id => total] $sqlCatReal = "SELECT category, SUM(amount) as total_real FROM pf_expenses WHERE gestion_month = ? AND budget_item_id IS NULL GROUP BY category"; $stmtCatReal = $pdo->prepare($sqlCatReal); $stmtCatReal->execute([$viewMonthDate]); $catTotals = $stmtCatReal->fetchAll(PDO::FETCH_KEY_PAIR); $stmtLabels = $pdo->prepare("SELECT label, amount FROM pf_expenses WHERE gestion_month = ? AND budget_item_id IS NULL"); $stmtLabels->execute([$viewMonthDate]); $unlinkedExpenses = $stmtLabels->fetchAll(PDO::FETCH_ASSOC); $moisFr = ['', 'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']; $monthTranslationKey = 'month_' . str_pad((int)$currentMonth, 2, '0', STR_PAD_LEFT); $currentMonthName = tr($monthTranslationKey) . ' ' . $currentYear; $totalDepenses = 0; $totalRevenus = 0; ?>

= ($targetAbs - 0.10))); $rowClass = ($item['category'] === 'income') ? 'row-income' : 'row-expense'; if ($item['is_estimate']) $rowClass .= ' row-estimate'; ?>
()
('.tr('bud_est_short').')' : '' ?> 🔗
📅
0.05): ?>
: +
:
/
+
-
€ /

*

``` --- ### 📄 Fichier : `modules/budget/views/suivi.php` ```php query("SELECT content FROM pf_notes WHERE note_type = 'active_gestion_month' LIMIT 1"); $defaultActiveMonth = $stmtActive->fetchColumn(); if (!$defaultActiveMonth) { $defaultActiveMonth = date('Y-m-01'); } $viewM = isset($_GET['m']) ? str_pad((int)$_GET['m'], 2, '0', STR_PAD_LEFT) : date('m', strtotime($defaultActiveMonth)); $viewY = isset($_GET['y']) ? (int)$_GET['y'] : date('Y', strtotime($defaultActiveMonth)); $viewMonthDate = "$viewY-$viewM-01"; $prevDate = date('Y-m-01', strtotime('-1 month', strtotime($viewMonthDate))); $nextDate = date('Y-m-01', strtotime('+1 month', strtotime($viewMonthDate))); $prevLink = "?tab=suivi&m=" . date('m', strtotime($prevDate)) . "&y=" . date('Y', strtotime($prevDate)); $nextLink = "?tab=suivi&m=" . date('m', strtotime($nextDate)) . "&y=" . date('Y', strtotime($nextDate)); $defaultLink = "?tab=suivi"; // ============================================================================ // 2. GESTION DES ACTIONS POST (Clôture, Ajouts, CSV) // ============================================================================ if (isset($_POST['action']) && $_POST['action'] === 'close_month') { $monthToClose = $_POST['close_month_date']; $nextMonthToOpen = date('Y-m-01', strtotime('+1 month', strtotime($monthToClose))); $frozenData = [ 'is_closed' => true, 'solde_actuel' => (float)$_POST['freeze_solde_actuel'], 'capacite_max' => (float)$_POST['freeze_capacite_max'], 'solde_theorique' => (float)$_POST['freeze_solde_theorique'], 'reste_a_venir' => (float)$_POST['freeze_reste_a_venir'], 'closed_at' => date('Y-m-d H:i:s') ]; $pdo->prepare("INSERT INTO pf_notes (note_type, reference_id, content) VALUES ('month_closure', ?, ?) ON DUPLICATE KEY UPDATE content = VALUES(content)") ->execute([$monthToClose, json_encode($frozenData)]); $pdo->prepare("INSERT INTO pf_notes (note_type, reference_id, content) VALUES ('active_gestion_month', 'GLOBAL', ?) ON DUPLICATE KEY UPDATE content = VALUES(content)") ->execute([$nextMonthToOpen]); echo ""; exit; } if (isset($_POST['action']) && $_POST['action'] === 'reopen_month') { $pdo->prepare("DELETE FROM pf_notes WHERE note_type = 'month_closure' AND reference_id = ?")->execute([$viewMonthDate]); $pdo->prepare("INSERT INTO pf_notes (note_type, reference_id, content) VALUES ('active_gestion_month', 'GLOBAL', ?) ON DUPLICATE KEY UPDATE content = VALUES(content)") ->execute([$viewMonthDate]); echo ""; exit; } if (isset($_POST['action']) && $_POST['action'] === 'save_import') { $count = 0; $stmtExp = $pdo->prepare("INSERT INTO pf_expenses (date_exp, gestion_month, category, label, amount, import_ref, budget_item_id, holiday_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"); $stmtRule = $pdo->prepare("INSERT INTO pf_import_rules (keyword, category) VALUES (?, ?) ON DUPLICATE KEY UPDATE category = VALUES(category)"); if (isset($_POST['lines']) && is_array($_POST['lines'])) { foreach ($_POST['lines'] as $line) { if (isset($line['import_check'])) { $cat = $line['cat']; $is_credit = isset($line['is_credit']) ? (int)$line['is_credit'] : 0; $budgetItemId = !empty($line['budget_item_id']) ? (int)$line['budget_item_id'] : null; $holidayId = !empty($line['holiday_id']) ? (int)$line['holiday_id'] : null; $gestionMonthLine = !empty($line['gestion_month']) ? $line['gestion_month'] . '-01' : $viewMonthDate; if ($is_credit && empty($cat)) continue; if (!$is_credit && empty($cat)) continue; $finalAmount = $is_credit ? abs($line['amount']) : -abs($line['amount']); $dateToSave = $line['date']; try { $stmtExp->execute([$dateToSave, $gestionMonthLine, $cat, $line['label'], $finalAmount, $line['ref'], $budgetItemId, $holidayId]); $stmtRule->execute([$line['label'], $cat]); $count++; } catch (Exception $e) { continue; } } } } echo ""; exit; } if (isset($_POST['action']) && $_POST['action'] === 'save_snapshot') { $pdo->query("DELETE FROM pf_bank_snapshots"); $pdo->prepare("INSERT INTO pf_bank_snapshots (snapshot_date, amount) VALUES (?, ?)")->execute([$_POST['snapshot_date'], floatval($_POST['snapshot_amount'])]); echo ""; exit; } // ============================================================================ // E-Bis. LECTURE ET PRÉVISUALISATION DU FICHIER CSV // ============================================================================ $csvData = []; $showPreview = false; if (isset($_FILES['csv_file']) && $_FILES['csv_file']['error'] == 0) { $file = $_FILES['csv_file']['tmp_name']; $handle = fopen($file, "r"); $rules = []; try { $rules = $pdo->query("SELECT keyword, category FROM pf_import_rules")->fetchAll(PDO::FETCH_KEY_PAIR); } catch(Exception $e){} $existingRefs = []; try { $existingRefs = $pdo->query("SELECT import_ref FROM pf_expenses WHERE import_ref IS NOT NULL")->fetchAll(PDO::FETCH_COLUMN); } catch(Exception $e){} fgetcsv($handle, 1000, ";", "\"", "\\"); while (($data = fgetcsv($handle, 1000, ";", "\"", "\\")) !== FALSE) { $rawDebit = $data[8] ?? ''; $rawCredit = $data[9] ?? ''; $amount = 0; $isCredit = 0; if (!empty(trim($rawCredit))) { $amount = abs((float)str_replace(',', '.', str_replace(' ', '', $rawCredit))); $isCredit = 1; } elseif (!empty(trim($rawDebit))) { $amount = abs((float)str_replace(',', '.', str_replace(' ', '', $rawDebit))); } else continue; $dateParts = explode('/', $data[0]); $dateSql = (count($dateParts) == 3) ? $dateParts[2].'-'.$dateParts[1].'-'.$dateParts[0] : date('Y-m-d'); $label = trim($data[1]) ?: trim($data[2]); $refCSV = trim($data[3]); $uniqueKey = !empty($refCSV) ? "REF_".$refCSV : "HASH_".md5($dateSql.$label.number_format($amount, 2).$isCredit); $isDuplicate = in_array($uniqueKey, $existingRefs); $suggestedCat = ''; foreach ($rules as $kw => $c) { if (stripos($label, $kw) !== false) { $suggestedCat = $c; break; } } $csvData[] = ['date'=>$dateSql, 'label'=>$label, 'amount'=>$amount, 'cat'=>$suggestedCat, 'ref'=>$uniqueKey, 'is_duplicate'=>$isDuplicate, 'is_credit'=>$isCredit]; } fclose($handle); $showPreview = true; } // ============================================================================ // 3. RECUPERATION DES DONNEES ET CALCULS // ============================================================================ $stmtCheckClose = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type = 'month_closure' AND reference_id = ?"); $stmtCheckClose->execute([$viewMonthDate]); $closureJson = $stmtCheckClose->fetchColumn(); $monthState = $closureJson ? json_decode($closureJson, true) : null; $isClosed = ($monthState && isset($monthState['is_closed']) && $monthState['is_closed'] === true); $snapshot = ['date' => date('Y-m-d'), 'amount' => 0]; $snapStmt = $pdo->query("SELECT * FROM pf_bank_snapshots ORDER BY id DESC LIMIT 1"); if ($s = $snapStmt->fetch(PDO::FETCH_ASSOC)) { $snapshot = ['date' => $s['snapshot_date'], 'amount' => (float)$s['amount']]; } $stmtPrevClose = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type = 'month_closure' AND reference_id = ?"); $stmtPrevClose->execute([$prevDate]); $prevClosureJson = $stmtPrevClose->fetchColumn(); $prevMonthState = $prevClosureJson ? json_decode($prevClosureJson, true) : null; $solde_initial = ($prevMonthState && isset($prevMonthState['solde_actuel'])) ? (float)$prevMonthState['solde_actuel'] : 0; if ($solde_initial === 0 && !$prevMonthState) { $solde_initial = $snapshot['amount']; } $stmt = $pdo->prepare("SELECT * FROM pf_expenses WHERE gestion_month = ? ORDER BY date_exp DESC"); $stmt->execute([$viewMonthDate]); $allExpenses = $stmt->fetchAll(PDO::FETCH_ASSOC); $paidItemIds = array_column(array_filter($allExpenses, fn($e) => !empty($e['budget_item_id'])), 'budget_item_id'); $realExpensesLabels = array_column(array_filter($allExpenses, fn($e) => $e['amount'] < 0), 'label'); $budget_fmcg = 0; $budget_school = 0; $budget_essence = 0; $budget_frais = 0; $budget_income_prevu = 0; $total_income = 0; $total_expenses_prevues = 0; $reste_a_venir_calc = 0; $fixedChargesList = []; $incomeList = []; $pending_charges = []; $stmt = $pdo->query("SELECT id, name, amount, type, category, is_estimate, payment_day, mapping_keywords FROM pf_budget_items ORDER BY name ASC"); while ($item = $stmt->fetch(PDO::FETCH_ASSOC)) { $absAmount = abs((float)$item['amount']); $amt = ($item['type'] === 'Annuel') ? $absAmount / 12 : $absAmount; $name = trim($item['name']); if ($item['category'] === 'expense' && $item['type'] === 'Mensuel' && (int)$item['is_estimate'] === 0) { $fixedChargesList[] = ['id' => $item['id'], 'name' => $name, 'amount' => $absAmount]; } if ($item['category'] === 'income') { $incomeList[] = ['id' => $item['id'], 'name' => $name, 'amount' => $absAmount]; $total_income += $amt; $budget_income_prevu += $amt; } else { $total_expenses_prevues += $amt; if ($item['category'] === 'expense' && $item['type'] === 'Mensuel' && (int)$item['is_estimate'] === 0) { $isPaid = false; if (in_array($item['id'], $paidItemIds)) $isPaid = true; elseif (!empty($item['mapping_keywords'])) { $keywords = array_map('trim', explode(',', $item['mapping_keywords'])); foreach ($keywords as $kw) { if (empty($kw)) continue; foreach ($realExpensesLabels as $realLabel) { if (stripos($realLabel, $kw) !== false) { $isPaid = true; break 2; } } } } if (!$isPaid) { $reste_a_venir_calc += $absAmount; $pending_charges[] = ['name' => $name, 'amount' => $absAmount]; } } if ($name === 'Estimacio F&B & beauty') $budget_fmcg = $amt; elseif ($name === 'Estimacio escola') $budget_school = $amt; elseif ($name === 'Estimation gasolina') $budget_essence = $amt; elseif ((int)$item['is_estimate'] === 0 && $item['type'] === 'Mensuel' && $item['category'] === 'expense') { $budget_frais += $absAmount; } } } $budget_autres = max(0, $total_income - $total_expenses_prevues); // TRADUCTION DES CATÉGORIES $categoriesConfig = [ 'Income' => ['type'=>'credit', 'label'=>tr('cat_income'), 'budget'=>$budget_income_prevu, 'color'=>'#10b981', 'suggestions'=>[]], 'FMCG' => ['type'=>'debit', 'label'=>tr('cat_fmcg'), 'budget'=>$budget_fmcg, 'color'=>'#3b82f6', 'suggestions'=>['Action', 'Carrefour', 'Lidl']], 'Essence' => ['type'=>'debit', 'label'=>tr('cat_fuel'), 'budget'=>$budget_essence, 'color'=>'#f59e0b', 'suggestions'=>['Audi', 'Polo']], 'School' => ['type'=>'debit', 'label'=>tr('cat_school'), 'budget'=>$budget_school, 'color'=>'#10b981', 'suggestions'=>[]], 'Frais' => ['type'=>'debit', 'label'=>tr('cat_fixed'), 'budget'=>$budget_frais, 'color'=>'#ef4444', 'suggestions'=>[]], 'Autres' => ['type'=>'debit', 'label'=>tr('cat_others'), 'budget'=>$budget_autres, 'color'=>'#64748b', 'suggestions'=>['Restaurant', 'Cadeau']], 'LivretA' => ['type'=>'debit', 'label'=>tr('cat_savings'),'budget'=>0, 'color'=>'#8b5cf6', 'suggestions'=>['Virement']] ]; $totals = array_fill_keys(array_keys($categoriesConfig), 0); $expensesByCategory = array_fill_keys(array_keys($categoriesConfig), []); $total_rentrees = 0; $depenses_reelles = 0; foreach ($allExpenses as $exp) { $cat = $exp['category']; if (!isset($totals[$cat])) $cat = 'Autres'; $val = (float)$exp['amount']; if ($cat === 'Income') { $totals[$cat] += $val; } else { if ($val > 0) $categoriesConfig[$cat]['budget'] += $val; else $totals[$cat] += abs($val); } $expensesByCategory[$cat][] = $exp; if ($val > 0) { if ($cat === 'Income' || $cat !== 'Frais') $total_rentrees += $val; else $depenses_reelles -= $val; } else { $depenses_reelles += abs($val); } } // Reste à venir dynamiques $ecole_depense = isset($totals['School']) ? $totals['School'] : 0; $reste_ecole = max(0, $budget_school - $ecole_depense); if ($reste_ecole > 0) { $reste_a_venir_calc += $reste_ecole; $pending_charges[] = ['name' => tr('bud_rem_school'), 'amount' => $reste_ecole]; } $fmcg_depense = isset($totals['FMCG']) ? $totals['FMCG'] : 0; $reste_fmcg = max(0, $budget_fmcg - $fmcg_depense); if ($reste_fmcg > 0) { $reste_a_venir_calc += $reste_fmcg; $pending_charges[] = ['name' => tr('bud_rem_fmcg'), 'amount' => $reste_fmcg]; } $essence_depense = isset($totals['Essence']) ? $totals['Essence'] : 0; $reste_essence = max(0, $budget_essence - $essence_depense); if ($reste_essence > 0) { $reste_a_venir_calc += $reste_essence; $pending_charges[] = ['name' => tr('bud_rem_fuel'), 'amount' => $reste_essence]; } // F. Calculs des KPIs finaux $rentrees_salaires_reels = $totals['Income'] ?? 0; $rentrees_autres = $total_rentrees - $rentrees_salaires_reels; $salaires_retenus = max($rentrees_salaires_reels, $budget_income_prevu); $capacite_max_calc = $solde_initial + $salaires_retenus + $rentrees_autres; $solde_theorique_calc = ($solde_initial + $total_rentrees) - $depenses_reelles - $reste_a_venir_calc; if ($isClosed) { $solde_actuel = $monthState['solde_actuel']; $capacite_max = $monthState['capacite_max']; $solde_theorique = $monthState['solde_theorique']; $reste_a_venir = $monthState['reste_a_venir']; } else { $solde_actuel = $snapshot['amount']; $capacite_max = $capacite_max_calc; $solde_theorique = $solde_theorique_calc; $reste_a_venir = $reste_a_venir_calc; } $solde_net = max(0, $solde_actuel - $reste_a_venir); $charges_visibles = min($solde_actuel, $reste_a_venir); $max_scale = max($solde_actuel, $solde_theorique, $capacite_max, 1) * 1.1; $pct_net = min(100, max(0, ($solde_net / $max_scale) * 100)); $pct_charges = min(100 - $pct_net, max(0, ($charges_visibles / $max_scale) * 100)); $pct_actuel = min(100, max(0, ($solde_actuel / $max_scale) * 100)); $pct_theorique = min(100, max(0, ($solde_theorique / $max_scale) * 100)); function getDisplayLogic($spent, $bg, $type) { $pct = ($bg > 0) ? min(100, ($spent / $bg) * 100) : ($spent > 0 ? 100 : 0); $isOver = ($type === 'debit' && $spent > $bg && $bg > 0); $text = ($bg > 0) ? number_format(ceil($spent), 0, ',', ' ') . ' / ' . number_format(ceil($bg), 0, ',', ' ') . ' €' : number_format(ceil($spent), 0, ',', ' ') . ' €'; return ['pct' => $pct, 'isOver' => $isOver, 'text' => $text]; } // Noms des mois traduits $monthNames = [ 1 => tr('month_01'), 2 => tr('month_02'), 3 => tr('month_03'), 4 => tr('month_04'), 5 => tr('month_05'), 6 => tr('month_06'), 7 => tr('month_07'), 8 => tr('month_08'), 9 => tr('month_09'), 10 => tr('month_10'), 11 => tr('month_11'), 12 => tr('month_12') ]; $monthName = $monthNames[(int)$viewM] . ' ' . $viewY; ?>
🔒

-
= 0 ? $posZero : $posActuel; $widthDanger = 0; $leftDanger = $posZero; if ($solde_theorique < 0) { $widthDanger = (abs($solde_theorique) / $range) * 100; $leftDanger = $posTheorique; } ?>
0): ?>
0 €
: € ℹ️
$conf): ?>
>

0 ? '+' : '-' ?>

🏦

📄

$row): $dup = $row['is_duplicate']; $isCrd = $row['is_credit']; $dis = $dup?'disabled':''; $bgCol = $dup ? 'opacity:0.5' : (empty($row['cat']) && !$isCrd ? 'background:#fff1f2' : ''); ?>
onchange="checkValidation()"> >
``` --- ### 📄 Fichier : `modules/family-calendar/admin/generate-calendar-year.php` ```php setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); } else { die("Erreur : PDO non initialisé dans db.php"); } $year = isset($_GET['year']) ? (int)$_GET['year'] : (int)date('Y'); if ($year < 2000 || $year > 2100) { die("Année invalide."); } try { $pdo->beginTransaction(); // On supprime d'abord les semaines de cette année calendaire $stmtDel = $pdo->prepare("DELETE FROM pf_calendar_weeks WHERE year = :year"); $stmtDel->execute([':year' => $year]); // 1er janvier de l'année calendaire $start = new DateTime("$year-01-01"); // On remonte au lundi précédent (ou on reste dessus si déjà lundi) while ($start->format('N') != 1) { // 1 = lundi $start->modify('-1 day'); } // Dernier jour de l'année $end = new DateTime("$year-12-31"); // Préparation de l'INSERT avec gestion des doublons // (en supposant une contrainte UNIQUE, par ex. sur (year, week_iso_year, week_iso_number)) $sql = " INSERT INTO pf_calendar_weeks ( year, week_iso_year, week_iso_number, week_label, month, month_name, week_start_date, mon_date, tue_date, wed_date, thu_date, fri_date, sat_date, sun_date ) VALUES ( :year, :week_iso_year, :week_iso_number, :week_label, :month, :month_name, :week_start_date, :mon_date, :tue_date, :wed_date, :thu_date, :fri_date, :sat_date, :sun_date ) ON DUPLICATE KEY UPDATE week_label = VALUES(week_label), month = VALUES(month), month_name = VALUES(month_name), week_start_date = VALUES(week_start_date), mon_date = VALUES(mon_date), tue_date = VALUES(tue_date), wed_date = VALUES(wed_date), thu_date = VALUES(thu_date), fri_date = VALUES(fri_date), sat_date = VALUES(sat_date), sun_date = VALUES(sun_date) "; $stmt = $pdo->prepare($sql); // fonction mois FR function getMonthNameFr($monthIndexZeroBased) { $months = [ "Janvier", "Fevrier", "Mars", "Avril", "Mai", "Juin", "Juillet", "Aout", "Septembre", "Octobre", "Novembre", "Decembre", ]; return $months[$monthIndexZeroBased] ?? ""; } $current = clone $start; $insertCount = 0; while ($current <= $end) { $monday = clone $current; // Calcul des 7 jours de la semaine $mon = clone $monday; $tue = (clone $monday)->modify('+1 day'); $wed = (clone $monday)->modify('+2 days'); $thu = (clone $monday)->modify('+3 days'); $fri = (clone $monday)->modify('+4 days'); $sat = (clone $monday)->modify('+5 days'); $sun = (clone $monday)->modify('+6 days'); // Année / semaine ISO $weekIsoYear = (int)$monday->format('o'); // année ISO $weekIsoNumber = (int)$monday->format('W'); $weekLabel = 'W' . str_pad($weekIsoNumber, 2, '0', STR_PAD_LEFT); // Détermination du mois d'affectation de la semaine // -> on compte combien de jours de la semaine tombent dans chaque mois $days = [$mon, $tue, $wed, $thu, $fri, $sat, $sun]; // compteur mois => nb de jours (clé = numéro de mois 1..12) $monthCounts = []; foreach ($days as $d) { $m = (int)$d->format('n'); // 1-12 if (!isset($monthCounts[$m])) { $monthCounts[$m] = 0; } $monthCounts[$m]++; } // On prend le mois ayant le plus de jours // (si égalité, le mois ayant la plus petite valeur numérique gagnera, // ce qui est raisonnable, mais on peut changer si besoin) $chosenMonth = null; $maxDays = -1; foreach ($monthCounts as $m => $count) { if ($count > $maxDays) { $maxDays = $count; $chosenMonth = $m; } } $month = $chosenMonth; $monthName = getMonthNameFr($month - 1); $stmt->execute([ ':year' => $weekIsoYear, // <-- au lieu de $year ':week_iso_year' => $weekIsoYear, ':week_iso_number' => $weekIsoNumber, ':week_label' => $weekLabel, ':month' => $month, ':month_name' => $monthName, ':week_start_date' => $monday->format('Y-m-d'), ':mon_date' => $mon->format('Y-m-d'), ':tue_date' => $tue->format('Y-m-d'), ':wed_date' => $wed->format('Y-m-d'), ':thu_date' => $thu->format('Y-m-d'), ':fri_date' => $fri->format('Y-m-d'), ':sat_date' => $sat->format('Y-m-d'), ':sun_date' => $sun->format('Y-m-d'), ]); $insertCount++; $current->modify('+7 days'); } $pdo->commit(); echo "Calendrier $year généré. Semaines traitées : " . $insertCount; } catch (PDOException $e) { if ($pdo->inTransaction()) { $pdo->rollBack(); } die("Erreur PDO : " . $e->getMessage()); } catch (Exception $e) { if ($pdo->inTransaction()) { $pdo->rollBack(); } die("Erreur générale : " . $e->getMessage()); } ``` --- ### 📄 Fichier : `modules/family-calendar/family-calendar.css` ```css /* modules/family-calendar/family-calendar.css */ :root { --c-school-holiday: #e5d9f2; --c-public-holiday: #e0e0e0; --c-off-carole: #ffedd5; --c-extra-off: #fee2e2; --c-selected: #bfdbfe; --bg-alex: #f0fdfa; --text-alex: #0f766e; --bg-laia: #fffbeb; --text-laia: #b45309; } .pf-family-calendar .pf-main { padding-top: 20px; padding-bottom: 40px; } /* En-tête & Boutons */ .fc-header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; } .pf-btn-icon-text { background: white; border: 1px solid var(--pf-border); border-radius: 20px; padding: 8px 16px; display: inline-flex; align-items: center; gap: 8px; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: 0.2s; box-shadow: var(--pf-shadow-sm); } .pf-btn-icon-text:hover { border-color: var(--pf-primary); color: var(--pf-primary); background: #f0f9ff; transform: translateY(-1px); } /* Calendrier Mensuel */ .fc-month-calendar-wrapper { background: white; border: 1px solid var(--pf-border); border-radius: var(--pf-radius-lg); padding: 24px; margin-bottom: 32px; box-shadow: var(--pf-shadow-sm); } .fc-month-header { display: flex !important; flex-direction: column !important; gap: 16px !important; margin-bottom: 20px !important; } .fc-month-nav-row { display: flex !important; align-items: center !important; justify-content: space-between !important; width: 100% !important; } .fc-month-nav-row h3 { margin: 0 !important; font-size: 1.4rem !important; font-weight: 800 !important; text-transform: capitalize !important; text-align: center !important; flex-grow: 1 !important; } .fc-nav-button { width: 36px; height: 36px; border-radius: 50%; border: 1px solid var(--pf-border); background: white; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 18px; transition: 0.2s; } .fc-nav-button:hover { border-color: var(--pf-primary); color: var(--pf-primary); } .fc-view-controls { display: flex !important; width: 100% !important; background: #e2e8f0 !important; padding: 4px !important; border-radius: 8px !important; } .fc-view-button { flex: 1 !important; text-align: center !important; background: transparent; border: none; border-radius: 6px; padding: 6px 16px; font-size: 0.85rem; font-weight: 600; color: var(--pf-text-muted); cursor: pointer; transition: 0.2s; } .fc-view-button--active { background: white; color: var(--pf-primary); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); font-weight: 700; } .fc-month-calendar { width: 100%; display: block; overflow-x: auto; } .fc-month-table { min-width: 100% !important; width: 100% !important; table-layout: fixed; border-collapse: collapse !important; } .fc-month-table td { height: 80px; padding: 6px !important; font-size: 0.85rem; vertical-align: top; position: relative; cursor: pointer; border: 1px solid var(--pf-border) !important; } .fc-month-table th { padding: 10px !important; font-size: 0.8rem !important; background: var(--pf-bg-lighter) !important; color: var(--pf-text-muted); text-transform: uppercase; border: 1px solid var(--pf-border) !important; } .fc-day--other-month { background: #fcfcfc !important; } /* Vues Multi-mois */ .fc-two-months-container { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; width: 100%; overflow-x: auto; } .fc-three-months-container { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; width: 100%; overflow-x: auto; } .fc-month-title { text-align: center; font-weight: 700; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid var(--pf-primary); text-transform: capitalize; } /* Décompte Congés (Pills) */ .fc-month-balances { display: flex; flex-wrap: wrap; justify-content: center; gap: 15px; margin-top: 20px; padding-top: 15px; border-top: 1px dashed var(--pf-border); } .fc-minimal-balance-card { display: flex; align-items: center; gap: 12px; background: white; padding: 8px 16px; border-radius: 50px; border: 1px solid #e2e8f0; } .fc-minimal-balance-card strong.alex { color: var(--text-alex); } .fc-minimal-balance-card strong.laia { color: var(--text-laia); } .fc-minimal-chips { display: flex; gap: 8px; flex-wrap: wrap; } .fc-min-chip { display: flex; align-items: center; background: var(--pf-bg-page); border-radius: 20px; padding: 3px 10px; font-size: 0.75rem; border: 1px solid #cbd5e1; } .fc-min-chip .type { font-weight: 700; color: var(--pf-text-muted); margin-right: 6px; } .fc-min-chip .val { font-weight: 800; color: var(--pf-text-main); } .fc-used-badge { margin-left: 6px; background: #fee2e2; color: var(--pf-danger); padding: 1px 5px; border-radius: 10px; font-size: 0.65rem; font-weight: bold; } .fc-burn-alert { margin-left: 5px; cursor: help; animation: pulseBurn 2s infinite; font-size: 0.9rem; display: flex; align-items: center; color: var(--pf-danger); font-weight: bold; } @keyframes pulseBurn { 0% { transform: scale(1); opacity: 0.8; } 50% { transform: scale(1.2); opacity: 1; } 100% { transform: scale(1); opacity: 0.8; } } /* Couleurs des jours */ .fc-day--school-holiday { background: var(--c-school-holiday) !important; } .fc-day--public-holiday { background: var(--c-public-holiday) !important; font-weight: 700; } .fc-day--off-carole { background: var(--c-off-carole) !important; } .fc-day--extra-off-carole { background: var(--c-extra-off) !important; } .fc-day--selected { background: var(--c-selected) !important; outline: 2px solid var(--pf-primary); z-index: 5; } .fc-day--centre::after { content: "🏫"; position: absolute; top: 2px; right: 2px; font-size: 12px; } .fc-day--avis::after { content: ""; position: absolute; top: 2px; right: 2px; width: 14px; height: 14px; background: url("/modules/family-calendar/assets/img/avis.svg") no-repeat center; background-size: contain; } .fc-pep-sick-emoji { position: absolute; bottom: 2px; right: 2px; font-size: 12px; } .fc-day--off-carole.fc-day--school-holiday, .fc-day--extra-off-carole.fc-day--school-holiday, .fc-day--centre.fc-day--school-holiday, .fc-day--avis.fc-day--school-holiday { box-shadow: inset 0 -8px 0 0 var(--c-school-holiday) !important; } /* Planning Hebo (Desktop) */ .fc-week-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; flex-wrap: wrap; } .fc-week-nav-controls { display: flex; align-items: center; gap: 12px; background: white; padding: 4px 12px; border-radius: 50px; } #fc-current-school-year-label { font-size: 1rem; font-weight: 800; min-width: 120px; text-align: center; } #planningTable-wrapper { max-height: 80vh; overflow: auto; border: 1px solid var(--pf-border); border-radius: 12px; background: white; box-shadow: var(--pf-shadow-sm); position: relative; } #planningTable { width: 100%; min-width: 1000px; border-collapse: separate; border-spacing: 0; } #planningTable thead th { position: sticky; top: 0; background: var(--pf-bg-lighter); border-bottom: 1px solid var(--pf-border); border-right: 1px solid var(--pf-border); padding: 6px; font-size: 0.75rem; z-index: 20; } .col-alex.header-group { background: var(--bg-alex) !important; color: var(--text-alex); } .col-laia.header-group { background: var(--bg-laia) !important; color: var(--text-laia); } #planningTable tbody td { border-right: 1px solid var(--pf-bg-lighter); border-bottom: 1px solid var(--pf-bg-lighter); height: 36px; padding: 0 4px; text-align: center; font-size: 0.85rem; white-space: nowrap; cursor: pointer; } .col-alex-sub { background: var(--bg-alex); } .col-laia-sub { background: var(--bg-laia); } .col-month { width: 54px !important; min-width: 54px !important; max-width: 54px !important; background: white; border-right: 2px solid var(--pf-border) !important; } th.col-day, td.col-day { min-width: 35px !important; border-right: 2px solid var(--pf-border) !important; } th.col-total, td.col-total, th.col-alex-sub, td.col-alex-sub, th.col-laia-sub, td.col-laia-sub { width: 32px !important; min-width: 32px !important; max-width: 32px !important; } .rotated-text span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.7rem; color: var(--pf-text-muted); } #planningTable tbody td.col-sticky-mois { position: sticky !important; left: 0 !important; z-index: 15 !important; background: white !important; } #planningTable tbody td.col-sticky-sem { position: sticky !important; left: 54px !important; z-index: 14 !important; background: white !important; box-shadow: 4px 0 6px -2px rgba(0, 0, 0, 0.1); } #planningTable thead tr th.col-sticky-mois { position: sticky !important; top: 0 !important; left: 0 !important; z-index: 30 !important; background: var(--pf-bg-lighter) !important; } #planningTable thead tr th.col-sticky-sem { position: sticky !important; top: 0 !important; left: 54px !important; z-index: 29 !important; background: var(--pf-bg-lighter) !important; box-shadow: 4px 0 6px -2px rgba(0, 0, 0, 0.1); } /* Panneaux bas & Légendes */ .fc-bottom-grid { display: grid; grid-template-columns: 2fr 1fr; gap: 24px; align-items: start; } .pf-legend-grid { display: flex; flex-wrap: wrap; gap: 8px; } .pf-legend-item { display: inline-flex; align-items: center; gap: 6px; font-size: 0.75rem; padding: 4px 8px; border: 1px solid var(--pf-border); border-radius: 6px; } .pf-legend-color { width: 16px; height: 16px; border-radius: 3px; } .fc-legend-school-holiday { background: var(--c-school-holiday); } .fc-legend-public-holiday { background: var(--c-public-holiday); } .fc-legend-off-carole { background: var(--c-off-carole); } .fc-legend-extra-off-carole { background: var(--c-extra-off); } .fc-legend-centre::after { content: "🏫"; } .fc-legend-pep-sick::after { content: "🤒"; } .fc-legend-avis { background: white url("/modules/family-calendar/assets/img/avis.svg") no-repeat center; background-size: contain; border: none; } .fc-summary-header { display: flex; justify-content: space-between; align-items: center; padding: 8px 16px; } .fc-summ-select { border: 1px solid var(--pf-border); border-radius: 6px; padding: 2px 8px; font-size: 0.75rem; height: 24px; } .fc-summary-item { display: flex; justify-content: space-between; padding: 12px 4px; border-bottom: 1px solid var(--pf-bg-lighter); } .fc-summary-label { font-size: 0.85rem; color: var(--pf-text-muted); font-weight: 500; } .fc-summary-value { font-size: 1rem; font-weight: 700; } /* Menu Contextuel Tactile */ .fc-selection-menu { position: absolute; z-index: 9000; background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(8px); border: 1px solid rgba(226, 232, 240, 0.8); box-shadow: var(--pf-shadow-lg); border-radius: 16px; padding: 10px 12px; min-width: 220px; display: none; animation: menuPopIn 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards; transform-origin: top center; } @keyframes menuPopIn { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } } .fc-menu-section { padding: 6px 0; border-bottom: 1px dashed #cbd5e1; } .fc-menu-section:last-child { border: none; padding-bottom: 0; } .fc-menu-header { display: flex; justify-content: space-between; margin-bottom: 6px; } .fc-menu-section strong { font-size: 0.7rem; text-transform: uppercase; color: var(--pf-text-muted); } .fc-menu-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; } .fc-menu-btn { background: white; border: 1px solid var(--pf-border); padding: 6px 8px; border-radius: 8px; font-size: 0.8rem; font-weight: 600; cursor: pointer; width: 100%; transition: 0.2s; box-shadow: var(--pf-shadow-sm); } .fc-menu-btn:hover { background: var(--pf-bg-page); border-color: var(--pf-primary); color: var(--pf-primary); } .fc-menu-clear-icon { background: transparent; border: none; color: #94a3b8; cursor: pointer; padding: 4px; display: flex; border-radius: 6px; } .fc-menu-clear-icon svg { width: 14px; height: 14px; } .fc-menu-clear-icon:hover { background: #fef2f2; color: var(--pf-danger); } .fc-menu-leaves-table table { width: 100%; border-spacing: 2px; } .fc-menu-leaves-table th { font-size: 0.7rem; color: var(--pf-text-muted); text-align: center; } .fc-th-inline { display: flex; justify-content: center; gap: 4px; } /* Table specifique modale vacances */ .fc-holidays-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; } .fc-holidays-table th { background: var(--pf-bg-page); padding: 12px 16px; text-align: left; font-weight: 600; position: sticky; top: 0; border-bottom: 1px solid var(--pf-border); } .fc-holidays-table td { padding: 10px 16px; border-bottom: 1px solid var(--pf-bg-lighter); } .fc-holidays-table tr:nth-child(even) { background: #fafafa; } /* Mobile */ @media (max-width: 768px) { .pf-family-calendar .pf-container { padding: 0 !important; } .fc-header-row { flex-direction: column; gap: 15px; padding: 0 10px; } .fc-header-row > div { flex-direction: column; width: 100%; } .pf-btn-icon-text { width: 100%; justify-content: center; } .fc-week-header, #planningTable-wrapper { display: none !important; } .fc-month-calendar-wrapper { padding: 16px !important; margin-bottom: 20px !important; } .fc-month-table td { height: 46px !important; padding: 2px !important; font-size: 0.75rem !important; } .fc-bottom-grid { grid-template-columns: 1fr; padding: 0 10px; } .fc-selection-menu { position: fixed !important; top: auto !important; left: 0 !important; bottom: 0 !important; width: 100% !important; border-radius: 24px 24px 0 0; padding: 24px 20px; transform: translateY(100%); animation: slideUpSheet 0.3s forwards; z-index: 10000 !important; } .fc-selection-menu::before { content: ""; display: block; width: 40px; height: 5px; background: #cbd5e1; border-radius: 10px; margin: 0 auto 15px auto; } } ``` --- ### 📄 Fichier : `modules/family-calendar/family-calendar.js` ```javascript /** * family-calendar.js (Version Optimisée - API BDD & Décalage Vendredi) */ document.addEventListener("DOMContentLoaded", () => { const CONGE_TYPES = ["OFF_CAROLE", "EXTRA_OFF_CAROLE"]; const GUARDE_TYPES = ["CENTRE", "AVIS"]; const PEP_TYPES = ["PEP_SICK"]; const FAMILY = { ALEX: { id: 2, prefix: "alex" }, LAIA: { id: 3, prefix: "laia" }, }; const LEAVES_CONFIG = { CP: { startMonth: 8, // Le cycle commence en août (après la tolérance de juillet) defaultBalance: 25, }, JRA: { // Tu pourras ajouter les années suivantes ici yearlyTotals: { 2024: 10, // ex: 0.83 * 12 arrondi 2025: 10, 2026: 11, // ex: 0.9 * 12 arrondi }, defaultBalance: 10, toleranceMonths: 2, // Janvier et Février maxReport: 2, }, JA: { [FAMILY.ALEX.id]: { startMonth: 4, startDay: 29, defaultBalance: 4 }, [FAMILY.LAIA.id]: { startMonth: 10, startDay: 1, defaultBalance: 4 }, // Date de Laia à adapter }, }; class FamilyCalendar { constructor() { this.planningBody = document.getElementById("planningBody"); this.selectionMenu = document.getElementById("selectionMenu"); this.schoolHolidaysTableBody = document.querySelector( "#schoolHolidaysTable tbody", ); this.monthCalendar = document.getElementById("fc-month-calendar"); this.monthSelectionMenu = document.getElementById( "fc-month-selectionMenu", ); if ( this.selectionMenu && this.selectionMenu.parentElement !== document.body ) { document.body.appendChild(this.selectionMenu); } if ( this.monthSelectionMenu && this.monthSelectionMenu.parentElement !== document.body ) { document.body.appendChild(this.monthSelectionMenu); } this.currentMonth = new Date(); this.currentMonth.setDate(1); this.viewMode = "1month"; this.currentSchoolYearStart = null; this.modalSelectedYear = null; // Pour la modale des vacances this.isSelecting = false; this.selectedCells = []; this.monthSelectedCells = []; this._currentBulkInfo = null; this.dbEvents = []; this.fixedEvents = []; // Fériés statiques this.events = []; this.leaves = []; this.weeks = []; this.monthlyLeaveBalances = { 2: { CP: {}, JRA: {}, JA: {} }, 3: { CP: {}, JRA: {}, JA: {} }, }; if (!this.planningBody) return; this.init(); } getLocalIsoDate(dateObj) { const y = dateObj.getFullYear(); const m = String(dateObj.getMonth() + 1).padStart(2, "0"); const d = String(dateObj.getDate()).padStart(2, "0"); return `${y}-${m}-${d}`; } async init() { this.setupEventListeners(); const now = new Date(); this.currentSchoolYearStart = now.getMonth() >= 8 ? now.getFullYear() : now.getFullYear() - 1; this.modalSelectedYear = this.currentSchoolYearStart; this.setupModalUI(); // Prépare le selecteur d'année dans la modale await this.refreshAllData(); this.updateSchoolYearLabel(); } // Modifie dynamiquement le titre de la modale pour y insérer le selecteur d'année setupModalUI() { const headerH2 = document.querySelector(".fc-modal-header h2"); if (headerH2 && !document.getElementById("holidayYearSelect")) { headerH2.innerHTML = `${tr("fc_modal_holidays_title")} `; document .getElementById("holidayYearSelect") .addEventListener("change", (e) => { this.modalSelectedYear = parseInt(e.target.value); this.renderModalHolidays(); }); } } async refreshAllData() { try { const weeksData = await this.fetchApi( `/modules/family-calendar/includes/api/get-calendar-weeks-scolaire.php?school_year_start=${this.currentSchoolYearStart}`, ); this.weeks = this.processWeeks(weeksData.weeks || []); const eventsData = await this.fetchApi( "/modules/family-calendar/includes/api/get-events.php", ); this.dbEvents = (eventsData.events || []).map((e) => ({ ...e, duration: parseFloat(e.duration), })); this.fixedEvents = await this.fetchPublicHolidays(); const leavesData = await this.fetchApi( "/modules/family-calendar/includes/api/get-leaves.php", ); this.leaves = leavesData.leaves || []; const balancesData = await this.fetchApi( "/modules/family-calendar/includes/api/get-leave-balances.php", ); this.leaveBalances = balancesData.balances || []; // --- NOUVEAU : Chargement des correctifs de congés --- const snapshotsData = await this.fetchApi( "/modules/family-calendar/includes/api/get-leave-snapshots.php", ); this.leaveSnapshots = snapshotsData.snapshots || []; this.events = [...this.dbEvents, ...this.fixedEvents]; this.publicHolidayDates = new Set( this.fixedEvents .filter((e) => e.type === "PUBLIC_HOLIDAY") .map((e) => e.date), ); this.reprocessAndRender(); this.renderModalHolidays(); } catch (e) { console.error("Erreur chargement données:", e); } } // Les jours fériés restent hardcodés car ils sont fixes. async fetchPublicHolidays() { try { // Appel à l'API officielle des jours fériés en métropole const res = await fetch( "https://calendrier.api.gouv.fr/jours-feries/metropole.json", ); const holidays = await res.json(); // L'API renvoie un objet : { "2024-01-01": "Jour de l'an", ... } // On le transforme en tableau compatible avec ton système d'événements return Object.keys(holidays).map((date, idx) => ({ id: `ph-${idx}`, date: date, name: holidays[date], // On garde le nom au cas où tu veuilles l'afficher plus tard type: "PUBLIC_HOLIDAY", duration: 1, })); } catch (error) { console.error( "Erreur lors de la récupération des jours fériés :", error, ); return []; // Évite de casser le calendrier si l'API de l'État est indisponible } } // --- RECONSTRUCTION DE LA MODALE VIA LA BDD (Par blocs consécutifs) --- renderModalHolidays() { if (!this.schoolHolidaysTableBody) return; // Filtrer les événements de type VACANCES_SCOLAIRES pour l'année scolaire sélectionnée const startDate = `${this.modalSelectedYear}-09-01`; const endDate = `${this.modalSelectedYear + 1}-08-31`; const yearHolidays = this.events.filter( (e) => e.type === "VACANCES_SCOLAIRES" && e.date >= startDate && e.date <= endDate, ); // S'il n'y a pas de vacances en base pour cette année, on affiche le bouton "Générer" if (yearHolidays.length === 0) { this.schoolHolidaysTableBody.innerHTML = `

Les vacances de cette année ne sont pas encore enregistrées.

`; document .getElementById("btnFetchGovHolidays") .addEventListener("click", (e) => { e.target.innerText = "Téléchargement en cours..."; e.target.disabled = true; this.fetchAndSaveGovHolidays(this.modalSelectedYear); }); return; } // 1. Tri chronologique strict des jours yearHolidays.sort((a, b) => new Date(a.date) - new Date(b.date)); // 2. Regroupement par blocs de jours consécutifs const blocks = []; let currentBlock = null; yearHolidays.forEach((e) => { const d = new Date(e.date + "T00:00:00"); // Force locale if (!currentBlock) { currentBlock = { start: d, end: d }; blocks.push(currentBlock); } else { // Calcul de l'écart en jours entre la date actuelle et la fin du bloc en cours const diffTime = d.getTime() - currentBlock.end.getTime(); const diffDays = Math.round(diffTime / (1000 * 60 * 60 * 24)); // Si l'écart est minime (<= 4 jours, pour absorber un éventuel week-end non stocké) // on considère qu'on est toujours dans la même période de vacances. if (diffDays <= 4) { currentBlock.end = d; } else { // Sinon, l'écart est grand : c'est une NOUVELLE période de vacances currentBlock = { start: d, end: d }; blocks.push(currentBlock); } } }); // 3. Rendu HTML et déduction des noms let html = ""; blocks.forEach((block) => { // Déduction intelligente du nom selon le mois de départ ET la durée const m = block.start.getMonth() + 1; const durationDays = Math.round( (block.end.getTime() - block.start.getTime()) / (1000 * 60 * 60 * 24), ) + 1; let name = tr("leg_school_holidays"); if (m === 10 || m === 11) { name = tr("vac_toussaint"); } else if (m === 12 || m === 1) { name = tr("vac_noel"); } else if (m === 2 || m === 3) { name = tr("vac_hiver"); } else if (m === 4 || (m === 5 && durationDays > 6)) { name = tr("vac_printemps"); } else if (m === 5 && durationDays <= 6) { name = tr("vac_ascension"); } else if (m === 7 || m === 8) { name = tr("vac_ete"); } html += ` ${name} ${block.start.toLocaleDateString("fr-FR")} ${block.end.toLocaleDateString("fr-FR")} `; }); this.schoolHolidaysTableBody.innerHTML = html; } // --- IMPORTATION DEPUIS L'API & SAUVEGARDE EN BDD --- async fetchAndSaveGovHolidays(yearStart) { try { const yearStr = `${yearStart}-${yearStart + 1}`; const url = `https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/fr-en-calendrier-scolaire/records?where=annee_scolaire='${yearStr}' AND zones LIKE '%Zone C%'&limit=100`; const res = await fetch(url); const data = await res.json(); const rawRecords = data.results || []; // Dédoublonnage const uniqueMap = new Map(); rawRecords.forEach((r) => { const key = `${r.description}|${r.start_date}`; if (!uniqueMap.has(key)) uniqueMap.set(key, r); }); const payload = []; Array.from(uniqueMap.values()).forEach((r) => { const startDateStr = r.start_date.split("T")[0]; const endDateStr = r.end_date.split("T")[0]; let curr = new Date(startDateStr + "T00:00:00"); const end = new Date(endDateStr + "T00:00:00"); // REGLE METIER : Si le 1er jour est un VENDREDI (jour=5), on décale le début au SAMEDI. if (curr.getDay() === 5) { curr.setDate(curr.getDate() + 1); } while (curr < end) { const iso = this.getLocalIsoDate(curr); payload.push({ date: iso, type: "VACANCES_SCOLAIRES", duration: 1, person: r.description, // ASTUCE: On stocke le nom de la vacance ici ! }); curr.setDate(curr.getDate() + 1); } }); if (payload.length > 0) { // On envoie le gros lot à la base de données via l'API existante await this.postApi( "/modules/family-calendar/includes/api/save-events.php", payload, ); await this.refreshAllData(); // Recharge tout, ce qui mettra à jour la modale } else { alert( "Aucune donnée trouvée sur l'API du gouvernement pour cette année.", ); document.getElementById("btnFetchGovHolidays").innerText = "Réessayer"; document.getElementById("btnFetchGovHolidays").disabled = false; } } catch (e) { console.error("Erreur API", e); alert("Erreur lors de la connexion à l'API gouvernementale."); } } // ================================================================ // LE RESTE DU CODE (AFFICHAGE) RESTE INCHANGÉ MAIS OPTIMISÉ // ================================================================ reprocessAndRender() { this.reprocessEvents(); this.calculateMonthlyBalances(); this.initSummaryControls(); this.renderTable(); this.renderMonthCalendar(); } reprocessEvents() { this.weeks.forEach((w) => { Object.keys(w.totals).forEach((k) => (w.totals[k] = 0)); Object.values(w.dayFlags).forEach((f) => (f.events = [])); this.events.forEach((e) => { const d = new Date(e.date + "T00:00:00"); if (d >= w.dayDates.mon && d <= w.dayDates.fri) { const dayKey = Object.keys(w.dayDates).find( (k) => w.dayDates[k].getTime() === d.getTime(), ); if (dayKey) w.dayFlags[dayKey].events.push(e); const dur = parseFloat(e.duration) || 1; const typeMap = { OFF_CAROLE: "offCarole", EXTRA_OFF_CAROLE: "extraOffCarole", CENTRE: "centre", AVIS: "avis", PEP_SICK: "pepSick", }; if (typeMap[e.type]) w.totals[typeMap[e.type]] += dur; } }); this.leaves.forEach((l) => { const d = new Date(l.leave_date + "T00:00:00"); if (d >= w.dayDates.mon && d <= w.dayDates.fri) { const dur = parseFloat(l.duration) || 1; const prefix = l.person_id === FAMILY.ALEX.id ? FAMILY.ALEX.prefix : l.person_id === FAMILY.LAIA.id ? FAMILY.LAIA.prefix : null; if (prefix) w.totals[`${prefix}${l.leave_type}`] += dur; } }); let workingDays = 0; Object.values(w.dayDates).forEach((d) => { if (!this.publicHolidayDates.has(this.getLocalIsoDate(d))) workingDays++; }); w.totals.presencePep = Math.max( 0, workingDays - (w.totals.offCarole + w.totals.extraOffCarole + w.totals.pepSick), ); }); } calculateMonthlyBalances() { const balances = { [FAMILY.ALEX.id]: { CP: {}, JRA: {}, JA: {} }, [FAMILY.LAIA.id]: { CP: {}, JRA: {}, JA: {} }, }; // Liste des mois actuellement affichés dans le planning const ymSet = new Set(); this.weeks.forEach((w) => ymSet.add(w.monthKey)); const ymList = Array.from(ymSet).sort(); // Pré-calcul de l'utilisation par mois const usageByMonth = {}; this.leaves.forEach((l) => { const pid = l.person_id; const type = l.leave_type; const ym = l.leave_date.substring(0, 7); if (!usageByMonth[pid]) usageByMonth[pid] = {}; if (!usageByMonth[pid][type]) usageByMonth[pid][type] = {}; usageByMonth[pid][type][ym] = (usageByMonth[pid][type][ym] || 0) + parseFloat(l.duration); }); [FAMILY.ALEX.id, FAMILY.LAIA.id].forEach((pid) => { ["CP", "JRA", "JA"].forEach((type) => { ymList.forEach((ym) => { const [currYear, currMonth] = ym.split("-").map(Number); let cycleStartStr = ""; let initialBalance = 0; // --- 1. RECHERCHE D'UN CORRECTIF MANUEL (SNAPSHOT) --- // On cherche le snapshot le plus récent qui est inférieur ou égal au mois en cours de calcul const latestSnapshot = (this.leaveSnapshots || []) .filter( (s) => s.person_id == pid && s.leave_type == type && s.snapshot_date.substring(0, 7) <= ym, ) // On trie du plus récent au plus ancien pour prendre le premier .sort((a, b) => b.snapshot_date.localeCompare(a.snapshot_date), )[0]; if (latestSnapshot) { // Si on trouve un correctif, il devient notre nouveau "point zéro" cycleStartStr = latestSnapshot.snapshot_date.substring(0, 7); initialBalance = parseFloat(latestSnapshot.remaining_balance); // Utilisation de VOTRE nom de colonne } // --- 2. SINON, CALCUL CLASSIQUE PAR DÉFAUT --- else { if (type === "CP") { const refYear = currMonth >= LEAVES_CONFIG.CP.startMonth ? currYear : currYear - 1; cycleStartStr = `${refYear}-${String(LEAVES_CONFIG.CP.startMonth).padStart(2, "0")}`; const dbBal = this.leaveBalances.find( (b) => b.person_id == pid && b.leave_type == "CP" && b.balance_year == refYear, ); initialBalance = dbBal ? parseFloat(dbBal.initial_balance) : LEAVES_CONFIG.CP.defaultBalance; } else if (type === "JRA") { cycleStartStr = `${currYear}-01`; initialBalance = LEAVES_CONFIG.JRA.yearlyTotals[currYear] || LEAVES_CONFIG.JRA.defaultBalance; // Logique de report du reliquat de l'année N-1 if (currMonth <= LEAVES_CONFIG.JRA.toleranceMonths) { const prevYear = currYear - 1; const prevInitial = LEAVES_CONFIG.JRA.yearlyTotals[prevYear] || LEAVES_CONFIG.JRA.defaultBalance; let usedPrevYear = 0; for (let m = 1; m <= 12; m++) { const mStr = `${prevYear}-${String(m).padStart(2, "0")}`; usedPrevYear += usageByMonth[pid]?.[type]?.[mStr] || 0; } const remainingPrevYear = Math.max( 0, prevInitial - usedPrevYear, ); // Ajout du report plafonné à 2 jours initialBalance += Math.min( remainingPrevYear, LEAVES_CONFIG.JRA.maxReport, ); console.log( `[JRA] Personne ${pid}, Année ${currYear}: Report de ${Math.min(remainingPrevYear, LEAVES_CONFIG.JRA.maxReport)}j inclus.`, ); } } else if (type === "JA") { const configJA = LEAVES_CONFIG.JA[pid]; const isPastAnniversary = currMonth > configJA.startMonth || (currMonth === configJA.startMonth && configJA.startDay === 1); const refYear = isPastAnniversary ? currYear : currYear - 1; cycleStartStr = `${refYear}-${String(configJA.startMonth).padStart(2, "0")}`; const dbBal = this.leaveBalances.find( (b) => b.person_id == pid && b.leave_type == "JA" && b.balance_year == refYear, ); initialBalance = dbBal ? parseFloat(dbBal.initial_balance) : configJA.defaultBalance; } } // --- 3. DÉDUCTION DES CONGÉS PRIS DEPUIS LE POINT ZÉRO --- let usedBeforeCurrentMonth = 0; Object.keys(usageByMonth[pid]?.[type] || {}).forEach((usedYm) => { // On ne déduit que ce qui a été posé entre le début du cycle (ou la date du snapshot) et le mois en cours if (usedYm >= cycleStartStr && usedYm < ym) { usedBeforeCurrentMonth += usageByMonth[pid][type][usedYm]; } }); const available = Math.max( 0, initialBalance - usedBeforeCurrentMonth, ); const usedInMonth = usageByMonth[pid]?.[type]?.[ym] || 0; balances[pid][type][ym] = { availableAtMonthStart: available, usedInMonth: usedInMonth, }; }); }); }); this.monthlyLeaveBalances = balances; } renderTable() { if (!this.planningBody) return; this.planningBody.innerHTML = ""; const monthSpans = this.weeks.reduce((acc, w) => { acc[w.monthKey] = (acc[w.monthKey] || 0) + 1; return acc; }, {}); const processedMonths = {}; const processedLeavesCols = {}; const fmt = (n) => n > 0 ? (Number.isInteger(n) ? n : n.toFixed(1)) : ""; this.weeks.forEach((w, idx) => { const tr = document.createElement("tr"); if (idx === 0 || this.weeks[idx - 1].monthKey !== w.monthKey) tr.classList.add("fc-month-first-week-row"); if ( idx === this.weeks.length - 1 || this.weeks[idx + 1].monthKey !== w.monthKey ) tr.classList.add("fc-month-last-week-row"); if (!processedMonths[w.monthKey]) { processedMonths[w.monthKey] = true; const td = document.createElement("td"); td.className = "col-month col-sticky-mois"; td.textContent = w.monthName; td.rowSpan = monthSpans[w.monthKey]; tr.appendChild(td); } const tdW = document.createElement("td"); tdW.className = "col-month col-sticky-sem"; tdW.textContent = w.weekLabel; tr.appendChild(tdW); ["mon", "tue", "wed", "thu", "fri"].forEach((d) => { const td = document.createElement("td"); const dateObj = w.dayDates[d]; const iso = this.getLocalIsoDate(dateObj); td.dataset.date = iso; td.textContent = String(dateObj.getDate()).padStart(2, "0"); td.className = "col-day"; w.dayFlags[d].events.forEach((evt) => { if (evt.type === "OFF_CAROLE") td.classList.add("fc-day--off-carole"); if (evt.type === "EXTRA_OFF_CAROLE") td.classList.add("fc-day--extra-off-carole"); if (evt.type === "PUBLIC_HOLIDAY") td.classList.add("fc-day--public-holiday"); if (evt.type === "VACANCES_SCOLAIRES") td.classList.add("fc-day--school-holiday"); if (evt.type === "CENTRE") td.classList.add("fc-day--centre"); if (evt.type === "AVIS") td.classList.add("fc-day--avis"); if (evt.type === "PEP_SICK") td.innerHTML += `🤒`; }); const dayLeaves = this.leaves.filter((l) => l.leave_date === iso); if (dayLeaves.length) { let html = `
`; if (dayLeaves.some((l) => l.person_id === window.CONFIG.ID_ALEX)) html += `A`; if (dayLeaves.some((l) => l.person_id === window.CONFIG.ID_LAIA)) html += `L`; html += `
`; td.innerHTML += html; } tr.appendChild(td); }); [ "offCarole", "extraOffCarole", "centre", "avis", "pepSick", "presencePep", ].forEach((k) => { const td = document.createElement("td"); td.className = "col-total"; td.textContent = fmt(w.totals[k]); tr.appendChild(td); }); if (!processedLeavesCols[w.monthKey]) { processedLeavesCols[w.monthKey] = true; const span = monthSpans[w.monthKey]; const ym = w.monthKey; const renderPersonCols = (pid, prefix) => { ["CP", "JRA", "JA"].forEach((type) => { const info = this.monthlyLeaveBalances[pid][type][ym]; const tdAv = document.createElement("td"); tdAv.className = `${prefix}-sub ${prefix}-av`; tdAv.rowSpan = span; tdAv.textContent = info ? fmt(info.availableAtMonthStart) : "-"; tr.appendChild(tdAv); const tdUse = document.createElement("td"); tdUse.className = `${prefix}-sub ${prefix}-use`; tdUse.rowSpan = span; tdUse.textContent = info ? fmt(info.usedInMonth) : ""; tr.appendChild(tdUse); }); }; renderPersonCols(FAMILY.ALEX.id, `col-${FAMILY.ALEX.prefix}`); renderPersonCols(FAMILY.LAIA.id, `col-${FAMILY.LAIA.prefix}`); } this.planningBody.appendChild(tr); }); } renderMonthCalendar() { if (!this.monthCalendar) return; this.monthCalendar.innerHTML = ""; const y = this.currentMonth.getFullYear(); const m = this.currentMonth.getMonth(); const lang = window.I18N_LANG || "fr-FR"; const titleEl = document.querySelector("#fc-current-month-year"); if (titleEl) { if (this.viewMode === "3months") { const nextM2 = new Date(y, m + 2, 1); const m1 = new Intl.DateTimeFormat(lang, { month: "short" }).format( this.currentMonth, ); const m2 = new Intl.DateTimeFormat(lang, { month: "short", year: "numeric", }).format(nextM2); titleEl.textContent = `${m1} - ${m2}`; this.renderThreeMonthsView(); } else if (this.viewMode === "2months") { const nextM = new Date(y, m + 1, 1); const m1 = new Intl.DateTimeFormat(lang, { month: "short" }).format( this.currentMonth, ); const m2 = new Intl.DateTimeFormat(lang, { month: "short", year: "numeric", }).format(nextM); titleEl.textContent = `${m1} - ${m2}`; this.renderTwoMonthsView(); } else { titleEl.textContent = new Intl.DateTimeFormat(lang, { month: "long", year: "numeric", }).format(this.currentMonth); this.monthCalendar.innerHTML = this.generateMonthHTML(y, m); } } this.renderMonthBalances(); this.syncSummaryWithMonth(); } renderTwoMonthsView() { const y = this.currentMonth.getFullYear(); const m = this.currentMonth.getMonth(); const nextDate = new Date(y, m + 1, 1); const lang = window.I18N_LANG || "fr-FR"; let html = `
`; html += `
${new Intl.DateTimeFormat(lang, { month: "long" }).format(this.currentMonth)}
${this.generateMonthHTML(y, m)}
`; html += `
${new Intl.DateTimeFormat(lang, { month: "long" }).format(nextDate)}
${this.generateMonthHTML(nextDate.getFullYear(), nextDate.getMonth())}
`; html += `
`; this.monthCalendar.innerHTML = html; } renderThreeMonthsView() { const y = this.currentMonth.getFullYear(); const m = this.currentMonth.getMonth(); const d1 = this.currentMonth; const d2 = new Date(y, m + 1, 1); const d3 = new Date(y, m + 2, 1); let html = `
`; [d1, d2, d3].forEach((d) => { html += `
${new Intl.DateTimeFormat("fr-FR", { month: "long" }).format(d)}
${this.generateMonthHTML(d.getFullYear(), d.getMonth())}
`; }); html += `
`; this.monthCalendar.innerHTML = html; } renderMonthBalances() { const container = document.getElementById("fc-month-balances"); if (!container) return; const monthsToDisplay = []; const y = this.currentMonth.getFullYear(); const m = this.currentMonth.getMonth(); let numMonths = this.viewMode === "2months" ? 2 : this.viewMode === "3months" ? 3 : 1; for (let i = 0; i < numMonths; i++) { const d = new Date(y, m + i, 1); monthsToDisplay.push( `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`, ); } container.style.display = "flex"; let html = ""; [FAMILY.ALEX, FAMILY.LAIA].forEach((person) => { html += `
${person.prefix.toUpperCase()}
`; ["CP", "JRA", "JA"].forEach((type) => { const startInfo = this.monthlyLeaveBalances[person.id]?.[type]?.[monthsToDisplay[0]]; const startBal = startInfo ? startInfo.availableAtMonthStart : 0; let totalUsed = 0; monthsToDisplay.forEach((ym) => { const info = this.monthlyLeaveBalances[person.id]?.[type]?.[ym]; if (info) totalUsed += info.usedInMonth; }); const endBal = Math.max(0, startBal - totalUsed); const fmt = (n) => n > 0 ? (Number.isInteger(n) ? n : n.toFixed(1)) : "0"; const usedHtml = totalUsed > 0 ? `-${fmt(totalUsed)}` : ""; // --- LOGIQUE D'ALERTE FIRE 🔥 --- let alertHtml = ""; const currentYm = monthsToDisplay[0]; const [cYear, cMonth] = currentYm.split("-").map(Number); if (endBal > 0) { if (type === "CP" && cMonth >= 6 && cMonth <= 7) { // CP : Alerte en Juin et Juillet uniquement let msg = ( window.I18N["fc_alert_burn_days"] || "Perte: %s j avant le %s" ) .replace("%s", fmt(endBal)) .replace("%s", "31/07"); alertHtml = `
🔥
`; } else if (type === "JRA" && (cMonth === 1 || cMonth === 2)) { // JRA : Alerte Janvier/Février si > 2 jours if (endBal > 2) { let msg = window.I18N["fc_alert_burn_jra"] || "Seuls 2j reportables"; alertHtml = `
🔥 ${fmt(endBal - 2)}
`; } } else if (type === "JA") { const configJA = LEAVES_CONFIG.JA[person.id]; let limitMonth = configJA.startMonth - 1 || 12; if (cMonth === limitMonth || cMonth === configJA.startMonth) { let msg = (window.I18N["fc_alert_burn_days"] || "Perte: %s j") .replace("%s", fmt(endBal)) .replace("%s", window.I18N["ANNIV"] || "Anniv"); alertHtml = `
🔥
`; } } } html += `
${type} ${fmt(endBal)} ${usedHtml} ${alertHtml}
`; }); html += `
`; }); container.innerHTML = html; } syncSummaryWithMonth() { const typeSelect = document.getElementById("summType"); const valueSelect = document.getElementById("summValue"); if (typeSelect && valueSelect) { // Le récap se synchronise avec le PREMIER mois affiché de la période const ym = `${this.currentMonth.getFullYear()}-${String(this.currentMonth.getMonth() + 1).padStart(2, "0")}`; if (typeSelect.value !== "month") { typeSelect.value = "month"; typeSelect.dispatchEvent(new Event("change")); } valueSelect.value = ym; this.updateGlobalSummary(); } } generateMonthHTML(year, month) { let html = ``; ["L", "M", "M", "J", "V"].forEach((d) => (html += ``)); html += ``; const daysInMonth = new Date(year, month + 1, 0).getDate(); let currentRenderedCols = 0; const firstDay = new Date(year, month, 1); let startDay = (firstDay.getDay() + 6) % 7; if (startDay < 5) { for (let i = 0; i < startDay; i++) { html += ``; currentRenderedCols++; } } for (let dayCounter = 1; dayCounter <= daysInMonth; dayCounter++) { const dateObj = new Date(year, month, dayCounter); const dayOfWeek = dateObj.getDay(); if (dayOfWeek === 0 || dayOfWeek === 6) continue; if (currentRenderedCols === 5) { html += ``; currentRenderedCols = 0; } const iso = this.getLocalIsoDate(dateObj); let cls = "fc-month-day"; const dayEvts = this.events.filter((e) => e.date === iso); if (dayEvts.some((e) => e.type === "VACANCES_SCOLAIRES")) cls += " fc-day--school-holiday"; if (dayEvts.some((e) => e.type === "PUBLIC_HOLIDAY")) cls += " fc-day--public-holiday"; if (dayEvts.some((e) => e.type === "OFF_CAROLE")) cls += " fc-day--off-carole"; if (dayEvts.some((e) => e.type === "EXTRA_OFF_CAROLE")) cls += " fc-day--extra-off-carole"; if (dayEvts.some((e) => ["CENTRE", "AVIS"].includes(e.type))) { cls += " fc-day--has-guard"; if (dayEvts.some((e) => e.type === "CENTRE")) cls += " fc-day--centre"; if (dayEvts.some((e) => e.type === "AVIS")) cls += " fc-day--avis"; } let content = `
${dayCounter}`; if (dayEvts.some((e) => e.type === "PEP_SICK")) content += `🤒`; const dayLeaves = this.leaves.filter((l) => l.leave_date === iso); if (dayLeaves.length) { content += `
`; if (dayLeaves.some((l) => l.person_id === 2)) content += `A `; if (dayLeaves.some((l) => l.person_id === 3)) content += `L`; content += `
`; } content += `
`; html += ``; currentRenderedCols++; } while (currentRenderedCols < 5 && currentRenderedCols > 0) { html += ``; currentRenderedCols++; } html += `
${d}
${content}
`; return html; } initSummaryControls() { const typeSelect = document.getElementById("summType"); const valueSelect = document.getElementById("summValue"); if (!typeSelect || !valueSelect) return; const years = new Set(); const months = new Set(); this.weeks.forEach((w) => { Object.values(w.dayDates).forEach((d) => { years.add(d.getFullYear()); const mKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; months.add(mKey); }); }); const sortedYears = Array.from(years).sort(); const sortedMonths = Array.from(months).sort(); const populateValues = () => { const currentType = typeSelect.value; const previousValue = valueSelect.value; valueSelect.innerHTML = ""; if (currentType === "year") { sortedYears.forEach((y) => { const opt = document.createElement("option"); opt.value = y; opt.textContent = y; valueSelect.appendChild(opt); }); if (sortedYears.includes(parseInt(previousValue))) { valueSelect.value = previousValue; } else { const currentYear = new Date().getFullYear(); if (sortedYears.includes(currentYear)) valueSelect.value = currentYear; } } else { sortedMonths.forEach((m) => { const [y, mo] = m.split("-"); const dateObj = new Date(y, mo - 1, 1); const label = new Intl.DateTimeFormat("fr-FR", { month: "long", year: "numeric", }).format(dateObj); const opt = document.createElement("option"); opt.value = m; opt.textContent = label; valueSelect.appendChild(opt); }); const now = new Date(); const nowIso = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; if (sortedMonths.includes(nowIso)) { valueSelect.value = nowIso; } } this.updateGlobalSummary(); }; if (!this.summaryListenersAttached) { typeSelect.addEventListener("change", populateValues); valueSelect.addEventListener("change", () => this.updateGlobalSummary(), ); this.summaryListenersAttached = true; } populateValues(); } updateGlobalSummary() { const div = document.getElementById("globalSummary"); const typeSelect = document.getElementById("summType"); const valueSelect = document.getElementById("summValue"); if (!div || (typeSelect && valueSelect && !valueSelect.value)) return; const filterType = typeSelect ? typeSelect.value : "year"; const filterValue = valueSelect ? valueSelect.value : new Date().getFullYear().toString(); const stats = { off: 0, extra: 0, sick: 0, pep: 0, totalWorking: 0 }; this.weeks.forEach((w) => { Object.entries(w.dayDates).forEach(([dayName, dateObj]) => { let match = false; if (filterType === "year") { if (dateObj.getFullYear().toString() === filterValue) match = true; } else { const isoMonth = this.getLocalIsoDate(dateObj).slice(0, 7); if (isoMonth === filterValue) match = true; } if (match) { const dayEvents = w.dayFlags[dayName].events; dayEvents.forEach((e) => { const dur = parseFloat(e.duration) || 1; if (e.type === "OFF_CAROLE") stats.off += dur; if (e.type === "EXTRA_OFF_CAROLE") stats.extra += dur; if (e.type === "PEP_SICK") stats.sick += dur; }); const dateIso = this.getLocalIsoDate(dateObj); if (!this.publicHolidayDates.has(dateIso)) { stats.totalWorking++; let dayAbsence = 0; dayEvents.forEach((e) => { if ( ["OFF_CAROLE", "EXTRA_OFF_CAROLE", "PEP_SICK"].includes( e.type, ) ) { dayAbsence += parseFloat(e.duration) || 1; } }); stats.pep += Math.max(0, 1 - dayAbsence); } } }); }); div.innerHTML = `
${tr("leg_off_carole")}${parseFloat(stats.off.toFixed(1))} ${tr("fc_unit_days")}
${tr("leg_extra_off")}${parseFloat(stats.extra.toFixed(1))} ${tr("fc_unit_days")}
${tr("leg_pep_sick")}${parseFloat(stats.sick.toFixed(1))} ${tr("fc_unit_days")}
${tr("leg_presence")} Pep${parseFloat(stats.pep.toFixed(1))} ${tr("fc_unit_days")}
`; } updateSchoolYearLabel() { const lbl = document.getElementById("fc-current-school-year-label"); if (lbl) lbl.textContent = `${this.currentSchoolYearStart} – ${this.currentSchoolYearStart + 1}`; } processWeeks(rawWeeks) { return rawWeeks.map((w) => ({ id: `${w.week_iso_year}-W${w.week_iso_number}`, monthKey: `${w.year}-${String(w.month).padStart(2, "0")}`, monthName: w.month_name, weekLabel: w.week_label, dayDates: { mon: new Date(w.mon_date + "T00:00:00"), tue: new Date(w.tue_date + "T00:00:00"), wed: new Date(w.wed_date + "T00:00:00"), thu: new Date(w.thu_date + "T00:00:00"), fri: new Date(w.fri_date + "T00:00:00"), }, dayFlags: { mon: { events: [] }, tue: { events: [] }, wed: { events: [] }, thu: { events: [] }, fri: { events: [] }, }, totals: { offCarole: 0, extraOffCarole: 0, centre: 0, avis: 0, pepSick: 0, presencePep: 0, alexCP: 0, alexJRA: 0, alexJA: 0, laiaCP: 0, laiaJRA: 0, laiaJA: 0, }, })); } async fetchApi(url) { return pachaFetch(url); } async postApi(url, data) { return pachaFetch(url, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(data), }); } async changeSchoolYear(delta) { this.currentSchoolYearStart += delta; this.updateSchoolYearLabel(); await this.refreshAllData(); } setupEventListeners() { if (this.planningBody) { // Souris (PC) this.planningBody.addEventListener("mousedown", (e) => this.handleMouseDown(e), ); document.addEventListener("mousemove", (e) => this.handleMouseMove(e)); document.addEventListener("mouseup", (e) => this.handleMouseUp(e)); // Tactile (Mobile/Tablette) this.planningBody.addEventListener( "touchstart", (e) => this.handleTouchStart(e), { passive: false }, ); document.addEventListener("touchmove", (e) => this.handleTouchMove(e), { passive: false, }); document.addEventListener("touchend", (e) => this.handleTouchEnd(e)); } // --- GESTION MODALE VACANCES SCOLAIRES --- const btnOpen = document.getElementById("btnOpenHolidays"); const btnClose = document.getElementById("btnCloseHolidays"); const modal = document.getElementById("modalHolidays"); if (btnOpen && modal) { btnOpen.addEventListener("click", () => { modal.style.display = "flex"; // Remet l'année par défaut sur l'année actuellement affichée dans le calendrier const yearSelect = document.getElementById("holidayYearSelect"); if (yearSelect) yearSelect.value = this.currentSchoolYearStart; this.modalSelectedYear = this.currentSchoolYearStart; this.renderModalHolidays(); }); } if (btnClose && modal) btnClose.addEventListener( "click", () => (modal.style.display = "none"), ); if (modal) modal.addEventListener("click", (e) => { if (e.target === modal) modal.style.display = "none"; }); // --- GESTION MODALE CORRECTIF DES SOLDES (SNAPSHOTS) --- const btnSnap = document.getElementById("btnOpenSnapshotModal"); const modalSnap = document.getElementById("modalSnapshot"); const btnCloseSnap = document.getElementById("btnCloseSnapshot"); const formSnap = document.getElementById("formSnapshot"); if (btnSnap && modalSnap) { modalSnap.classList.add("is-open"); btnSnap.addEventListener("click", () => { modalSnap.classList.add("is-open"); // Utilise flex au lieu de block pour le centrage document.body.classList.add("no-scroll"); // Pré-remplir la date avec le 1er jour du mois en cours const today = new Date(); const y = today.getFullYear(); const m = String(today.getMonth() + 1).padStart(2, "0"); const snapDateInput = document.getElementById("snapDate"); if (snapDateInput) snapDateInput.value = `${y}-${m}-01`; }); } if (btnCloseSnap && modalSnap) { btnCloseSnap.addEventListener( "click", () => (modalSnap.style.display = "none"), ); } if (modalSnap) { modalSnap.addEventListener("click", (e) => { if (e.target === modalSnap) modalSnap.style.display = "none"; }); } if (formSnap) { formSnap.addEventListener("submit", async (e) => { e.preventDefault(); // Empêche le rechargement de la page const payload = { person_id: document.getElementById("snapPerson").value, leave_type: document.getElementById("snapType").value, snapshot_date: document.getElementById("snapDate").value, remaining_balance: document.getElementById("snapBalance").value, }; try { // On envoie à la nouvelle API await this.postApi( "/modules/family-calendar/includes/api/save-leave-snapshot.php", payload, ); modalSnap.style.display = "none"; formSnap.reset(); // On vide le formulaire // On rafraîchit tout le calendrier pour appliquer le nouveau solde immédiatement await this.refreshAllData(); } catch (error) { alert("Erreur lors de la sauvegarde du correctif."); } }); } // --- GESTION DU RESTE DE L'INTERFACE --- if (this.monthCalendar) { this.monthCalendar.addEventListener("click", (e) => { const td = e.target.closest("td[data-date]"); if (td && td.dataset.date) { this.monthSelectedCells = [td]; this.showMenu(e.pageX, e.pageY, [td.dataset.date], true); } }); } document.addEventListener("click", (e) => this.closeMenusIfOutside(e)); const handleMenu = (e) => { const btn = e.target.closest("button"); if (btn && btn.dataset.action) this.handleMenuAction(btn.dataset); }; if (this.selectionMenu) this.selectionMenu.addEventListener("click", handleMenu); if (this.monthSelectionMenu) this.monthSelectionMenu.addEventListener("click", handleMenu); document .getElementById("fc-prev-month") ?.addEventListener("click", () => { this.currentMonth.setMonth(this.currentMonth.getMonth() - 1); this.renderMonthCalendar(); }); document .getElementById("fc-next-month") ?.addEventListener("click", () => { this.currentMonth.setMonth(this.currentMonth.getMonth() + 1); this.renderMonthCalendar(); }); document .getElementById("fc-prev-school-year") ?.addEventListener("click", () => this.changeSchoolYear(-1)); document .getElementById("fc-next-school-year") ?.addEventListener("click", () => this.changeSchoolYear(1)); document.querySelectorAll(".fc-view-button").forEach((btn) => { btn.addEventListener("click", (e) => { document .querySelectorAll(".fc-view-button") .forEach((b) => b.classList.remove("fc-view-button--active")); e.target.classList.add("fc-view-button--active"); this.viewMode = e.target.dataset.view; this.renderMonthCalendar(); }); }); } handleMouseDown(e) { const td = e.target.closest("#planningTable td[data-date]"); if (!td) return; e.preventDefault(); this.clearSelection(); this.isSelecting = true; this.selectCell(td); } handleMouseMove(e) { if (!this.isSelecting) return; const td = e.target.closest("#planningTable td[data-date]"); if (td && !this.selectedCells.includes(td)) this.selectCell(td); } handleMouseUp(e) { if (!this.isSelecting) return; const td = e.target.closest("#planningTable td[data-date]"); if (td && !this.selectedCells.includes(td)) this.selectCell(td); this.isSelecting = false; if (!this.selectedCells.length) return; const dates = this.selectedCells.map((c) => c.dataset.date); this.showMenu(e.pageX, e.pageY, dates, false); } // --- GESTION DU TACTILE (Swipe pour sélectionner) --- handleTouchStart(e) { const td = e.target.closest("#planningTable td[data-date]"); if (!td) return; // Si l'utilisateur touche une case, on bloque le scroll de la page pour le laisser sélectionner e.preventDefault(); this.clearSelection(); this.isSelecting = true; this.selectCell(td); } handleTouchMove(e) { if (!this.isSelecting) return; e.preventDefault(); // Bloque le défilement de l'écran pendant qu'on glisse le doigt const touch = e.touches[0]; // elementFromPoint permet de savoir sur quelle case se trouve le doigt actuellement const target = document.elementFromPoint(touch.clientX, touch.clientY); if (!target) return; const td = target.closest("#planningTable td[data-date]"); if (td && !this.selectedCells.includes(td)) this.selectCell(td); } handleTouchEnd(e) { if (!this.isSelecting) return; this.isSelecting = false; if (!this.selectedCells.length) return; const dates = this.selectedCells.map((c) => c.dataset.date); // Sur mobile, on s'en fiche de X et Y car le menu s'affiche en bas de l'écran this.showMenu(0, 0, dates, false); } selectCell(cell) { cell.classList.add("fc-day--selected"); this.selectedCells.push(cell); } clearSelection() { this.selectedCells.forEach((c) => c.classList.remove("fc-day--selected")); this.selectedCells = []; if (this.selectionMenu) this.selectionMenu.style.display = "none"; if (this.monthSelectionMenu) this.monthSelectionMenu.style.display = "none"; } closeMenusIfOutside(e) { if ( !this.selectionMenu?.contains(e.target) && !this.monthSelectionMenu?.contains(e.target) && !this.menuJustOpened ) { this.clearSelection(); } } showMenu(x, y, dates, isMonthView) { const menu = isMonthView ? this.monthSelectionMenu : this.selectionMenu; if (!menu) return; this._currentBulkInfo = { dates }; // Gestion de la date affichée (Singulier/Pluriel) const dateLabel = dates.length > 1 ? `${dates.length} ${tr("fc_unit_days")}` : new Date(dates[0]).toLocaleDateString(window.I18N_LANG || "fr-FR"); // Icône SVG Poubelle (Uniformisée) const trashSvg = ``; // Fonction utilitaire interne pour les en-têtes de section avec suppression const buildHeader = (title, action, cat) => `
${title} ${action ? `` : ""}
`; // 1. En-tête du menu (Date ou nombre de jours) let html = `
${dateLabel}
`; // 2. Section Carole (Congés) html += `
${buildHeader(tr("fc_menu_carole"), "clear-type", "CONGE")}
`; // 3. Section Garde (Centre / Avis) html += `
${buildHeader(tr("leg_centre"), "clear-type", "GARDE")}
`; // 4. Section Pep (Maladie) html += `
${buildHeader("Pep", "clear-type", "PEP")}
`; // 5. Section Enfants (Alex & Laia) html += `
${buildHeader(tr("fc_menu_kids_leaves"), null, null)}
`; ["CP", "JRA", "JA"].forEach((t) => { html += ``; }); html += `
Alex
Laia
`; menu.innerHTML = html; // Positionnement (Gestion des bords d'écran) const menuWidth = 240; let left = x + 10; if (left + menuWidth > window.innerWidth) left = x - menuWidth - 10; menu.style.left = `${left}px`; menu.style.top = `${y + 10}px`; menu.style.display = "block"; this.menuJustOpened = true; setTimeout(() => (this.menuJustOpened = false), 100); } async handleMenuAction(dataset) { if (this.selectionMenu) this.selectionMenu.style.display = "none"; if (this.monthSelectionMenu) this.monthSelectionMenu.style.display = "none"; const { action, type, pid, cat } = dataset; const dates = this._currentBulkInfo.dates; try { if (action === "add") { let typesToClear = []; if (["OFF_CAROLE", "EXTRA_OFF_CAROLE"].includes(type)) typesToClear = CONGE_TYPES; if (["CENTRE", "AVIS"].includes(type)) typesToClear = GUARDE_TYPES; if (type === "PEP_SICK") typesToClear = PEP_TYPES; if (typesToClear.length) { await this.postApi( "/modules/family-calendar/includes/api/manage-event.php", { action: "bulk_delete_day_types", dates, types: typesToClear }, ); } const payload = dates.map((d) => ({ date: d, type: type, duration: 1, person: "Carole", })); await this.postApi( "/modules/family-calendar/includes/api/save-events.php", payload, ); } else if (action === "clear-type") { let typesToClear = []; if (cat === "CONGE") typesToClear = CONGE_TYPES; if (cat === "GARDE") typesToClear = GUARDE_TYPES; if (cat === "PEP") typesToClear = PEP_TYPES; await this.postApi( "/modules/family-calendar/includes/api/manage-event.php", { action: "bulk_delete_day_types", dates, types: typesToClear }, ); } else if (action === "add-leave") { await this.postApi( "/modules/family-calendar/includes/api/manage-leaf.php", { action: "bulk_delete_day_person", dates, person_id: parseInt(pid), }, ); const payload = dates.map((d) => ({ date: d, person_id: parseInt(pid), // On s'assure que c'est bien un format numérique leave_type: type, duration: 1, })); await this.postApi( "/modules/family-calendar/includes/api/save-leaves.php", payload, ); } else if (action === "clear-leaves-person") { await this.postApi( "/modules/family-calendar/includes/api/manage-leaf.php", { action: "bulk_delete_day_person", dates, person_id: parseInt(pid), }, ); } else if (action === "clear-leaves") { await this.postApi( "/modules/family-calendar/includes/api/manage-leaf.php", { action: "bulk_delete_day_person", dates, person_id: window.CONFIG.ID_ALEX, }, ); await this.postApi( "/modules/family-calendar/includes/api/manage-leaf.php", { action: "bulk_delete_day_person", dates, person_id: window.CONFIG.ID_LAIA, }, ); } await this.refreshAllData(); } catch (e) { alert("Erreur action: " + e.message); } } } new FamilyCalendar(); }); ``` --- ### 📄 Fichier : `modules/family-calendar/includes/api/get-calendar-weeks-scolaire.php` ```php 08/2026) * - défaut: année courante si non fourni */ $schoolYearStart = isset($_GET['school_year_start']) ? (int)$_GET['school_year_start'] : (int)date('Y'); if ($schoolYearStart < 2000 || $schoolYearStart > 2100) { http_response_code(400); echo json_encode(['status' => 'error', 'message' => 'Année scolaire invalide.']); exit; } $yearStart = $schoolYearStart; $yearEnd = $schoolYearStart + 1; // On veut toutes les semaines: // - de septembre (month >= 9) de yearStart // - à août (month <= 8) de yearEnd try { $sql = " SELECT year, week_iso_year, week_iso_number, week_label, month, month_name, week_start_date, mon_date, tue_date, wed_date, thu_date, fri_date FROM pf_calendar_weeks WHERE (year = :year_start AND month >= 9) OR (year = :year_end AND month <= 8) ORDER BY week_start_date ASC "; $stmt = $pdo->prepare($sql); $stmt->execute([ ':year_start' => $yearStart, ':year_end' => $yearEnd, ]); $weeks = $stmt->fetchAll(PDO::FETCH_ASSOC); echo json_encode([ 'status' => 'success', 'school_year' => $schoolYearStart, 'weeks' => $weeks, ]); } catch (PDOException $e) { http_response_code(500); echo json_encode([ 'status' => 'error', 'message' => $e->getMessage(), ]); } ``` --- ### 📄 Fichier : `modules/family-calendar/includes/api/get-calendar-weeks.php` ```php prepare(" SELECT year, week_iso_year, week_iso_number, week_label, month, month_name, week_start_date, mon_date, tue_date, wed_date, thu_date, fri_date FROM pf_calendar_weeks WHERE year = :year ORDER BY week_start_date ASC "); $stmt->execute([':year' => $year]); $weeks = $stmt->fetchAll(PDO::FETCH_ASSOC); echo json_encode(['weeks' => $weeks]); } catch (PDOException $e) { http_response_code(500); echo json_encode([ 'status' => 'error', 'message' => $e->getMessage(), ]); } ``` --- ### 📄 Fichier : `modules/family-calendar/includes/api/get-events.php` ```php query("SELECT id, event_date AS date, event_type AS type, person_id, duration FROM pf_events ORDER BY event_date"); $events = $stmt->fetchAll(PDO::FETCH_ASSOC); echo json_encode([ 'status' => 'success', 'events' => $events, ]); } catch (Exception $e) { http_response_code(500); echo json_encode([ 'status' => 'error', 'message' => 'Erreur lors de la récupération des événements : ' . $e->getMessage(), ]); } ``` --- ### 📄 Fichier : `modules/family-calendar/includes/api/get-leave-balances.php` ```php query(" SELECT person_id, leave_type, initial_balance, balance_year FROM pf_leave_balances "); $balances = $stmt->fetchAll(PDO::FETCH_ASSOC); echo json_encode(['balances' => $balances]); } catch (PDOException $e) { http_response_code(500); echo json_encode([ 'status' => 'error', 'message' => $e->getMessage(), ]); } ``` --- ### 📄 Fichier : `modules/family-calendar/includes/api/get-leave-snapshots.php` ```php query(" SELECT person_id, leave_type, snapshot_date, remaining_balance FROM pf_leave_snapshots ORDER BY snapshot_date ASC "); $snapshots = $stmt->fetchAll(PDO::FETCH_ASSOC); echo json_encode(['snapshots' => $snapshots]); } catch (PDOException $e) { http_response_code(500); echo json_encode([ 'status' => 'error', 'message' => $e->getMessage(), ]); } ``` --- ### 📄 Fichier : `modules/family-calendar/includes/api/get-leaves.php` ```php query(" SELECT l.id, l.person_id, p.name AS person_name, l.leave_type, l.leave_date, l.duration FROM pf_leaves l JOIN pf_people p ON p.id = l.person_id ORDER BY l.leave_date, l.person_id "); $leaves = $stmt->fetchAll(PDO::FETCH_ASSOC); echo json_encode([ 'status' => 'success', 'leaves' => $leaves, ]); } catch (PDOException $e) { http_response_code(500); echo json_encode(['status' => 'error', 'message' => $e->getMessage()]); } ``` --- ### 📄 Fichier : `modules/family-calendar/includes/api/get-person-leave-meta.php` ```php query(" SELECT person_id, anniversary_date FROM pf_person_leave_meta "); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); echo json_encode([ 'meta' => $rows, ]); } catch (Exception $e) { http_response_code(500); echo json_encode([ 'error' => true, 'message' => $e->getMessage(), ]); } ``` --- ### 📄 Fichier : `modules/family-calendar/includes/api/manage-event.php` ```php 'error', 'message' => 'Action manquante.']); exit; } try { // --- SUPPRESSION UNITAIRE --- if ($action === 'delete') { $eventId = (int)($input['event_id'] ?? 0); if ($eventId <= 0) { http_response_code(400); echo json_encode(['status' => 'error', 'message' => 'ID manquant.']); exit; } $stmt = $pdo->prepare("DELETE FROM pf_events WHERE id = ?"); $stmt->execute([$eventId]); echo json_encode(['status' => 'success']); exit; } // --- MISE À JOUR UNITAIRE --- if ($action === 'update') { $eventId = (int)($input['event_id'] ?? 0); $newType = $input['new_type'] ?? ''; if ($eventId <= 0 || !$newType) { http_response_code(400); echo json_encode(['status' => 'error', 'message' => 'Données manquantes.']); exit; } $stmt = $pdo->prepare("UPDATE pf_events SET event_type = ? WHERE id = ?"); $stmt->execute([$newType, $eventId]); echo json_encode(['status' => 'success']); exit; } // --- SUPPRESSION DE MASSE (Par date et type) --- // Utilisé quand on ajoute un événement pour nettoyer les doublons potentiels (ex: Off vs Extra) if ($action === 'bulk_delete_day_types') { $dates = $input['dates'] ?? []; $types = $input['types'] ?? []; $dates = array_filter($dates, function($d) { return preg_match('/^\d{4}-\d{2}-\d{2}$/', $d); }); if (empty($dates) || empty($types)) { echo json_encode(['status' => 'success']); exit; } // Création des placeholders IN (?,?,?) $datePlaceholders = implode(',', array_fill(0, count($dates), '?')); $typePlaceholders = implode(',', array_fill(0, count($types), '?')); $sql = "DELETE FROM pf_events WHERE event_date IN ($datePlaceholders) AND event_type IN ($typePlaceholders)"; $stmt = $pdo->prepare($sql); // Fusion des tableaux pour l'exécution $stmt->execute(array_merge($dates, $types)); echo json_encode(['status' => 'success']); exit; } // --- SUPPRESSION TOTALE SUR DES DATES --- if ($action === 'bulk_delete_all') { $dates = $input['dates'] ?? []; $dates = array_filter($dates, function($d) { return preg_match('/^\d{4}-\d{2}-\d{2}$/', $d); }); if (empty($dates) || empty($types)) { echo json_encode(['status' => 'success']); exit; } $datePlaceholders = implode(',', array_fill(0, count($dates), '?')); $sql = "DELETE FROM pf_events WHERE event_date IN ($datePlaceholders)"; $stmt = $pdo->prepare($sql); $stmt->execute($dates); echo json_encode(['status' => 'success']); exit; } // Action inconnue http_response_code(400); echo json_encode(['status' => 'error', 'message' => 'Action non reconnue : ' . $action]); } catch (PDOException $e) { http_response_code(500); echo json_encode(['status' => 'error', 'message' => $e->getMessage()]); } ``` --- ### 📄 Fichier : `modules/family-calendar/includes/api/manage-leaf.php` ```php 'error', 'message' => 'JSON invalide.']); exit; } $action = $input['action'] ?? null; if (!$action) { http_response_code(400); echo json_encode(['status' => 'error', 'message' => 'Action manquante.']); exit; } try { // ===================== DELETE TOUS LES LEAVES D'UN JOUR (EXISTANT) ===================== if ($action === 'delete_day') { if (empty($input['date'])) { http_response_code(400); echo json_encode(['status' => 'error', 'message' => 'Date manquante pour delete_day.']); exit; } $dateObj = date_create($input['date']); if (!$dateObj) { http_response_code(400); echo json_encode(['status' => 'error', 'message' => 'Date invalide.']); exit; } $leaveDate = $dateObj->format('Y-m-d'); $stmt = $pdo->prepare("DELETE FROM pf_leaves WHERE leave_date = ?"); $stmt->execute([$leaveDate]); echo json_encode(['status' => 'success', 'message' => 'Congés supprimés pour ce jour.']); exit; } // ===================== DELETE POUR UNE PERSONNE / JOUR (SINGLE) ===================== if ($action === 'delete_day_person') { $date = $input['date'] ?? null; $personId = $input['person_id'] ?? null; if (empty($date) || empty($personId)) { http_response_code(400); echo json_encode(['status' => 'error', 'message' => 'date ou person_id manquant pour delete_day_person.']); exit; } $dateObj = date_create($date); if (!$dateObj) { http_response_code(400); echo json_encode(['status' => 'error', 'message' => 'Date invalide pour delete_day_person.']); exit; } $leaveDate = $dateObj->format('Y-m-d'); $stmt = $pdo->prepare(" DELETE FROM pf_leaves WHERE leave_date = :date AND person_id = :person_id "); $stmt->execute([ ':date' => $leaveDate, ':person_id' => $personId, ]); echo json_encode([ 'status' => 'success', 'message' => 'Congés supprimés pour cette personne et ce jour.', 'deleted' => $stmt->rowCount(), ]); exit; } // ===================== BULK DELETE POUR UNE PERSONNE / PLUSIEURS JOURS ===================== if ($action === 'bulk_delete_day_person') { $dates = $input['dates'] ?? null; $personId = $input['person_id'] ?? null; if (!is_array($dates) || empty($dates) || empty($personId)) { http_response_code(400); echo json_encode(['status' => 'error', 'message' => 'dates ou person_id manquant pour bulk_delete_day_person.']); exit; } // Normaliser les dates en 'Y-m-d' $normalizedDates = []; foreach ($dates as $d) { $dateObj = date_create($d); if ($dateObj) { $normalizedDates[] = $dateObj->format('Y-m-d'); } } if (empty($normalizedDates)) { echo json_encode([ 'status' => 'success', 'message' => 'Aucune date valide fournie.', 'deleted' => 0, ]); exit; } // Construire le IN (...) $placeholders = implode(',', array_fill(0, count($normalizedDates), '?')); $sql = " DELETE FROM pf_leaves WHERE person_id = ? AND leave_date IN ($placeholders) "; $stmt = $pdo->prepare($sql); $params = array_merge([$personId], $normalizedDates); $stmt->execute($params); echo json_encode([ 'status' => 'success', 'message' => 'Congés supprimés pour cette personne sur les dates fournies.', 'deleted' => $stmt->rowCount(), ]); exit; } // ===================== ACTION NON RECONNUE ===================== http_response_code(400); echo json_encode(['status' => 'error', 'message' => 'Action non valide.']); } catch (PDOException $e) { http_response_code(500); echo json_encode(['status' => 'error', 'message' => $e->getMessage()]); } ``` --- ### 📄 Fichier : `modules/family-calendar/includes/api/save-events.php` ```php 'error', 'message' => 'Aucune donnée d\'événement reçue.']); exit; } $inserted = []; try { // 1. OPTIMISATION : On récupère toutes les personnes d'un coup (Mapping) $stmtPeople = $pdo->query("SELECT id, name FROM pf_people"); $peopleMap = []; while ($row = $stmtPeople->fetch(PDO::FETCH_ASSOC)) { // On crée un tableau associatif : ['Carole' => 1, 'Alex' => 2, etc.] $peopleMap[$row['name']] = $row['id']; } $pdo->beginTransaction(); $sql = "INSERT INTO pf_events (event_date, event_type, person_id, duration) VALUES (:event_date, :event_type, :person_id, :duration)"; $stmt = $pdo->prepare($sql); foreach ($eventsToSave as $event) { $person_id = null; // 2. On vérifie simplement dans notre tableau (plus de requête SQL ici !) if (!empty($event['person']) && isset($peopleMap[$event['person']])) { $person_id = $peopleMap[$event['person']]; } $stmt->execute([ ':event_date' => $event['date'], ':event_type' => $event['type'], ':person_id' => $person_id, ':duration' => $event['duration'] ?? 1.0, ]); $inserted[] = [ 'id' => $pdo->lastInsertId(), 'date' => $event['date'], 'type' => $event['type'], 'duration' => $event['duration'] ?? 1.0, 'person_id' => $person_id, ]; } $pdo->commit(); echo json_encode([ 'status' => 'success', 'inserted' => $inserted, ]); } catch (Exception $e) { $pdo->rollBack(); http_response_code(500); echo json_encode(['status' => 'error', 'message' => 'Erreur lors de la sauvegarde : ' . $e->getMessage()]); } ``` --- ### 📄 Fichier : `modules/family-calendar/includes/api/save-leave-snapshot.php` ```php 'error', 'message' => 'Données manquantes.']); exit; } try { // Étape 1 : Par sécurité, on supprime un éventuel snapshot existant exactement à la même date pour cette personne et ce type $stmtDel = $pdo->prepare("DELETE FROM pf_leave_snapshots WHERE person_id = ? AND leave_type = ? AND snapshot_date = ?"); $stmtDel->execute([$input['person_id'], $input['leave_type'], $input['snapshot_date']]); // Étape 2 : On insère le nouveau solde $stmt = $pdo->prepare("INSERT INTO pf_leave_snapshots (person_id, leave_type, snapshot_date, remaining_balance) VALUES (?, ?, ?, ?)"); $stmt->execute([ $input['person_id'], $input['leave_type'], $input['snapshot_date'], $input['remaining_balance'] ]); echo json_encode(['status' => 'success']); } catch (PDOException $e) { http_response_code(500); echo json_encode(['status' => 'error', 'message' => $e->getMessage()]); } ``` --- ### 📄 Fichier : `modules/family-calendar/includes/api/save-leaves.php` ```php 'error', 'message' => 'Données invalides (attendu tableau JSON).']); exit; } // Exemple d'élément attendu : // { date: '2025-09-01', person_id: 2, leave_type: 'CP', duration: 1.0 } $validLeaveTypes = ['CP', 'JRA', 'JA']; try { $pdo->beginTransaction(); $stmtInsert = $pdo->prepare(" INSERT INTO pf_leaves (person_id, leave_type, leave_date, duration) VALUES (:person_id, :leave_type, :leave_date, :duration) "); foreach ($input as $item) { $date = $item['date'] ?? null; $personId = $item['person_id'] ?? null; $leaveType = $item['leave_type'] ?? null; $duration = isset($item['duration']) ? (float)$item['duration'] : 1.0; if (!$date || !$personId || !$leaveType) { continue; // on ignore les lignes incomplètes } if (!in_array($leaveType, $validLeaveTypes, true)) { continue; // leave_type invalide } // Normalisation de la date $dateObj = date_create($date); if (!$dateObj) { continue; } $leaveDate = $dateObj->format('Y-m-d'); $stmtInsert->execute([ ':person_id' => (int)$personId, ':leave_type' => $leaveType, ':leave_date' => $leaveDate, ':duration' => $duration, ]); } $pdo->commit(); echo json_encode([ 'status' => 'success', 'message' => 'Congés enregistrés.', ]); } catch (PDOException $e) { $pdo->rollBack(); http_response_code(500); echo json_encode(['status' => 'error', 'message' => $e->getMessage()]); } ``` --- ### 📄 Fichier : `modules/gift-list/gift-list.css` ```css /* modules/gift-list/gift-list.css */ .pf-gift-list h1 { font-size: 1.6rem; font-weight: 700; margin: 0; } .cl-titlebar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 2rem; padding-bottom: 1rem; border-bottom: 1px solid var(--pf-border); } .cl-view-switch { display: flex; background: white; padding: 4px; border-radius: 8px; border: 1px solid var(--pf-border); } .cl-view-btn { padding: 6px 16px; border-radius: 6px; text-decoration: none; font-size: 0.85rem; font-weight: 600; color: var(--pf-text-muted); } .cl-view-btn.is-active { background: #334155; color: white; } .cl-occasion-title { font-size: 1.2rem; font-weight: 700; margin-bottom: 16px; display: flex; align-items: center; gap: 10px; text-transform: uppercase; border-bottom: 2px solid var(--pf-border); padding-bottom: 8px; } /* Grilles intelligentes */ .cl-occasion-children-tables { display: grid; gap: 20px; align-items: start; } .cl-view-nadal .cl-occasion-children-tables { grid-template-columns: repeat(3, 1fr); } .cl-view-anniversary .cl-occasion-children-tables { grid-template-columns: repeat(6, 1fr); } .cl-view-anniversary .child-pol, .cl-view-anniversary .child-pep { grid-column: span 3; } .cl-view-anniversary .child-elna, .cl-view-anniversary .child-bru, .cl-view-anniversary .child-guim { grid-column: span 2; } /* Tableaux par enfant */ .cl-child-table { background: white; box-shadow: var(--pf-shadow-sm); border-radius: 0 0 var(--pf-radius-md) var(--pf-radius-md); border: 1px solid var(--pf-border); border-top: none; width: 100%; table-layout: fixed; border-collapse: separate; border-spacing: 0; } .cl-child-table caption { padding: 10px 16px; text-align: left; font-weight: 700; font-size: 1.1rem; border-radius: var(--pf-radius-md) var(--pf-radius-md) 0 0; border: 1px solid var(--pf-border); border-bottom: 2px solid; position: relative; } .cl-child-add-btn { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); background: rgba(255, 255, 255, 0.6); border: 1px solid rgba(0, 0, 0, 0.1); width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: 0.4s; } .cl-child-add-btn:hover { background: white; transform: translateY(-50%) rotate(90deg) scale(1.1); box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15); } /* Thèmes Enfants */ .child-pol { border-color: #bcd3ff; } .child-pol caption { background: #eaf2ff; color: #1e3a8a; border-bottom-color: #93c5fd; } .child-pol thead th { background: #f5f9ff; } .child-pep { border-color: #b9e3b9; } .child-pep caption { background: #eaf7ea; color: #14532d; border-bottom-color: #86efac; } .child-pep thead th { background: #f6fdf6; } .child-elna { border-color: #f3bfd7; } .child-elna caption { background: #fdeaf3; color: #831843; border-bottom-color: #f9a8d4; } .child-elna thead th { background: #fff5f9; } .child-bru { border-color: #ffd0a8; } .child-bru caption { background: #fff3e6; color: #7c2d12; border-bottom-color: #fdba74; } .child-bru thead th { background: #fffaf5; } .child-guim { border-color: #d3c6ff; } .child-guim caption { background: #f0eaff; color: #4c1d95; border-bottom-color: #c4b5fd; } .child-guim thead th { background: #fbf9ff; } /* Table interne */ .cl-child-table thead th { padding: 8px; font-size: 0.75rem; color: var(--pf-text-muted); text-transform: uppercase; border-bottom: 1px solid var(--pf-border); border-right: 1px solid var(--pf-border); } .cl-child-table tbody td { padding: 8px; border-bottom: 1px solid var(--pf-bg-lighter); border-right: 1px solid var(--pf-bg-lighter); font-size: 0.85rem; } .cl-summary-adult-total { font-size: 0.7rem; color: var(--pf-primary); background: rgba(37, 99, 235, 0.1); padding: 1px 4px; border-radius: 4px; } /* Actions Cadeaux au Survol */ .cl-gift-line { display: flex; justify-content: space-between; position: relative; min-height: 20px; } .cl-gift-desc { font-size: 0.85rem; font-weight: 500; word-break: break-word; flex: 1; } .cl-gift-amount { font-size: 0.75rem; font-weight: 700; color: var(--pf-success); background: #ecfdf5; padding: 1px 4px; border-radius: 4px; transition: 0.2s; opacity: 1; } .cl-gift-actions { display: flex; gap: 4px; position: absolute; right: 0; top: 0; opacity: 0; pointer-events: none; transition: 0.2s; } .cl-gift-line:hover .cl-gift-amount { opacity: 0; } .cl-gift-line:hover .cl-gift-actions { opacity: 1; pointer-events: auto; } .cl-gift-action-btn { background: var(--pf-bg-lighter); border: 1px solid var(--pf-border); width: 26px; height: 26px; border-radius: 6px; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: 0.2s; } .cl-gift-action-btn:hover { background: white; border-color: var(--pf-text-main); } /* Matrice Tricount */ .cl-budget-wrapper { overflow-x: auto; background: white; border-radius: var(--pf-radius-md); box-shadow: var(--pf-shadow-sm); border: 1px solid var(--pf-border); } .cl-debt-matrix th:first-child, .cl-debt-matrix td:first-child { position: sticky; left: 0; background: var(--pf-bg-page); z-index: 2; border-right: 2px solid var(--pf-border); } .cl-mtx-owe { color: var(--pf-danger); background: #fef2f2; font-weight: 700; } @media (max-width: 1000px) { .cl-view-nadal .cl-occasion-children-tables, .cl-view-anniversary .cl-occasion-children-tables { grid-template-columns: 1fr; } .cl-child-table { display: block; overflow-x: auto; scroll-snap-type: x mandatory; } .cl-child-table caption { position: sticky; left: 0; z-index: 5; } .cl-child-table th, .cl-child-table td { min-width: 180px; scroll-snap-align: start; } } @media (hover: none) { .cl-gift-line { flex-direction: column; align-items: flex-start; } .cl-gift-amount { opacity: 1 !important; margin-bottom: 6px; } .cl-gift-actions { position: relative; opacity: 1 !important; pointer-events: auto !important; } } ``` --- ### 📄 Fichier : `modules/gift-list/save-gift.php` ```php 0) { $stmt = $pdo->prepare("DELETE FROM {$tableName} WHERE id = :id"); $stmt->execute(['id' => $gift_id]); } header("Location: $redirectUrl"); exit; } // Champs communs $year = (int)($_POST['year'] ?? date('Y')); $adult_name = trim($_POST['adult_name'] ?? ''); $payer_name = trim($_POST['payer_name'] ?? ''); $child_name = trim($_POST['child_name'] ?? ''); $occasion = trim($_POST['occasion'] ?? ''); $gift_desc = trim($_POST['gift_description'] ?? ''); $prod_link = trim($_POST['product_link'] ?? ''); $amount = ($_POST['amount'] !== '') ? (float)$_POST['amount'] : 0.0; // Si payeur vide, c'est l'adulte responsable qui paye if ($payer_name === '') { $payer_name = $adult_name; } // Validation minimale if (!$adult_name || !$child_name || !$occasion || !$gift_desc) { // En cas d'erreur, on redirige sans rien faire (ou on pourrait gérer une erreur) header("Location: $redirectUrl"); exit; } // --- UPDATE --- if ($action === 'update' && $gift_id > 0) { $stmt = $pdo->prepare(" UPDATE {$tableName} SET year = :year, adult_name = :adult_name, payer_name = :payer_name, child_name = :child_name, occasion = :occasion, gift_description = :gift_description, product_link = :product_link, amount = :amount WHERE id = :id "); $stmt->execute([ 'id' => $gift_id, 'year' => $year, 'adult_name' => $adult_name, 'payer_name' => $payer_name, 'child_name' => $child_name, 'occasion' => $occasion, 'gift_description' => $gift_desc, 'product_link' => $prod_link ?: null, 'amount' => $amount, ]); } // --- CREATE --- else { $stmt = $pdo->prepare(" INSERT INTO {$tableName} (year, adult_name, payer_name, child_name, occasion, gift_description, product_link, amount) VALUES (:year, :adult_name, :payer_name, :child_name, :occasion, :gift_description, :product_link, :amount) "); $stmt->execute([ 'year' => $year, 'adult_name' => $adult_name, 'payer_name' => $payer_name, 'child_name' => $child_name, 'occasion' => $occasion, 'gift_description' => $gift_desc, 'product_link' => $prod_link ?: null, 'amount' => $amount, ]); } } catch (PDOException $e) { // Log l'erreur si besoin } header("Location: $redirectUrl"); exit; ``` --- ### 📄 Fichier : `modules/holidays/holidays.css` ```css /* modules/holidays/holidays.css */ .pf-holidays { padding-bottom: 50px; } .pf-holidays__titlebar { display: flex; justify-content: space-between; align-items: center; gap: 20px; margin-bottom: 2rem; border-bottom: 1px solid var(--pf-border); padding-bottom: 1rem; flex-wrap: wrap; } .hol-title-group { display: flex; align-items: center; gap: 20px; flex-wrap: wrap; flex: 1; } .hol-main-title { font-size: 1.8rem; font-weight: 800; margin: 0; } .hol-filters-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } .hol-year-select { width: auto !important; padding: 6px 12px; font-weight: bold; } .hol-badge-left-to-pay { background: #fff1f2; color: #be123c; padding: 6px 12px; border-radius: 8px; font-weight: bold; border: 1px solid #fecdd3; font-size: 0.9rem; display: flex; gap: 8px; } /* Boutons d'actions */ .hol-map-toggle { background: white; color: #0f766e; border: 1px solid #ccfbf1; border-radius: 50px; padding: 10px 20px; font-size: 0.9rem; font-weight: 600; cursor: pointer; transition: 0.2s; box-shadow: var(--pf-shadow-sm); } .hol-map-toggle:hover { background: #f0fdfa; color: #0d9488; border-color: #99f6e4; } .btn-add-item { background: rgba(255, 255, 255, 0.6); border: 1px solid rgba(0, 0, 0, 0.1); width: 28px; height: 28px; border-radius: 50%; font-size: 18px; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: 0.4s; } .btn-add-item:hover { background: white; color: var(--pf-primary); transform: rotate(90deg) scale(1.1); box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15); border-color: currentColor; } /* Cartes Vacances */ .hol-ideas-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr)); gap: 24px; margin-bottom: 40px; } .hol-idea-card { background: var(--pf-bg-card); border-radius: var(--pf-radius-lg); box-shadow: var(--pf-shadow-md); padding: 20px; display: flex; flex-direction: column; transition: 0.25s ease; border: 1px solid transparent; cursor: pointer; } .hol-idea-card:hover { transform: translateY(-5px); box-shadow: var(--pf-shadow-lg); border-color: var(--pf-border); } .hol-idea-card__head { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 8px; } .hol-idea-card h3 { margin: 0; font-size: 1.15rem; font-weight: 700; line-height: 1.3; } .hol-idea-meta { font-size: 0.9rem; color: var(--pf-text-muted); margin-bottom: 12px; } /* Barres progression */ .hol-progress-bar { width: 100%; height: 12px; background: var(--pf-border); border-radius: 6px; margin-bottom: 10px; display: flex; overflow: hidden; } .hol-progress-paid { background: var(--pf-success); } .hol-progress-saved { background: var(--pf-primary); } .hol-progress-labels { display: flex; justify-content: space-between; font-size: 0.85rem; } .hol-label-paid { color: var(--pf-success); font-weight: 600; } .hol-label-saved { color: var(--pf-primary); font-weight: 600; } .hol-label-left { color: var(--pf-danger); font-weight: 700; } /* Statuts */ .bg-green-100 { background: #dcfce7; color: #166534; } .bg-blue-100 { background: #dbeafe; color: #1e40af; } .bg-yellow-50 { background: #fefce8; color: #854d0e; } .bg-gray-100 { background: #f3f4f6; color: #4b5563; } /* Détail Voyage - Colonnes et Checklist */ .hol-columns-wrapper { display: flex; flex-direction: column; gap: 24px; width: 100%; } .hol-col { background: var(--pf-bg-page); padding: 20px; border-radius: var(--pf-radius-md); border: 1px solid var(--pf-border); } .hol-col-header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--pf-border); padding-bottom: 8px; margin-bottom: 12px; } .hol-col-header h4 { margin: 0; font-size: 1rem; text-transform: uppercase; font-weight: 700; } .savings-line-item { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; background: white; padding: 8px; border-radius: 8px; border: 1px solid var(--pf-bg-lighter); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03); } .savings-line-item input[type="text"] { flex-grow: 1; min-width: 120px; padding: 6px 10px; } .savings-line-item input[type="number"] { width: 120px !important; padding: 6px 8px !important; text-align: right; font-weight: 600; color: var(--pf-success); } .savings-line-item label { display: flex; align-items: center; font-size: 0.75rem; color: var(--pf-text-muted); gap: 4px; cursor: pointer; white-space: nowrap; } .btn-remove { width: 28px; height: 28px; background: transparent; color: var(--pf-danger); border: none; border-radius: 4px; font-size: 1.2rem; cursor: pointer; display: flex; align-items: center; justify-content: center; } .btn-remove:hover { background: #fef2f2; color: #b91c1c; } /* Détail Voyage - Grille & Timeline */ .hol-layout-grid { display: grid; grid-template-columns: 2fr 1fr; gap: 20px; align-items: stretch; } .hol-panel { background: white; border-radius: var(--pf-radius-lg); box-shadow: var(--pf-shadow-sm); border: 1px solid var(--pf-border); display: flex; flex-direction: column; height: 600px; overflow: hidden; } .hol-panel-header { padding: 15px 20px; border-bottom: 1px solid var(--pf-border); display: flex; justify-content: space-between; align-items: center; background: var(--pf-bg-page); } .hol-panel-body { padding: 15px; overflow-y: auto; flex: 1; } .hol-checkpoint { border: 1px solid var(--pf-border); border-radius: 8px; margin-bottom: 15px; background: white; } .hol-cp-header { background: var(--pf-bg-page); padding: 12px 15px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--pf-border); gap: 12px; } .hol-cp-title { font-weight: 700; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .hol-expense-wrapper { display: flex; flex-direction: column; gap: 4px; padding-bottom: 8px; border-bottom: 1px dashed var(--pf-bg-lighter); margin-bottom: 8px; } .hol-expense-main { display: flex; justify-content: space-between; width: 100%; font-size: 0.85rem; } .hol-expense-name { color: #475569; font-weight: 500; } .hol-expense-amount { font-weight: bold; } .status-paid { color: var(--pf-success); margin-left: 5px; } .status-pending { color: var(--pf-warning); margin-left: 5px; } /* Modale Map Exception */ .hol-dialog--map { width: 95vw; height: 90vh; max-width: 1400px; padding: 0; } #tripMap { touch-action: none; z-index: 1; } .hol-checkpoint-draggable { -webkit-user-drag: none; user-select: none; } /* Modale Formulaire Ligne Rapide */ .hol-form-row { display: flex; flex-direction: column; gap: 6px; padding: 10px; background: var(--pf-bg-page); border-radius: 8px; border: 1px solid var(--pf-border); margin-bottom: 10px; } .hol-form-inner { display: flex; gap: 8px; align-items: center; width: 100%; } .hol-form-select { width: 50px; padding: 8px 4px; font-size: 1.2rem; } .hol-form-text { flex: 2; padding: 8px; font-size: 0.9rem; } .hol-form-number { width: 80px; text-align: right; padding: 8px; font-size: 0.9rem; } .hol-form-paid-label { display: flex; align-items: center; cursor: pointer; width: 55px; } .hol-form-notes-input { font-size: 0.8rem; padding: 6px 8px; border-style: dashed; width: calc(100% - 58px); margin-left: 58px; } .btn-remove-expense { width: 28px; height: 28px; border: none; background: #fee2e2; color: var(--pf-danger); border-radius: 6px; cursor: pointer; display: flex; justify-content: center; align-items: center; } /* Planning Drag & Drop */ .hol-planning-layout { display: flex; gap: 20px; height: 65vh; align-items: stretch; overflow: hidden; } .hol-unmapped-zone { width: 250px; background: var(--pf-bg-page); padding: 15px; border-radius: 12px; border: 2px dashed var(--pf-border); overflow-y: auto; } .hol-calendar-zone { flex: 1; display: flex; gap: 15px; overflow: auto; padding-bottom: 10px; position: relative; } .hol-day-column { min-width: 220px; flex: 1; background: white; border-radius: 12px; border: 1px solid var(--pf-border); } .hol-calendar-day-header { text-align: center; padding: 12px 10px; background: var(--pf-bg-page); border-bottom: 2px solid var(--pf-border); position: sticky; top: 0; z-index: 20; } .hol-time-slot { height: 75px; border-bottom: 1px solid var(--pf-bg-lighter); position: relative; box-sizing: border-box; } .hol-time-slot.drag-over { background: #e0f2fe; } .hol-slot-label { position: absolute; top: 2px; right: 5px; font-size: 0.65rem; font-weight: 700; color: var(--pf-text-muted); } .hol-drag-item { background: white; padding: 6px 8px; border-radius: 6px; box-shadow: var(--pf-shadow-sm); cursor: grab; font-size: 0.85rem; border: 1px solid var(--pf-border); border-left: 4px solid var(--pf-primary); z-index: 10; overflow: hidden; } .hol-calendar-zone .hol-drag-item { position: absolute; top: 1px; left: 45px; right: 5px; height: calc(75px * var(--duration) - 2px); } .hol-drag-item.cat-accommodation { border-left-color: #8b5cf6; } .hol-drag-item.cat-transport { border-left-color: var(--pf-success); } .hol-item-duration-controls { display: flex; align-items: center; gap: 3px; background: rgba(255, 255, 255, 0.4); border-radius: 4px; } .hol-dur-btn { background: var(--pf-bg-lighter); border: none; border-radius: 4px; width: 18px; height: 18px; cursor: pointer; font-weight: bold; } /* Météo */ .pf-weather-badge { display: flex; align-items: center; gap: 4px; background: var(--pf-bg-page); border: 1px solid var(--pf-border); padding: 1px 6px; border-radius: 6px; color: #475569; font-weight: 600; font-size: 0.7rem; } /* Mobile */ @media (max-width: 992px) { .hol-layout-grid { grid-template-columns: 1fr; } .hol-panel { height: 500px; } .hol-planning-layout { flex-direction: column; height: 75vh; } .hol-unmapped-zone { width: 100%; height: 180px; min-height: 180px; flex-direction: row; overflow-x: auto; overflow-y: hidden; } } @media (max-width: 768px) { .pf-holidays__titlebar, .hol-title-group { flex-direction: column; align-items: stretch; gap: 15px; } .hol-filters-row { justify-content: space-between; width: 100%; } .hol-actions-group .hol-add-btn { width: 100%; justify-content: center; } .savings-line-item { flex-wrap: wrap; gap: 10px; } .savings-line-item input[type="text"] { min-width: 100%; } .hol-cp-header { flex-direction: column; align-items: flex-start; } .hol-cp-actions-group { width: 100%; justify-content: flex-end; margin-top: 8px; } } ``` --- ### 📄 Fichier : `modules/holidays/holidays.js` ```javascript // ============================================================================ // FONCTION DE TRADUCTION JS & LANGUE COURANTE // ============================================================================ function tr(key) { return window.I18N && window.I18N[key] ? window.I18N[key] : key; } // On utilise 'var' au lieu de 'const/let' pour éviter les crashs si le fichier est lu 2 fois var currentLang = document.documentElement.lang === "ca" ? "ca-ES" : "fr-FR"; var selectedItemIdForMove = null; // Déplacé ici pour plus de clarté // ============================================================================ // UTILITAIRES MÉTÉO // ============================================================================ function getWeatherInfo(code) { // Transformation en conditions pour regrouper les codes WMO if (code === 0) return { icon: "☀️", label: tr("weather_sunny") }; if ([1, 2].includes(code)) return { icon: "🌤️", label: tr("weather_sunny") }; if ([3, 45, 48].includes(code)) return { icon: "☁️", label: tr("weather_cloudy") }; // Les codes 51 à 67 et 80 à 82 couvrent toutes les formes de pluie et bruine if ([51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82].includes(code)) return { icon: "🌧️", label: tr("weather_rainy") }; // Les codes neigeux if ([71, 73, 75, 77, 85, 86].includes(code)) return { icon: "❄️", label: tr("weather_snowy") }; // Orages if ([95, 96, 99].includes(code)) return { icon: "⛈️", label: tr("weather_rainy") }; return { icon: "🌡️", label: tr("weather_forecast") }; } async function loadWeatherForStep(pt) { if (!pt.step_start_date || !pt.lat || !pt.lng) return; const container = document.querySelector( `#step-card-${pt.sort_order} .hol-weather-info`, ); if (!container) return; try { const resp = await fetch( `/modules/holidays/includes/api/get_weather.php?lat=${pt.lat}&lng=${pt.lng}&date=${pt.step_start_date}`, ); const res = await resp.json(); console.log(`Météo pour ${pt.location_name} :`, res); if (res.success) { const info = getWeatherInfo(res.data.code); // Si c'est une estimation basée sur le passé, on adapte l'affichage const approxSymbol = res.data.is_historical ? "~" : ""; const badgeTitle = res.data.is_historical ? `${info.label} (${tr("weather_historical")})` : info.label; const opacityStyle = res.data.is_historical ? "opacity: 0.85; font-style: italic;" : ""; container.innerHTML = `
${info.icon} ${approxSymbol}${Math.round(res.data.temp_min)}° / ${Math.round(res.data.temp_max)}°C
`; } } catch (e) { console.error("Weather error", e); } } // ============================================================================ // FERMETURE UNIVERSELLE DES MODALES // ============================================================================ window.addEventListener("click", function (event) { if (event.target.classList.contains("pf-modal")) { event.target.style.display = "none"; document.body.classList.remove("no-scroll"); } }); // --- 1. GESTION DE LA MODALE D'ÉDITION RAPIDE --- function openHolidayModal(mode) { const modal = document.getElementById("holidayModal"); const form = document.getElementById("holidayForm"); const btnDelete = document.getElementById("btn_delete"); form.reset(); document.getElementById("inp_id").value = ""; document.getElementById("list_transport").innerHTML = ""; document.getElementById("list_accommodation").innerHTML = ""; document.getElementById("list_activity").innerHTML = ""; if (mode === "add") { document.getElementById("modalTitle").innerText = tr("hdl_modal_title"); btnDelete.style.display = "none"; } else { document.getElementById("modalTitle").innerText = tr( "hdl_quick_edit_title", ); btnDelete.style.display = "block"; } modal.style.display = "flex"; setTimeout(() => document.getElementById("inp_title").focus(), 100); } function closeHolidayModal() { document.getElementById("holidayModal").style.display = "none"; document.body.classList.remove("no-scroll"); } function editHoliday(data) { const h = data.main; const modal = document.getElementById("holidayModal"); if (!modal) { alert(tr("err_modal_missing")); return; } document.body.appendChild(modal); if (typeof openHolidayModal === "function") { openHolidayModal("edit"); } modal.classList.add("open"); modal.style.setProperty("display", "flex", "important"); modal.style.setProperty("z-index", "999999", "important"); document.body.classList.add("no-scroll"); try { document.getElementById("inp_id").value = h.id; document.getElementById("inp_title").value = h.title; document.getElementById("inp_status").value = h.status; document.getElementById("inp_period").value = h.period_hint || ""; document.getElementById("inp_start").value = h.start_date || ""; document.getElementById("inp_end").value = h.end_date || ""; document.getElementById("inp_food").value = h.budget_food > 0 ? h.budget_food : ""; document.getElementById("inp_extra").value = h.budget_extra > 0 ? h.budget_extra : ""; document.getElementById("inp_notes").value = h.notes || ""; } catch (err) { console.error("Erreur champs textes :", err); } try { document.getElementById("list_transport").innerHTML = ""; document.getElementById("list_accommodation").innerHTML = ""; document.getElementById("list_activity").innerHTML = ""; if (data.items && data.items.length > 0) { data.items.forEach((item) => { if ( typeof addItem === "function" && item.name !== "PF_TECHNICAL_POINT" ) { addItem(item.category, item.name, item.amount, item.is_paid); } }); } } catch (err) { console.error("Erreur listes :", err); } } // --- 2. GESTION DES LISTES DYNAMIQUES DANS LA MODALE --- function addItem(category, name = "", amount = "", isPaid = 0) { const container = document.getElementById("list_" + category); const div = document.createElement("div"); div.style.display = "flex"; div.style.gap = "8px"; div.style.alignItems = "center"; div.style.marginBottom = "10px"; const checkedAttr = isPaid == 1 ? "checked" : ""; div.innerHTML = ` `; container.appendChild(div); } function deleteHoliday() { if (!confirm(tr("hdl_js_confirm_del_trip"))) return; const form = document.getElementById("holidayForm"); const input = document.createElement("input"); input.type = "hidden"; input.name = "action_delete"; input.value = "1"; form.appendChild(input); form.submit(); } // --- 3. GESTION DE LA CARTE --- var map = null; function toggleMap() { const modal = document.getElementById("hol-map-modal"); if (!modal) return; if (modal.style.display === "flex") { modal.style.display = "none"; } else { modal.style.display = "flex"; setTimeout(initMap, 100); } } function initMap() { if (map) { map.invalidateSize(); return; } if (typeof L === "undefined") return; map = L.map("hol-map").setView([46.6, 2.4], 4); L.tileLayer( "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png", { attribution: "© OpenStreetMap", }, ).addTo(map); if (typeof HOL_MAP_POINTS !== "undefined") { HOL_MAP_POINTS.forEach((pt) => { const color = pt.status === "planned" || pt.status === "booked" ? "green" : "blue"; L.circleMarker([pt.lat, pt.lng], { color: color, radius: 8, fillOpacity: 0.8, }) .addTo(map) .bindPopup(`${pt.title}
${pt.status}`); }); } } // ============================================================================ // 4. GESTION DE LA CARTE DÉTAILLÉE (ROADTRIP) ET GÉOCODAGE // ============================================================================ var detailMap = null; document.addEventListener("DOMContentLoaded", () => { if (document.getElementById("tripMap")) { initDetailMap(); } }); function initDetailMap() { if (typeof L === "undefined" || typeof MAP_POINTS === "undefined") return; // 1. 🧹 NETTOYAGE PROPRE : On détruit l'ancienne instance si elle existe (Évite le bug de la souris bloquée) if (detailMap !== null) { detailMap.remove(); detailMap = null; } const mapContainer = document.getElementById("tripMap"); if (!mapContainer) return; // 2. 🛡️ BOUCLIER FIREFOX DESKTOP : Empêche le drag natif HTML5 de voler le clic mapContainer.style.touchAction = "none"; mapContainer.ondragstart = function (e) { e.preventDefault(); }; // 3. 🛠️ INITIALISATION DE LA CARTE detailMap = L.map("tripMap", { tap: false, // Désactive le tap simulé (anti-warning mobile/Firefox) dragging: true, // Force l'autorisation du déplacement à la souris }); L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19, attribution: '© OpenStreetMap contributors', }).addTo(detailMap); // Cas : Aucun point if (MAP_POINTS.length === 0) { detailMap.setView([46.6, 2.4], 5); // France par défaut return; } const latlngs = []; const bounds = L.latLngBounds(); // 4. PLACEMENT DES MARQUEURS MAP_POINTS.forEach((pt, index) => { const pos = [pt.lat, pt.lng]; latlngs.push(pos); bounds.extend(pos); const color = "#2563eb"; const marker = L.circleMarker(pos, { color: color, radius: window.innerWidth < 768 ? 6 : 8, fillOpacity: 1, fillColor: "white", weight: 3, }).addTo(detailMap); const stepLabel = window.I18N ? window.I18N["hdl_js_step_label"] : tr("hdl_js_step_label"); marker.bindPopup(`
${stepLabel} ${index + 1}
${pt.location_name}
${parseFloat(pt.total_amount).toFixed(2)} €
`); // Animation au clic sur le marqueur marker.on("click", function () { const card = document.getElementById("step-card-" + pt.sort_order); if (card) { card.scrollIntoView({ behavior: "smooth", block: "center" }); card.style.transition = "box-shadow 0.3s, transform 0.3s"; card.style.boxShadow = "0 0 0 3px #3b82f6"; card.style.transform = "scale(1.02)"; setTimeout(() => { card.style.boxShadow = ""; card.style.transform = ""; }, 1500); } }); }); const mapPadding = window.innerWidth < 768 ? [20, 20] : [50, 50]; // 5. CENTRAGE ET TRACÉS (OSRM) if (latlngs.length === 1) { detailMap.setView(latlngs[0], 12); } else if (latlngs.length > 1) { detailMap.fitBounds(bounds, { padding: mapPadding }); const routePromises = []; for (let i = 0; i < latlngs.length - 1; i++) { const startPt = MAP_POINTS[i]; const endPt = MAP_POINTS[i + 1]; const coordsString = `${startPt.lng},${startPt.lat};${endPt.lng},${endPt.lat}`; const promise = fetch( `https://router.project-osrm.org/route/v1/driving/${coordsString}?overview=full&geometries=geojson`, ) .then((response) => response.json()) .then((data) => ({ index: i, data: data, coords: [latlngs[i], latlngs[i + 1]], })) .catch((err) => ({ index: i, error: true, coords: [latlngs[i], latlngs[i + 1]], })); routePromises.push(promise); } Promise.all(routePromises).then((results) => { results.sort((a, b) => a.index - b.index); let returnStartIndex = latlngs.length - 2; const customReturnStep = MAP_POINTS.findIndex((p) => p.is_return == 1); if (customReturnStep > 0) { returnStartIndex = customReturnStep; } results.forEach((res) => { const i = res.index; let routeColor = "#3b82f6"; let routeWeight = window.innerWidth < 768 ? 4 : 6; let routeDash = null; if (i >= returnStartIndex) { routeColor = "#f97316"; routeWeight = window.innerWidth < 768 ? 3 : 4; routeDash = "10, 10"; } if (res.data && res.data.code === "Ok" && res.data.routes.length > 0) { const routeCoords = res.data.routes[0].geometry.coordinates.map( (c) => [c[1], c[0]], ); L.polyline(routeCoords, { color: routeColor, weight: routeWeight, dashArray: routeDash, opacity: 0.9, lineCap: "round", lineJoin: "round", }).addTo(detailMap); } else { drawFallbackLine(res.coords, routeColor, routeWeight); } }); }); } // 6. LANCEMENT DE LA MÉTÉO if (typeof MAP_POINTS !== "undefined") { MAP_POINTS.forEach((pt) => { if (typeof loadWeatherForStep === "function") { loadWeatherForStep(pt); } }); } // 7. FIX FINAL : Force Leaflet à recalculer sa taille une fois le DOM stabilisé setTimeout(() => { if (detailMap) { detailMap.invalidateSize(); } }, 300); // Fonction utilitaire locale function drawFallbackLine(coords, color, weight) { L.polyline(coords, { color: color, weight: weight || 3, dashArray: "8, 8", opacity: 0.7, }).addTo(detailMap); } } function panMapTo(lat, lng) { if (detailMap) { detailMap.setView([lat, lng], 14, { animate: true }); // 🛠️ ERGONOMIE MOBILE : Auto-scroll vers la carte si on est sur petit écran if (window.innerWidth < 768) { const mapDiv = document.getElementById("tripMap"); if (mapDiv) { mapDiv.scrollIntoView({ behavior: "smooth", block: "start" }); } } } } // --- LOGIQUE DE LA MODALE CHECKPOINT --- function openCheckpointModal(mode, data = null) { const searchBlock = document.getElementById("cpSearchBlock"); const formBlock = document.getElementById("formCheckpoint"); const container = document.getElementById("cpExpensesContainer"); const btnDel = document.getElementById("btnDeleteCp"); container.innerHTML = ""; if (document.getElementById("cp_start_date")) document.getElementById("cp_start_date").value = ""; if (document.getElementById("cp_end_date")) document.getElementById("cp_end_date").value = ""; document.getElementById("searchPlaceInput").value = ""; document.getElementById("searchResults").innerHTML = ""; searchBlock.style.display = "block"; if (mode === "add") { document.getElementById("cpModalTitle").innerText = tr("hdl_btn_add_step"); formBlock.style.display = "none"; btnDel.style.display = "none"; document.getElementById("cp_old_sort_order").value = ""; document.getElementById("cp_name").value = ""; addCpExpenseLine(); document.getElementById("cp_is_return").checked = false; } else if (mode === "edit" && data) { document.getElementById("cpModalTitle").innerText = tr("hdl_js_edit_step"); formBlock.style.display = "block"; btnDel.style.display = "block"; document.getElementById("cp_lat").value = data.lat; document.getElementById("cp_lng").value = data.lng; document.getElementById("cp_old_sort_order").value = data.sort_order; document.getElementById("cp_name").value = data.location_name; document.getElementById("cp_start_date").value = data.step_start_date || ""; document.getElementById("cp_end_date").value = data.step_end_date || ""; document.getElementById("cp_is_return").checked = data.is_return == 1; if (data.items && data.items.length > 0) { let visibleCount = 0; data.items.forEach((it) => { if (it.name !== "PF_TECHNICAL_POINT") { addCpExpenseLine( it.category, it.name, it.amount, it.is_paid, it.notes || "", it.id || "", it.item_date || "", it.item_time || "", it.duration || 1, ); visibleCount++; } }); if (visibleCount === 0) addCpExpenseLine(); } else { addCpExpenseLine(); } } document.getElementById("checkpointModal").style.display = "flex"; document.body.classList.add("no-scroll"); } function searchPlace() { const q = document.getElementById("searchPlaceInput").value.trim(); if (q.length < 3) return; const resultsDiv = document.getElementById("searchResults"); resultsDiv.innerHTML = `${tr("hdl_js_search_loading")}`; fetch( "/modules/holidays/includes/api/geocode.php?limit=5&q=" + encodeURIComponent(q), ) .then((res) => res.json()) .then((data) => { resultsDiv.innerHTML = ""; if (data.error || !data.results || data.results.length === 0) { resultsDiv.innerHTML = `${tr("hdl_js_no_result")}`; return; } data.results.forEach((place) => { const btn = document.createElement("button"); btn.type = "button"; btn.className = "pf-btn btn-secondary"; btn.style.textAlign = "left"; btn.style.padding = "8px"; btn.style.height = "auto"; btn.innerText = "📍 " + place.display_name; btn.onclick = () => selectPlace(place.lat, place.lng, place.display_name); resultsDiv.appendChild(btn); }); }) .catch((err) => { resultsDiv.innerHTML = `${tr("hdl_js_network_error")}`; }); } function selectPlace(lat, lng, fullName) { document.getElementById("cp_lat").value = lat; document.getElementById("cp_lng").value = lng; document.getElementById("cp_name").value = fullName.split(",")[0].trim(); document.getElementById("formCheckpoint").style.display = "block"; } function addCpExpenseLine( category = "accommodation", name = "", amount = "", isPaid = 0, notes = "", itemId = "", itemDate = "", itemTime = "", itemDur = 1, ) { const container = document.getElementById("cpExpensesContainer"); const div = document.createElement("div"); div.className = "hol-form-row"; const isChecked = isPaid == 1 ? "checked" : ""; div.innerHTML = `
`; container.appendChild(div); } function deleteCheckpoint() { if (!confirm(tr("hdl_js_confirm_del_step"))) return; const form = document.getElementById("formCheckpoint"); const input = document.createElement("input"); input.type = "hidden"; input.name = "action_delete"; input.value = "1"; form.appendChild(input); form.submit(); } // ============================================================================ // 5. RÉORDONNANCEMENT DES ÉTAPES (DRAG & DROP PC + FLÈCHES MOBILE) // ============================================================================ // On sort cette fonction pour pouvoir l'appeler depuis les boutons fléchés sur mobile function saveCheckpointOrder() { const locations = [ ...document.querySelectorAll(".hol-checkpoint-draggable"), ].map((el) => el.getAttribute("data-location")); const holidayId = document.querySelector('input[name="holiday_id"]').value; const formData = new FormData(); formData.append("holiday_id", holidayId); formData.append("locations", JSON.stringify(locations)); fetch("/modules/holidays/includes/api/reorder_checkpoints.php", { method: "POST", body: formData, }).then(() => window.location.reload()); } // Fonction appelée par les flèches Haut/Bas sur mobile function moveStepMobile(btn, direction) { const item = btn.closest(".hol-checkpoint-draggable"); const container = item.parentElement; if ( direction === -1 && item.previousElementSibling && item.previousElementSibling.classList.contains("hol-checkpoint-draggable") ) { container.insertBefore(item, item.previousElementSibling); saveCheckpointOrder(); } else if ( direction === 1 && item.nextElementSibling && item.nextElementSibling.classList.contains("hol-checkpoint-draggable") ) { container.insertBefore(item, item.nextElementSibling.nextElementSibling); saveCheckpointOrder(); } } document.addEventListener("DOMContentLoaded", () => { const checkpoints = document.querySelectorAll(".hol-checkpoint-draggable"); const container = checkpoints[0]?.parentElement; if (!container) return; const isMobile = window.innerWidth <= 768; let draggedItem = null; checkpoints.forEach((item) => { // Si on est sur mobile, on supprime l'attribut draggable pour éviter les conflits de scroll if (isMobile) { item.removeAttribute("draggable"); return; } item.addEventListener("dragstart", function (e) { draggedItem = this; setTimeout(() => (this.style.opacity = "0.4"), 0); }); item.addEventListener("dragend", function () { setTimeout(() => { this.style.opacity = "1"; draggedItem = null; saveCheckpointOrder(); }, 0); }); item.addEventListener("dragover", function (e) { e.preventDefault(); const afterElement = getDragAfterElement(container, e.clientY); if (afterElement == null) { container.appendChild(draggedItem); } else { container.insertBefore(draggedItem, afterElement); } }); }); function getDragAfterElement(container, y) { const draggableElements = [ ...container.querySelectorAll( '.hol-checkpoint-draggable:not([style*="opacity: 0.4"])', ), ]; return draggableElements.reduce( (closest, child) => { const box = child.getBoundingClientRect(); const offset = y - box.top - box.height / 2; if (offset < 0 && offset > closest.offset) { return { offset: offset, element: child }; } else { return closest; } }, { offset: Number.NEGATIVE_INFINITY }, ).element; } }); // ============================================================================ // MOTEUR DRAG & DROP DU PLANNING // ============================================================================ function closePlanningModal() { document.getElementById("planningModal").style.display = "none"; document.body.classList.remove("no-scroll"); } function openPlanningModal(step) { document.getElementById("planningModalTitle").innerText = tr("hdl_planning_title") + " : " + step.location_name; const container = document.getElementById("planningContainer"); selectedItemIdForMove = null; let validItems = step.items.filter((it) => it.name !== "PF_TECHNICAL_POINT"); if (!step.step_start_date || !step.step_end_date) { container.innerHTML = `

${tr("hdl_js_missing_dates_title")}

${tr("hdl_js_missing_dates_msg")}

`; document.getElementById("planningModal").style.display = "flex"; return; } let datesToDisplay = []; let curr = new Date(step.step_start_date); let end = new Date(step.step_end_date); while (curr <= end) { datesToDisplay.push(curr.toISOString().split("T")[0]); curr.setDate(curr.getDate() + 1); } let html = `
📥 ${tr("hdl_to_place")}
`; datesToDisplay.forEach((dateStr) => { const dObj = new Date(dateStr); const dayName = dObj.toLocaleDateString(currentLang, { weekday: "short" }); const dayNum = dObj.toLocaleDateString(currentLang, { day: "numeric", month: "short", }); html += `
${dayName}
${dayNum}
`; for (let h = 8; h <= 22; h++) { let hourStr = h.toString().padStart(2, "0") + ":00"; html += `
${hourStr}
`; } html += `
`; }); html += `
`; container.innerHTML = html; // 1. On détecte si on est sur mobile juste avant la boucle const isMobile = window.innerWidth <= 768; const dragAttr = isMobile ? "" : 'draggable="true"'; validItems.forEach((it) => { let icon = "🏷️"; let catClass = "cat-activity"; if (it.category === "accommodation") { icon = "🏨"; catClass = "cat-accommodation"; } if (it.category === "transport") { icon = "🚗"; catClass = "cat-transport"; } const dur = it.duration || 1; const noteHtml = it.notes ? `
${it.notes}
` : ""; // 2. MODIFICATION ICI : On remplace le texte en dur draggable="true" par la variable ${dragAttr} const elHtml = `
${icon} ${it.name}
${dur}h
${noteHtml}
`; if (it.item_date && it.item_time && datesToDisplay.includes(it.item_date)) { const hourPrefix = it.item_time.substring(0, 2) + ":00"; const targetSlot = container.querySelector( `.hol-time-slot[data-date="${it.item_date}"][data-time="${hourPrefix}"]`, ); if (targetSlot) { targetSlot.insertAdjacentHTML("beforeend", elHtml); return; } } document .getElementById("unmapped-pool") .insertAdjacentHTML("beforeend", elHtml); }); document.getElementById("planningModal").style.display = "flex"; document.body.classList.add("no-scroll"); datesToDisplay.forEach((dateStr) => { loadWeatherForPlanning(step.lat, step.lng, dateStr); }); } function changeDuration(e, itemId, delta) { e.stopPropagation(); const itemEl = document.getElementById("drag-item-" + itemId); let currentDur = parseInt(itemEl.style.getPropertyValue("--duration")) || 1; let newDur = currentDur + delta; if (newDur < 1) newDur = 1; if (newDur > 12) newDur = 12; itemEl.style.setProperty("--duration", newDur); document.getElementById(`dur-text-${itemId}`).innerText = newDur + "h"; updateItemMemory(itemId, { duration: newDur }); const formData = new FormData(); formData.append("action", "update_item_duration"); formData.append("item_id", itemId); formData.append("duration", newDur); fetch("/modules/holidays/includes/api/save_checkpoint.php", { method: "POST", body: formData, }); } function handleItemTap(e, itemId) { e.stopPropagation(); document .querySelectorAll(".hol-drag-item") .forEach((el) => el.classList.remove("selected-for-move")); if (selectedItemIdForMove === itemId) { selectedItemIdForMove = null; } else { selectedItemIdForMove = itemId; document .getElementById("drag-item-" + itemId) .classList.add("selected-for-move"); } } function handleZoneTap(e, dateStr, timeStr) { if (selectedItemIdForMove) { handleDropLogic(selectedItemIdForMove, dateStr, timeStr, e.currentTarget); selectedItemIdForMove = null; document .querySelectorAll(".hol-drag-item") .forEach((el) => el.classList.remove("selected-for-move")); } } function dragStart(e) { e.dataTransfer.setData("text/plain", e.target.id); e.dataTransfer.effectAllowed = "move"; } function allowDrop(e) { e.preventDefault(); } function dragEnter(e) { e.preventDefault(); let s = e.target.closest(".hol-time-slot"); if (s) s.classList.add("drag-over"); } function dragLeave(e) { let s = e.target.closest(".hol-time-slot"); if (s) s.classList.remove("drag-over"); } function handleDropEvent(e, dateStr, timeStr) { e.preventDefault(); let slot = e.target.closest(".hol-time-slot"); if (slot) slot.classList.remove("drag-over"); const idStr = e.dataTransfer.getData("text/plain"); const itemId = idStr.replace("drag-item-", ""); const dropZone = slot || document.getElementById("unmapped-pool"); handleDropLogic(itemId, dateStr, timeStr, dropZone); } function handleDropLogic(itemId, dateStr, timeStr, dropZone) { const draggedEl = document.getElementById("drag-item-" + itemId); if (dropZone && draggedEl) { dropZone.appendChild(draggedEl); updateItemMemory(itemId, { item_date: dateStr, item_time: timeStr }); const formData = new FormData(); formData.append("action", "update_item_datetime"); formData.append("item_id", itemId); formData.append("item_date", dateStr); formData.append("item_time", timeStr); fetch("/modules/holidays/includes/api/save_checkpoint.php", { method: "POST", body: formData, }); } } function updateItemMemory(itemId, changes) { MAP_POINTS.forEach((step) => { let item = step.items.find((i) => i.id == itemId); if (item) Object.assign(item, changes); }); } function saveItemDateTime(itemId, dateStr, timeStr) { const formData = new FormData(); formData.append("action", "update_item_datetime"); formData.append("item_id", itemId); formData.append("item_date", dateStr); formData.append("item_time", timeStr); fetch("/modules/holidays/includes/api/save_checkpoint.php", { method: "POST", body: formData, }).catch((err) => console.error("Erreur:", err)); } // ============================================================================ // MÉTÉO SPÉCIFIQUE AU HEADER DU PLANNING // ============================================================================ async function loadWeatherForPlanning(lat, lng, dateStr) { const container = document.getElementById(`plan-weather-${dateStr}`); if (!container || !lat || !lng) return; try { const resp = await fetch( `/modules/holidays/includes/api/get_weather.php?lat=${lat}&lng=${lng}&date=${dateStr}`, ); const res = await resp.json(); if (res.success) { const info = getWeatherInfo(res.data.code); const approxSymbol = res.data.is_historical ? "~" : ""; container.innerHTML = `
${info.icon} ${approxSymbol}${Math.round(res.data.temp_min)}° / ${Math.round(res.data.temp_max)}°C
`; } } catch (e) { console.error("Erreur météo planning", e); } } ``` --- ### 📄 Fichier : `modules/holidays/includes/api/geocode.php` ```php 'missing_q']); exit; } $limit = (int)($_GET['limit'] ?? 1); if ($limit < 1) $limit = 1; if ($limit > 10) $limit = 10; // 2. Normalisation pour le cache $qNorm = mb_strtolower($q); $qHash = hash('sha256', $qNorm); try { $pdo->exec(" CREATE TABLE IF NOT EXISTS pf_geocode_cache ( q_hash CHAR(64) PRIMARY KEY, q VARCHAR(255), lat DECIMAL(10, 7), lng DECIMAL(10, 7), display_name TEXT, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); } catch (Throwable $e) {} // 3. Vérification du cache try { $st = $pdo->prepare("SELECT lat, lng, display_name FROM pf_geocode_cache WHERE q_hash = ?"); $st->execute([$qHash]); if ($row = $st->fetch(PDO::FETCH_ASSOC)) { if ($limit === 1) { echo json_encode([ 'lat' => (float)$row['lat'], 'lng' => (float)$row['lng'], 'display_name' => $row['display_name'], 'cached' => true ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } else { echo json_encode(['results' => [[ 'lat' => (float)$row['lat'], 'lng' => (float)$row['lng'], 'display_name' => $row['display_name'], 'cached' => true ]]], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } exit; } } catch (Throwable $e) {} // 4. NOUVELLE API : Appel à Photon (Komoot) - Beaucoup plus tolérant pour les adresses ! $endpoint = 'https://photon.komoot.io/api/'; $params = http_build_query([ 'q' => $q, 'limit' => $limit, 'lang' => 'fr' // On force les résultats en français ], '', '&', PHP_QUERY_RFC3986); $url = $endpoint . '?' . $params; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 10, CURLOPT_USERAGENT => 'PachaFamily-Holidays/1.0', CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => false ]); $body = curl_exec($ch); $http = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($body === false || $http !== 200) { http_response_code(502); echo json_encode(['error' => 'geocode_failed']); exit; } $data = json_decode($body, true); if (!isset($data['features']) || empty($data['features'])) { http_response_code(404); echo json_encode(['error' => 'not_found']); exit; } // 5. Formatage des résultats (Photon renvoie les données différemment de Nominatim) $results = []; foreach ($data['features'] as $feature) { $prop = $feature['properties']; $coords = $feature['geometry']['coordinates']; // [lng, lat] // Construction propre du nom (Nom du lieu + Rue + Ville + Pays) $nameParts = []; if (!empty($prop['name'])) $nameParts[] = $prop['name']; if (!empty($prop['housenumber']) && !empty($prop['street'])) { $nameParts[] = $prop['housenumber'] . ' ' . $prop['street']; } elseif (!empty($prop['street'])) { $nameParts[] = $prop['street']; } if (!empty($prop['city'])) $nameParts[] = $prop['city']; elseif (!empty($prop['town'])) $nameParts[] = $prop['town']; elseif (!empty($prop['village'])) $nameParts[] = $prop['village']; if (!empty($prop['country'])) $nameParts[] = $prop['country']; $displayName = implode(', ', array_unique($nameParts)); $results[] = [ 'lat' => round((float)$coords[1], 6), 'lng' => round((float)$coords[0], 6), 'display_name' => $displayName ]; } // 6. Mise en cache du premier résultat if (isset($results[0])) { try { $st = $pdo->prepare(" INSERT INTO pf_geocode_cache (q_hash, q, lat, lng, display_name, updated_at) VALUES (?, ?, ?, ?, ?, NOW()) ON DUPLICATE KEY UPDATE lat=VALUES(lat), lng=VALUES(lng), display_name=VALUES(display_name), updated_at=NOW() "); $st->execute([$qHash, $q, $results[0]['lat'], $results[0]['lng'], $results[0]['display_name']]); } catch (Throwable $e) {} } // 7. Retour JSON pour ton Javascript if ($limit === 1) { echo json_encode($results[0], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } else { echo json_encode(['results' => $results], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } ``` --- ### 📄 Fichier : `modules/holidays/includes/api/get_holiday_data.php` ```php 'invalid_id']); exit; } try { // Si tu utilises pf_holidays au lieu de pf_holidays_ideas, change le nom de la table ici ! $st = $pdo->prepare("SELECT * FROM pf_holidays_ideas WHERE id = ?"); $st->execute([$id]); $it = $st->fetch(PDO::FETCH_ASSOC); if (!$it) { http_response_code(404); echo json_encode(['error' => 'not_found']); exit; } $it['id'] = (int)$it['id']; if (isset($it['lat'])) $it['lat'] = (float)$it['lat']; if (isset($it['lng'])) $it['lng'] = (float)$it['lng']; if (isset($it['ideal_days'])) $it['ideal_days'] = (int)$it['ideal_days']; echo json_encode($it, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } catch (Throwable $e) { http_response_code(500); echo json_encode(['error' => 'server_error']); } ``` --- ### 📄 Fichier : `modules/holidays/includes/api/get_weather.php` ```php false, 'message' => 'Paramètres manquants']); exit; } $date = substr($date, 0, 10); $today = new DateTime(); $targetDate = new DateTime($date); $interval = $today->diff($targetDate); $daysDiff = (int)$interval->format('%R%a'); // --- 🧠 GESTION INTELLIGENTE DU TEMPS (Prévisions vs Historique) --- if ($daysDiff > 16) { // 1. VOYAGE LOINTAIN : Calcul de la moyenne sur 3 ans $currentYear = (int)$today->format('Y'); $monthDay = $targetDate->format('m-d'); if ($monthDay === '02-29') $monthDay = '02-28'; // Sécurité année bissextile $tempMaxSum = 0; $tempMinSum = 0; $validYearsCount = 0; $representativeCode = 0; // On gardera le code de l'année N-1 for ($i = 1; $i <= 3; $i++) { $pastYear = $currentYear - $i; $searchDate = $pastYear . '-' . $monthDay; $url = "https://archive-api.open-meteo.com/v1/archive?latitude=$lat&longitude=$lng&daily=weather_code,temperature_2m_max,temperature_2m_min&timezone=auto&start_date=$searchDate&end_date=$searchDate"; $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 2); // Timeout court pour ne pas ralentir le serveur curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); $res = curl_exec($ch); curl_close($ch); if ($res) { $data = json_decode($res, true); if (isset($data['daily']['temperature_2m_max'][0])) { $tempMaxSum += $data['daily']['temperature_2m_max'][0]; $tempMinSum += $data['daily']['temperature_2m_min'][0]; if ($i === 1) { // On prend le temps qu'il a fait à N-1 pour l'icône $representativeCode = $data['daily']['weather_code'][0]; } $validYearsCount++; } } } // Si on a pu récupérer au moins une année de données if ($validYearsCount > 0) { echo json_encode([ 'success' => true, 'data' => [ 'code' => $representativeCode, 'temp_max' => round($tempMaxSum / $validYearsCount, 1), // Moyenne arrondie à 1 décimale 'temp_min' => round($tempMinSum / $validYearsCount, 1), 'is_historical' => true ] ]); } else { echo json_encode(['success' => false, 'message' => 'Archives indisponibles']); } exit; // On arrête l'exécution ici pour les voyages lointains } elseif ($daysDiff < -2) { // 2. VOYAGE PASSÉ : On interroge les archives pour cette date précise $baseUrl = "https://archive-api.open-meteo.com/v1/archive"; $searchDate = $date; } else { // 3. FUTUR PROCHE (Prévisions fiables) : On interroge les prévisions $baseUrl = "https://api.open-meteo.com/v1/forecast"; $searchDate = $date; } // --- APPEL API CLASSIQUE (Pour les prévisions et les voyages passés) --- $url = "$baseUrl?latitude=$lat&longitude=$lng&daily=weather_code,temperature_2m_max,temperature_2m_min&timezone=auto&start_date=$searchDate&end_date=$searchDate"; $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 5); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); $res = curl_exec($ch); curl_close($ch); if (!$res) { echo json_encode(['success' => false, 'message' => 'Erreur API Open-Meteo']); exit; } $data = json_decode($res, true); if (isset($data['daily']['weather_code'][0])) { echo json_encode([ 'success' => true, 'data' => [ 'code' => $data['daily']['weather_code'][0], 'temp_max' => $data['daily']['temperature_2m_max'][0], 'temp_min' => $data['daily']['temperature_2m_min'][0], 'is_historical' => false ] ]); } else { echo json_encode(['success' => false, 'message' => 'Aucune donnée pour cette date']); } ``` --- ### 📄 Fichier : `modules/holidays/includes/api/reorder_checkpoints.php` ```php 0 && is_array($locations)) { try { $pdo->beginTransaction(); $stmt = $pdo->prepare("UPDATE pf_holidays_items SET sort_order = ? WHERE holiday_id = ? AND location_name = ?"); foreach ($locations as $index => $loc) { // On met à jour toutes les dépenses de ce lieu avec son nouveau rang (0, 1, 2...) $stmt->execute([$index, $holiday_id, $loc]); } $pdo->commit(); echo json_encode(['success' => true]); } catch (Exception $e) { $pdo->rollBack(); http_response_code(500); echo json_encode(['error' => $e->getMessage()]); } } ``` --- ### 📄 Fichier : `modules/holidays/includes/api/save_checkpoint.php` ```php prepare("UPDATE pf_holidays_items SET item_date = ?, item_time = ? WHERE id = ?"); $stmt->execute([$itemDate, $itemTime, $itemId]); } else { $duration = (int)$_POST['duration']; $stmt = $pdo->prepare("UPDATE pf_holidays_items SET duration = ? WHERE id = ?"); $stmt->execute([$duration, $itemId]); } echo json_encode(['success' => true]); exit; // Crucial : on arrête le script ici ! } $holiday_id = (int)$_POST['holiday_id']; $location_name = trim($_POST['location_name']); $lat = (float)$_POST['lat']; $lng = (float)$_POST['lng']; $old_sort_order = isset($_POST['old_sort_order']) && $_POST['old_sort_order'] !== '' ? (int)$_POST['old_sort_order'] : null; // 1. SUPPRESSION if (isset($_POST['action_delete']) && $_POST['action_delete'] === '1') { $pdo->prepare("DELETE FROM pf_holidays_items WHERE holiday_id = ? AND sort_order = ?")->execute([$holiday_id, $old_sort_order]); header("Location: /holidays.php?tab=holiday_detail&id=" . $holiday_id); exit; } if ($holiday_id > 0 && !empty($location_name)) { try { $pdo->beginTransaction(); // 2. DÉTERMINER L'ORDRE (Identifiant de l'étape) if ($old_sort_order !== null) { // Modification : on supprime l'ancien contenu de cette étape précise $pdo->prepare("DELETE FROM pf_holidays_items WHERE holiday_id = ? AND sort_order = ?")->execute([$holiday_id, $old_sort_order]); $target_order = $old_sort_order; } else { // Ajout : on place à la fin (Max + 1) $stmtMax = $pdo->prepare("SELECT MAX(sort_order) FROM pf_holidays_items WHERE holiday_id = ?"); $stmtMax->execute([$holiday_id]); $max = $stmtMax->fetchColumn(); $target_order = ($max !== null) ? (int)$max + 1 : 0; } // Récupération des dates de l'étape globale $step_start = !empty($_POST['step_start_date']) ? $_POST['step_start_date'] : null; $step_end = !empty($_POST['step_end_date']) ? $_POST['step_end_date'] : null; $is_return = isset($_POST['is_return']) ? 1 : 0; // NOUVEAU // 3. INSERTION DES LIGNES (16 Colonnes) $stmt = $pdo->prepare("INSERT INTO pf_holidays_items (holiday_id, category, name, amount, is_paid, location_name, lat, lng, sort_order, notes, item_date, item_time, step_start_date, step_end_date, duration, is_return) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); $validItemsCount = 0; if (isset($_POST['items']['name'])) { foreach ($_POST['items']['name'] as $i => $raw_name) { $name = trim($raw_name); $amount_raw = $_POST['items']['amount'][$i]; if ($name !== '' || $amount_raw !== '') { $cat = $_POST['items']['cat'][$i] ?? 'activity'; $amount = (float)$amount_raw; $paid = (isset($_POST['items']['paid'][$i]) && (int)$_POST['items']['paid'][$i] === 1) ? 1 : 0; $note = trim($_POST['items']['notes'][$i] ?? ''); $date = !empty($_POST['items']['date'][$i]) ? $_POST['items']['date'][$i] : null; $time = !empty($_POST['items']['time'][$i]) ? $_POST['items']['time'][$i] : null; $dur = !empty($_POST['items']['duration'][$i]) ? (int)$_POST['items']['duration'][$i] : 1; // Ajout de $is_return à la fin $stmt->execute([$holiday_id, $cat, $name ?: tr('hdl_default_exp_name'), $amount, $paid, $location_name, $lat, $lng, $target_order, $note, $date, $time, $step_start, $step_end, $dur, $is_return]); $validItemsCount++; } } } if ($validItemsCount === 0) { $stmt->execute([$holiday_id, 'activity', 'PF_TECHNICAL_POINT', 0, 1, $location_name, $lat, $lng, $target_order, '', null, null, $step_start, $step_end, 1, $is_return]); } // 4. GESTION DES FAVORIS if (isset($_POST['save_favorite']) && $_POST['save_favorite'] == '1') { $stmtFav = $pdo->query("SELECT content FROM pf_notes WHERE note_type = 'holiday_favorites'"); $favs = json_decode($stmtFav->fetchColumn() ?: '[]', true); $exists = false; foreach ($favs as $f) { if ($f['name'] === $location_name) $exists = true; } if (!$exists) { $favs[] = ['name' => $location_name, 'lat' => $lat, 'lng' => $lng]; $pdo->prepare("INSERT INTO pf_notes (note_type, reference_id, content) VALUES ('holiday_favorites', 'GLOBAL', ?) ON DUPLICATE KEY UPDATE content = VALUES(content)")->execute([json_encode($favs)]); } } $pdo->commit(); } catch (Exception $e) { $pdo->rollBack(); die($e->getMessage()); } } header("Location: /holidays.php?tab=holiday_detail&id=" . $holiday_id); exit; ``` --- ### 📄 Fichier : `modules/holidays/includes/api/save_holiday.php` ```php includes -> holidays -> modules -> racine) require dirname(__DIR__, 4) . '/includes/auth.php'; require dirname(__DIR__, 4) . '/includes/db.php'; require_login(); // Si cette fonction nécessite une redirection, gère-la dans auth.php if (isset($_POST['action_delete']) && $_POST['action_delete'] == '1') { $stmt = $pdo->prepare("DELETE FROM pf_holidays WHERE id = ?"); $stmt->execute([$_POST['id']]); header("Location: /holidays.php"); exit; } $id = $_POST['id'] ?? ''; $title = $_POST['title']; $period = $_POST['period_hint']; $start = !empty($_POST['start_date']) ? $_POST['start_date'] : null; $end = !empty($_POST['end_date']) ? $_POST['end_date'] : null; $status = $_POST['status']; $food = !empty($_POST['budget_food']) ? $_POST['budget_food'] : 0; $extra = !empty($_POST['budget_extra']) ? $_POST['budget_extra'] : 0; $notes = $_POST['notes']; try { $pdo->beginTransaction(); if ($id) { // UPDATE $sql = "UPDATE pf_holidays SET title=?, period_hint=?, start_date=?, end_date=?, status=?, budget_food=?, budget_extra=?, notes=? WHERE id=?"; $stmt = $pdo->prepare($sql); $stmt->execute([$title, $period, $start, $end, $status, $food, $extra, $notes, $id]); } else { // INSERT $sql = "INSERT INTO pf_holidays (title, period_hint, start_date, end_date, status, budget_food, budget_extra, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"; $stmt = $pdo->prepare($sql); $stmt->execute([$title, $period, $start, $end, $status, $food, $extra, $notes]); $id = $pdo->lastInsertId(); } // GESTION DES ITEMS GLOBAUX (On ne supprime QUE les items qui n'ont pas de lieu défini !) $pdo->prepare("DELETE FROM pf_holidays_items WHERE holiday_id = ? AND (location_name IS NULL OR location_name = '')")->execute([$id]); if (!empty($_POST['items']['name'])) { $stmtItem = $pdo->prepare("INSERT INTO pf_holidays_items (holiday_id, category, name, amount, is_paid) VALUES (?, ?, ?, ?, ?)"); $count = count($_POST['items']['name']); for ($i = 0; $i < $count; $i++) { // Utilisation de "?? ''" pour sécuriser si la donnée n'est pas envoyée $cat = $_POST['items']['cat'][$i] ?? ''; $name = trim($_POST['items']['name'][$i] ?? ''); $amount = floatval($_POST['items']['amount'][$i] ?? 0); $paid = isset($_POST['items']['paid'][$i]) ? $_POST['items']['paid'][$i] : 0; if (!empty($name)) { $stmtItem->execute([$id, $cat, $name, $amount, $paid]); } } } $pdo->commit(); } catch (Exception $e) { $pdo->rollBack(); die("Erreur base de données : " . $e->getMessage()); } // Redirection vers la page principale header("Location: /holidays.php"); exit; ``` --- ### 📄 Fichier : `modules/holidays/views/detail.php` ```php

".tr('err_invalid_holiday_id')."

"; exit; } // Récupération des données du voyage $stmt = $pdo->prepare(" SELECT h.*, (COALESCE(h.budget_food, 0) + COALESCE(h.budget_extra, 0) + COALESCE((SELECT SUM(amount) FROM pf_holidays_items WHERE holiday_id = h.id), 0)) as total_cost, ((SELECT COALESCE(SUM(ABS(amount)), 0) FROM pf_expenses WHERE holiday_id = h.id) + (SELECT COALESCE(SUM(amount), 0) FROM pf_holidays_items WHERE holiday_id = h.id AND is_paid = 1)) as total_paid, (SELECT COALESCE(SUM(amount), 0) FROM pf_savings WHERE holiday_id = h.id) as total_saved FROM pf_holidays h WHERE h.id = ? "); $stmt->execute([$id]); $holiday = $stmt->fetch(PDO::FETCH_ASSOC); // Récupération des items (étapes et frais généraux) $stmtItems = $pdo->prepare("SELECT * FROM pf_holidays_items WHERE holiday_id = ? ORDER BY sort_order ASC, id ASC"); $stmtItems->execute([$id]); $items = $stmtItems->fetchAll(PDO::FETCH_ASSOC); // Favoris pour le géocodage $stmtFav = $pdo->query("SELECT content FROM pf_notes WHERE note_type = 'holiday_favorites'"); $favorites = json_decode($stmtFav->fetchColumn() ?: '[]', true); $steps = []; $generalItems = []; foreach ($items as $it) { if ($it['location_name'] !== null) { $orderKey = $it['sort_order']; if (!isset($steps[$orderKey])) { $steps[$orderKey] = [ 'location_name' => $it['location_name'], 'lat' => (float)$it['lat'], 'lng' => (float)$it['lng'], 'sort_order' => $it['sort_order'], 'step_start_date' => $it['step_start_date'], 'step_end_date' => $it['step_end_date'], 'is_return' => (int)$it['is_return'], 'total_amount' => 0, 'items' => [] ]; } $steps[$orderKey]['items'][] = $it; $steps[$orderKey]['total_amount'] += (float)$it['amount']; } else { $generalItems[] = $it; } } ksort($steps); $mapPoints = array_values($steps); // Affichage de la date $dateDisplay = htmlspecialchars($holiday['period_hint'] ?? ''); if (empty($dateDisplay) && $holiday['start_date']) { $dateDisplay = date('d/m/Y', strtotime($holiday['start_date'])); if ($holiday['end_date']) $dateDisplay .= ' → ' . date('d/m/Y', strtotime($holiday['end_date'])); } $cost = (float)$holiday['total_cost']; $paid = (float)$holiday['total_paid']; $saved = (float)$holiday['total_saved']; $leftToPay = max(0, $cost - $paid); $pctPaid = $cost > 0 ? min(100, ($paid / $cost) * 100) : 0; $pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0; ?>

🍔 € | 🎁
: 💼 : :

0 || $holiday['budget_extra'] > 0): ?>
🌍
0): ?>
🍔
0): ?>
🎁
'🚗', 'accommodation' => '🏨', 'activity' => '🎫', default => '🏷️' }; ?>
📍 🏁
'🚗', 'accommodation' => '🏨', 'activity' => '🎫', default => '🏷️' }; ?>
📍

📝

📍

📅

``` --- ### 📄 Fichier : `modules/holidays/views/list.php` ```php query("SELECT DISTINCT YEAR(start_date) as yr FROM pf_holidays WHERE start_date IS NOT NULL ORDER BY yr DESC"); $availableYears = $yearStmt->fetchAll(PDO::FETCH_COLUMN); if (!in_array(date('Y'), $availableYears)) { $availableYears[] = date('Y'); rsort($availableYears); } $whereSQL = ""; $params = []; if ($selectedYear !== 'all') { $whereSQL = "WHERE YEAR(h.start_date) = ? OR h.start_date IS NULL"; $params[] = $selectedYear; } // 2. Récupération des voyages + Calculs $sql = " SELECT h.*, (COALESCE(h.budget_food, 0) + COALESCE(h.budget_extra, 0) + COALESCE(SUM(hi.amount), 0)) as total_cost, ((SELECT COALESCE(SUM(ABS(amount)), 0) FROM pf_expenses WHERE holiday_id = h.id) + (SELECT COALESCE(SUM(amount), 0) FROM pf_holidays_items WHERE holiday_id = h.id AND is_paid = 1)) as total_paid, (SELECT COALESCE(SUM(amount), 0) FROM pf_savings WHERE holiday_id = h.id) as total_saved FROM pf_holidays h LEFT JOIN pf_holidays_items hi ON h.id = hi.holiday_id $whereSQL GROUP BY h.id ORDER BY COALESCE(start_date, '2999-12-31') ASC "; $stmt = $pdo->prepare($sql); $stmt->execute($params); $holidays = $stmt->fetchAll(PDO::FETCH_ASSOC); $active = array_filter($holidays, fn($h) => in_array($h['status'], ['draft', 'planned', 'booked'])); $history = array_filter($holidays, fn($h) => in_array($h['status'], ['passed', 'archived'])); $globalLeftToPay = 0; foreach ($active as $h) { $globalLeftToPay += max(0, (float)$h['total_cost'] - (float)$h['total_paid']); } ?>

✈️

() :

prepare("SELECT * FROM pf_holidays_items WHERE holiday_id = ?"); $stmt->execute([$h['id']]); $items = $stmt->fetchAll(PDO::FETCH_ASSOC); $json = htmlspecialchars(json_encode(['main' => $h, 'items' => $items]), ENT_QUOTES, 'UTF-8'); $dateDisplay = htmlspecialchars($h['period_hint'] ?? ''); if (empty($dateDisplay) && $h['start_date']) { $dateDisplay = date('d/m/Y', strtotime($h['start_date'])); if ($h['end_date']) $dateDisplay .= ' → ' . date('d/m/Y', strtotime($h['end_date'])); } $statusClass = match($h['status']) { 'booked' => 'bg-green-100 text-green-800', 'planned' => 'bg-blue-100 text-blue-800', 'passed' => 'bg-gray-100 text-gray-600', default => 'bg-yellow-50 text-yellow-800' }; $cost = (float)$h['total_cost']; $paid = (float)$h['total_paid']; $saved = (float)$h['total_saved']; $leftToPay = max(0, $cost - $paid); $pctPaid = $cost > 0 ? min(100, ($paid / $cost) * 100) : 0; $pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0; $statusLabel = tr('hdl_status_' . $h['status']); echo "

".htmlspecialchars($h['title'])."

".strtoupper($statusLabel)."
👁️
🗓️ ".($dateDisplay ?: tr('hdl_dates_to_define'))."
".tr('hdl_total_budget')." ".number_format($cost, 0, ',', ' ')." €
✓ ".tr('hdl_paid')." : ".number_format($paid, 0, ',', ' ')." €
💼 ".tr('hdl_saved')." : ".number_format($saved, 0, ',', ' ')." €
⏳ ".tr('hdl_left_to_pay')." : ".number_format($leftToPay, 0, ',', ' ')." €
"; } ?> ``` --- ### 📄 Fichier : `modules/holidays/views/modal.php` ```php


🚗

🏨

🎫


``` --- ### 📄 Fichier : `modules/home/home.css` ```css /* modules/home/home.css */ /* === 1. CONFIGURATION DU FOND & DU BODY === */ body.pf-home { /* Image de fond desktop */ background-image: url("/modules/home/assets/img/background.jpg"); background-size: cover; background-position: center center; background-attachment: fixed; /* Effet parallax léger */ background-repeat: no-repeat; /* Couleur de texte par défaut sur la home (Blanc) */ color: #ffffff; } /* Voile sombre pour garantir la lisibilité du texte blanc */ body.pf-home::before { content: ""; position: fixed; inset: 0; background: rgba(15, 23, 42, 0.5); /* Bleu nuit semi-transparent */ z-index: -1; /* Derrière le contenu */ } /* === 2. HEADER SPÉCIFIQUE (Translucide) === */ body.pf-home .pf-header { background: rgba(36, 59, 83, 0.85); /* Ton ancien bleu sombre */ backdrop-filter: blur(8px); /* Effet verre dépoli */ border-bottom: 1px solid rgba(255, 255, 255, 0.1); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); } /* Adaptation des couleurs des liens du header sur fond sombre */ body.pf-home .pf-logo { color: #ffffff; } body.pf-home .pf-burger-btn { color: #ffffff; } body.pf-home .pf-nav-link { color: rgba(255, 255, 255, 0.8); } body.pf-home .pf-nav-link:hover { background: rgba(255, 255, 255, 0.2); color: #ffffff; } body.pf-home .pf-nav-link--active { background: rgba(255, 255, 255, 0.9); color: #243b53; /* Texte foncé sur fond blanc */ } /* Badge utilisateur */ body.pf-home .pf-user-badge { color: rgba(255, 255, 255, 0.9); } body.pf-home .pf-logout-btn { color: #fca5a5; } /* Rouge clair */ body.pf-home .pf-logout-btn:hover { background: rgba(255, 255, 255, 0.1); border-color: #fca5a5; } /* === 3. HERO SECTION (Message de Bienvenue) === */ .pf-hero { margin-top: 40px; margin-bottom: 60px; text-align: right; /* Aligné à droite comme demandé */ } .pf-hero h1 { color: #ffffff; font-size: 2.5rem; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.4); /* Ombre pour détacher le texte de l'image */ margin-bottom: 12px; } .pf-hero p { color: #e2e8f0; /* Gris très clair */ font-size: 1.2rem; font-weight: 500; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); } /* === 4. GRILLE DES MODULES === */ /* Titre de section "Modules" */ body.pf-home .pf-modules-grid { /* On ajoute un titre visuel ou on espace simplement */ margin-top: 20px; } /* Les cartes restent blanches, donc on force le texte foncé à l'intérieur */ .pf-module-card { background: rgba(255, 255, 255, 0.95); /* Presque opaque */ border: none; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); color: var(--text-main); /* Retour à la couleur sombre globale */ } .pf-module-card:hover { background: #ffffff; transform: translateY(-5px); box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3); } /* On s'assure que les titres et textes DANS les cartes ne sont pas blancs */ .pf-module-card h3.pf-card-title { color: var(--text-main); } .pf-module-card p.pf-card-desc { color: var(--text-muted); } /* === 5. MOBILE RESPONSIVE === */ @media (max-width: 768px) { body.pf-home { /* Image spécifique mobile */ background-image: url("/modules/home/assets/img/background_m.jpg"); background-attachment: scroll; /* Fixed bug souvent sur mobile, scroll est mieux */ } /* Sur mobile, on recentre le texte pour l'équilibre */ .pf-hero { text-align: center; margin-top: 20px; margin-bottom: 40px; } .pf-hero h1 { font-size: clamp(1.8rem, 6vw, 2.5rem); /* Typographie fluide */ } /* Ajustement header mobile */ body.pf-home .pf-header { padding: 0 16px; } } ``` --- ### 📄 Fichier : `test-engine.js` ```javascript /** * PachaFamily - Test Engine 🦙 * Moteur de tests automatisés (E2E) - Version Anti-Flakiness & QA Exhaustive */ const PachaTestEngine = { arena: document.getElementById("test-arena"), reportBox: document.getElementById("test-report"), doc: null, // ========================================== // 🛠️ HELPERS (BLINDÉS POUR LE QA) // ========================================== wait: (ms) => new Promise((r) => setTimeout(r, ms)), log: function (msg, icon = "ℹ️") { this.reportBox.value += `[${new Date().toLocaleTimeString()}] ${icon} ${msg}\n`; this.reportBox.scrollTop = this.reportBox.scrollHeight; }, assert: function (cond, msgPass, msgFail) { this.log(cond ? msgPass : msgFail || msgPass, cond ? "✅" : "❌"); }, // 📡 Intercepte les erreurs de l'application dans l'iframe pour les afficher dans le rapport injectIframeErrorCatcher: function () { if (!this.arena.contentWindow) return; this.arena.contentWindow.onerror = (msg, url, line) => { this.log(`Erreur App (Ligne ${line}): ${msg}`, "🔥"); }; const origError = this.arena.contentWindow.console.error; this.arena.contentWindow.console.error = (...args) => { this.log(`Console Error: ${args.join(" ")}`, "🔥"); origError.apply(this.arena.contentWindow.console, args); }; }, // 🛡️ Attend le rechargement de la page de manière stricte actionAndWaitForReload: async function (actionFn, timeout = 5000) { return new Promise((resolve, reject) => { let reloaded = false; this.arena.onload = () => { reloaded = true; this.doc = this.arena.contentWindow.document; this.arena.contentWindow.confirm = () => true; this.arena.contentWindow.alert = () => true; this.injectIframeErrorCatcher(); resolve(); }; // Exécute l'action et gère le rejet si elle plante Promise.resolve(actionFn()).catch((err) => reject(err)); setTimeout(() => { if (!reloaded) { this.log( "⚠️ Le rechargement n'a pas eu lieu. Suite du test...", "⏱️", ); resolve(); } }, timeout); }); }, load: async function (url) { this.log(`Chargement de la page : ${url}`, "🔄"); return new Promise((resolve) => { this.arena.onload = () => { this.doc = this.arena.contentWindow.document; this.arena.contentWindow.confirm = () => true; this.arena.contentWindow.alert = () => true; this.injectIframeErrorCatcher(); resolve(); }; this.arena.src = url; }); }, get: function (sel) { return this.doc.querySelector(sel); }, // 🖱️ Clic strict : Si l'élément n'existe pas, on DÉCLENCHE UNE ERREUR FATALE click: async function (sel, delay = 500) { const el = typeof sel === "string" ? this.get(sel) : sel; if (!el) { throw new Error( `Clic impossible : l'élément '${typeof sel === "string" ? sel : "DOM Object"}' est introuvable.`, ); } this.arena.contentWindow.confirm = () => true; this.arena.contentWindow.alert = () => true; el.click(); await this.wait(delay); return true; }, fill: function (sel, val) { const el = this.get(sel); if (!el) throw new Error(`Remplissage impossible : '${sel}' introuvable.`); el.value = val; }, select: async function (sel, val, delay = 300) { const el = this.get(sel); if (!el) throw new Error(`Sélection impossible : '${sel}' introuvable.`); el.value = val; el.dispatchEvent(new Event("change")); await this.wait(delay); }, findInTable: function (text) { return Array.from(this.doc.querySelectorAll("td")).find((el) => el.textContent.includes(text), ); }, // ========================================== // 🧪 SCÉNARIO EXHAUSTIF : SUIVI MENSUEL (MODALES & CRUD) // ========================================== testBudgetSuiviExhaustive: async function () { this.reportBox.value = ""; this.log("=== 🚀 DÉBUT EXHAUSTIF : SUIVI MENSUEL ===", "INFO"); await this.load("budget.php?tab=suivi"); await this.wait(1000); // --- 1. GESTION DE L'ÉTAT (CLÔTURE / RÉOUVERTURE) --- this.log("🔍 Test 1 : État du mois (Clôture / Réouverture)"); let btnReopenInitial = this.get( 'form input[value="reopen_month"] ~ button[type="submit"]', ); if (btnReopenInitial) { this.log( "🔒 Mois actuellement fermé. Réouverture forcée pour les tests...", "WARN", ); await this.actionAndWaitForReload( async () => await this.click(btnReopenInitial), ); await this.wait(1500); // ⏱️ Attente de la 2e redirection JS du PHP this.assert( this.get('form input[value="close_month"]') !== null, "Mois rouvert.", ); } // --- 2. UI TOGGLES --- this.log("🔍 Test 2 : Toggle 'Charges à venir'"); const toggleBtn = this.get( "button[onclick*=\"toggleDiv('pendingDetailsList')\"]", ); if (toggleBtn) { await this.click(toggleBtn); const pendingList = this.get("#pendingDetailsList"); this.assert( pendingList && pendingList.style.display !== "none", "Liste des charges déployée.", ); } // --- 3. MODALE CSV --- this.log("🔍 Test 3 : Modale Import CSV"); const btnCsv = this.get('button[onclick*="importCsvModal"]'); if (btnCsv) { await this.click(btnCsv); const csvModal = this.get("#importCsvModal"); this.assert( csvModal && (csvModal.classList.contains("is-open") || csvModal.style.display === "flex"), "Modale CSV ouverte.", ); // Fermeture const closeCsvBtn = csvModal.querySelector( ".pf-modal-close, button[onclick*='closeSuiviModal']", ); if (closeCsvBtn) await this.click(closeCsvBtn); this.assert( !csvModal.classList.contains("is-open") && csvModal.style.display !== "flex", "Modale CSV fermée.", ); } // --- 4. MODALE SNAPSHOT (UPDATE BALANCE) --- this.log("🔍 Test 4 : Modale Snapshot (Solde Bancaire)"); const btnSnapshot = this.get('button[onclick*="snapshotModal"]'); if (btnSnapshot) { await this.click(btnSnapshot); this.assert(this.get("#snapshotModal"), "Modale Snapshot ouverte."); this.fill('#snapshotModal input[name="snapshot_amount"]', "1337.00"); this.log("Soumission du nouveau solde...", "INFO"); await this.actionAndWaitForReload(async () => { await this.click('#snapshotModal button[type="submit"]'); }); const pageText = this.doc.body.innerText; this.assert( pageText.includes("1 337"), "Solde mis à jour et formaté à 1 337 €.", ); } // --- 5. CRUD DÉPENSE (AJOUT / ÉDITION / SUPPRESSION) --- this.log("🔍 Test 5 : CRUD Dépense Manuelle"); const btnAdd = this.get(".btn-add-item"); if (btnAdd) { // 5A. AJOUT this.log("Création d'une nouvelle dépense..."); await this.click(btnAdd); await this.select("#modalCatSelect", "Autres"); this.fill("#modalAmount", "77.77"); this.fill("#modalLabelInput", "E2E_QA_TEST_EXPENSE"); await this.actionAndWaitForReload(async () => { await this.click('#manualExpenseModal button[type="submit"]'); }); let newExpense = this.findInTable("E2E_QA_TEST_EXPENSE"); this.assert( newExpense !== undefined, "Dépense 'E2E_QA_TEST_EXPENSE' trouvée dans le DOM.", ); if (newExpense) { // 5B. ÉDITION this.log("✏️ Édition de la dépense..."); const row = newExpense.closest("tr"); // 🛡️ NOUVEAU : Sécurité anti-crash if (!row) { throw new Error( "L'élément trouvé n'est pas dans une ligne de tableau ( manquant). Le test est corrompu.", ); } const btnEdit = row.querySelector('button[onclick*="openEditModal"]'); if (btnEdit) { await this.click(btnEdit); this.fill("#modalAmount", "88.88"); await this.actionAndWaitForReload(async () => { await this.click('#manualExpenseModal button[type="submit"]'); }); const editedExpense = this.findInTable("E2E_QA_TEST_EXPENSE"); this.assert( editedExpense && editedExpense.closest("tr").innerHTML.includes("88"), "Montant mis à jour à 88.88€.", ); } // 5C. SUPPRESSION (Gère pachaConfirm custom modal) this.log("🧹 Teardown : Suppression de la dépense..."); const rowToDel = this.findInTable("E2E_QA_TEST_EXPENSE").closest("tr"); const btnDel = rowToDel.querySelector("button.delete"); if (btnDel) { await this.actionAndWaitForReload(async () => { await this.click(btnDel); // Déclenche pachaConfirm await this.wait(500); // Attend l'animation de la modale custom const confirmBtn = this.get("#confirm-ok"); if (confirmBtn) { await this.click(confirmBtn); // Valide la suppression (fetch AJAX) } }, 3000); const checkGone = this.findInTable("E2E_QA_TEST_EXPENSE"); this.assert( checkGone === undefined, "Base de données propre ! Dépense supprimée.", ); } } } else { this.log("Bouton '.btn-add-item' introuvable.", "❌"); } // --- 6. CLÔTURE ET RÉOUVERTURE DU MOIS (TEARDOWN STATE) --- this.log("🔍 Test 6 : Clôture du mois"); let btnClose = this.get( 'form input[value="close_month"] ~ button[type="submit"]', ); if (btnClose) { this.log("Verrouillage du mois..."); await this.actionAndWaitForReload(async () => await this.click(btnClose)); await this.wait(1500); // ⏱️ Attente de la 2e redirection JS du PHP (Mois suivant) this.log("Retour au mois précédent pour vérification..."); await this.actionAndWaitForReload( async () => await this.click(".suivi-nav-group a.suivi-btn-nav:first-child"), ); this.assert( this.get('form input[value="reopen_month"]') !== null, "Mois clôturé avec succès.", ); // Teardown this.log("🧹 Teardown : Restauration du mois (Réouverture)"); await this.actionAndWaitForReload( async () => await this.click( 'form input[value="reopen_month"] ~ button[type="submit"]', ), ); await this.wait(1500); // ⏱️ Attente de la 2e redirection JS du PHP this.assert( this.get('form input[value="close_month"]') !== null, "Mois rouvert avec succès.", ); } this.log("=== 🏁 FIN DU SCÉNARIO EXHAUSTIF ===", "INFO"); }, // ========================================== // 🧪 SCÉNARIO 2 : BUDGET PRÉVISIONNEL // ========================================== testBudgetPrev: async function () { this.reportBox.value = ""; this.log("=== 🚀 DÉBUT DU SCÉNARIO : BUDGET PRÉVISIONNEL ===", "INFO"); await this.load("budget.php?tab=budget_prev"); await this.wait(1000); this.log("🔍 Étape 1 : Test du mode Somme flottant...", "INFO"); await this.click("#fabSumMode"); this.assert( this.doc.body.classList.contains("sum-mode-active"), "Le mode somme s'active.", ); const firstSalaryInput = this.get('input[data-field="salary"]'); if (firstSalaryInput) { await this.click(firstSalaryInput, 500); const sumValue = this.get("#sumResultValue").innerText; this.assert( sumValue !== "0,00 €" && sumValue !== "0 €", `Le total interactif affiche : ${sumValue}`, ); } await this.click(".pf-sum-close"); this.assert( !this.doc.body.classList.contains("sum-mode-active"), "Le mode somme se désactive.", ); this.log("🔍 Étape 2 : Test de sauvegarde de la note...", "INFO"); const noteArea = this.get("#monthNoteArea"); if (noteArea) { const oldNote = noteArea.value; this.fill("#monthNoteArea", "🤖 Test Auto"); await this.click('button[onclick^="saveGenericNote"]', 1500); const indicator = this.get("#note-save-indicator"); this.assert(indicator !== null, "Indicateur de sauvegarde affiché."); this.fill("#monthNoteArea", oldNote); await this.click('button[onclick^="saveGenericNote"]', 500); } const uniqueCatName = "TEST_CAT_" + Math.floor(Math.random() * 10000); this.log( `🔍 Étape 3 : Création de la catégorie '${uniqueCatName}'...`, "INFO", ); await this.click('button[onclick*="addCatModal"]'); this.fill('#addCatModal input[name="name"]', uniqueCatName); await this.select('#addCatModal select[name="target"]', "vers commune"); await this.actionAndWaitForReload(async () => { await this.click('#addCatModal button[type="submit"]'); }); const newCatCell = Array.from( this.doc.querySelectorAll(".prev-alloc-table td, .prev-alloc-table div"), ).find((el) => el.textContent.includes(uniqueCatName)); this.assert( newCatCell !== undefined, `SUCCÈS : '${uniqueCatName}' insérée !`, ); if (newCatCell) { this.log("🧹 Étape 4 : Nettoyage de la BDD...", "WARN"); const row = newCatCell.closest("tr"); const delBtn = row.querySelector(".delete"); if (delBtn) { await this.actionAndWaitForReload(async () => { await this.click(delBtn); }); const checkGone = Array.from( this.doc.querySelectorAll(".prev-alloc-table td"), ).find((td) => td.textContent.includes(uniqueCatName)); this.assert( checkGone === undefined, "NETTOYAGE PARFAIT : Ligne effacée.", ); } } this.log("=== 🏁 FIN DU SCÉNARIO ===", "INFO"); }, }; // ========================================== // ROUTEUR DU LABORATOIRE ET BOUTON COPIER // ========================================== document.getElementById("btn-run-test")?.addEventListener("click", async () => { const selectedTest = document.getElementById("test-selector").value; const btnRun = document.getElementById("btn-run-test"); btnRun.disabled = true; btnRun.style.opacity = "0.5"; try { switch (selectedTest) { case "budget_suivi_exhaustive": await PachaTestEngine.testBudgetSuiviExhaustive(); break; case "budget_prev": await PachaTestEngine.testBudgetPrev(); break; default: PachaTestEngine.log(`Scénario non implémenté : ${selectedTest}`, "⚠️"); } } catch (error) { PachaTestEngine.log(`Erreur JS critique : ${error.message}`, "❌"); console.error(error); } btnRun.disabled = false; btnRun.style.opacity = "1"; }); // 📋 Bouton "Copier" document .getElementById("btn-copy-report") ?.addEventListener("click", async () => { const textArea = document.getElementById("test-report"); const reportText = textArea.value; const successMsg = window.I18N ? window.I18N.tests_report_copied || "Rapport copié !" : "Rapport copié !"; try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(reportText); alert(successMsg); } else { textArea.select(); document.execCommand("copy"); window.getSelection().removeAllRanges(); alert(successMsg); } } catch (err) { alert("Erreur lors de la copie du rapport."); console.error(err); } }); ``` --- ### 📄 Fichier : `tests.php` ```php

🧪

Live Preview
``` ---