diff --git a/family-calendar.php b/family-calendar.php index 2d4a1d4..0686738 100644 --- a/family-calendar.php +++ b/family-calendar.php @@ -1,5 +1,7 @@ query("SELECT * FROM pf_events"); -$dbEvents = $stmt_events->fetchAll(); +$dbEvents = $stmt_events->fetchAll(PDO::FETCH_ASSOC); $pageTitle = "PachaFamily - Family Calendar"; $activePage = "family-calendar"; @@ -18,75 +21,47 @@ $bodyClass = "pf-family-calendar"; $pageCss = "/modules/family-calendar/family-calendar.css"; require __DIR__ . '/header.php'; - ?> - - - - -

Family Calendar

+
+

Family Calendar

+
- - -
-

Légende

-
-
- Vacances scolaires -
-
-
- Jour férié -
-
-
- Off Carole -
-
-
- Extra Off Carole -
-
-
- Centre -
-
-
- Avis -
-
-
- Pep malade +
+
Vacances
+
Férié
+
Off Carole
+
Extra Off
+
Centre
+
Avis
+
Pep Malade
- -

Récapitulatif annuel

- + Chargement...
-
-

Vacances scolaires - Zone C (2025-2026)

+

Vacances scolaires - Zone C

-
- +
+
@@ -96,8 +71,7 @@ require __DIR__ . '/header.php'; - - +
Période
@@ -106,12 +80,10 @@ require __DIR__ . '/header.php';
- - -

Calendrier mensuel

+
@@ -124,18 +96,16 @@ require __DIR__ . '/header.php';
-
+ +
+
+
- -
+
-
- - -

Planning hebdo

@@ -147,29 +117,15 @@ require __DIR__ . '/header.php';
- +
+ - - - - - - - - - - - - - - + - - @@ -177,28 +133,27 @@ require __DIR__ . '/header.php'; + - - - - - - - - - - - - + + + + + + + + + + + + - - - + + - @@ -208,15 +163,12 @@ require __DIR__ . '/header.php'; - - - + - @@ -225,54 +177,16 @@ require __DIR__ . '/header.php'; -
MoisSemaineLundiMardiMercrediJeudiVendredi# Off Carole# Extra off Carole# Centre# Avis# Pep malade# Pep PrésenceSem.LunMarMerJeuVenOff CaroleExtra OffCentreAvisPep MaladePrésenceALEXLAIAALEXLAIA
CP JRA JAJA
Av.Av. Use Av. Use Av. Use Av. Use Av.Use
- + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
- -
+
- - - - + \ No newline at end of file diff --git a/gift-list.php b/gift-list.php index 71a9e34..4ca5293 100644 --- a/gift-list.php +++ b/gift-list.php @@ -1,627 +1,535 @@ ['TIO','NOEL','ROIS'], - 'anniversary' => ['ANNIV','SANT'], + 'nadal' => ['TIO', 'NOEL', 'ROIS'], + 'anniversary' => ['ANNIV', 'SANT'], ]; -// Vue courante (URL > Session > défaut) +// Vue courante $currentView = strtolower($_GET['view'] ?? ($_SESSION['gift_view'] ?? 'nadal')); if (!isset($VIEWS[$currentView])) $currentView = 'nadal'; $_SESSION['gift_view'] = $currentView; $allowedOccasions = $VIEWS[$currentView]; -// En vue anniversary, on ajoute 3 adultes pour Pol et Pep uniquement +// Logique spécifique : Adultes supplémentaires pour Anniversaires $extraAdults = ['Pauline', 'Papy JC', 'Mamy Caro']; $adultsByChildForAnniv = [ - 'Pol' => array_merge($baseAdults, $extraAdults), - 'Pep' => array_merge($baseAdults, $extraAdults), - 'Elna' => $baseAdults, - 'Bru' => $baseAdults, - 'Guim' => $baseAdults, + 'Pol' => array_merge($baseAdults, $extraAdults), + 'Pep' => array_merge($baseAdults, $extraAdults), + 'Elna' => $baseAdults, + 'Bru' => $baseAdults, + 'Guim' => $baseAdults, ]; - - -// Labels d’occasion (affichage) +// Labels & Icônes $allOccasionLabels = [ - 'TIO' => 'Tió', - 'NOEL' => 'Nadal', - 'ROIS' => 'Reis', - 'ANNIV' => 'Anniversary', - 'SANT' => 'Sant', + 'TIO' => 'Tió', + 'NOEL' => 'Nadal', + 'ROIS' => 'Reis', + 'ANNIV' => 'Anniversary', + 'SANT' => 'Sant', ]; - -// Icônes (pense à ajouter les 2 nouvelles images) $occasionIcons = [ - 'TIO' => '/modules/gift-list/assets/img/tio.png', - 'NOEL' => '/modules/gift-list/assets/img/santa.png', - 'ROIS' => '/modules/gift-list/assets/img/reis.png', - 'ANNIV' => '/modules/gift-list/assets/img/corona.png', - 'SANT' => '/modules/gift-list/assets/img/sant.png', + 'TIO' => '/modules/gift-list/assets/img/tio.png', + 'NOEL' => '/modules/gift-list/assets/img/santa.png', + 'ROIS' => '/modules/gift-list/assets/img/reis.png', + 'ANNIV' => '/modules/gift-list/assets/img/corona.png', + 'SANT' => '/modules/gift-list/assets/img/sant.png', ]; -// Nom de table (renommée -> pf_gifts), fallback si pas encore migrée $tableGifts = 'pf_gifts'; -// Récup données filtrées par vue +// --- 2. RÉCUPÉRATION DES DONNÉES --- + +// Préparation requête $inMarks = implode(',', array_fill(0, count($allowedOccasions), '?')); -$sql = " - SELECT * - FROM {$tableGifts} - WHERE year = ? AND occasion IN ($inMarks) - ORDER BY adult_name, child_name, occasion, created_at -"; +$sql = "SELECT * FROM {$tableGifts} WHERE year = ? AND occasion IN ($inMarks) ORDER BY adult_name, child_name, occasion, created_at"; $stmt = $pdo->prepare($sql); $params = array_merge([$year], $allowedOccasions); $stmt->execute($params); $gifts = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; -// Index [occasion][child][adult] +// Indexation [occasion][child][adult] pour la matrice $byOccasion = []; foreach ($gifts as $gift) { - $o = $gift['occasion']; - $c = $gift['child_name']; - $a = $gift['adult_name']; - $byOccasion[$o][$c][$a][] = $gift; + $byOccasion[$gift['occasion']][$gift['child_name']][$gift['adult_name']][] = $gift; } -// Occasions à afficher pour la vue +// Occasions à afficher $occasionsToShow = array_values(array_intersect(array_keys($allOccasionLabels), $allowedOccasions)); -?> -
-
-

Llista de regals

- -
- -
-

Vista per festa

+// --- 3. CALCUL TRICOUNT (Backend) --- +// On pré-calcule ici pour alléger la vue HTML - -

No hi ha cap regal registrat per a en aquesta vista.

- +$people = $baseAdults; +$adultsInDb = array_column($gifts, 'adult_name'); +$payersInDb = array_column($gifts, 'payer_name'); // Peut contenir des NULL si colonne vide, mais array_unique gère +// Nettoyage et fusion des participants +$people = array_values(array_unique(array_merge($people, $adultsInDb, $payersInDb))); +$people = array_filter($people); // Enlève les vides éventuels - -
-

- - - - -

+// Matrice de dettes +$matrix = []; +foreach ($people as $p1) { + foreach ($people as $p2) $matrix[$p1][$p2] = 0.0; +} -
- - (float)$g['amount'], $lists[$adultName])); - } - // max() compatible PHP 8 - $counts = array_map('count', $lists); - $maxRowsChild = !empty($counts) ? max($counts) : 0; - ?> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - -
-
- - - € - -
-
- - -
-
- - - - - -
- ( €) - - - - - -
-
- - - (pagat per ) - - -
- - - -
- - -
-
- -
- - -
-

Resum del pressupost

- prepare($sqlSum); - $stmtSum->execute($params); - $sums = $stmtSum->fetchAll(PDO::FETCH_ASSOC); - ?> -
- - - - - - - - - - - - - - -
AdultInfantFestaTotal
-
-
- - - 0 && $adult && $payer && $adult !== $payer) { + if (isset($matrix[$adult][$payer])) { + $matrix[$adult][$payer] += $amt; + } } - foreach ($gifts as $g) { - $adult = $g['adult_name']; - $payer = $g['payer_name'] ?? $g['adult_name']; - $amt = (float)$g['amount']; - if ($amt > 0 && $adult !== $payer) { - $matrix[$adult][$payer] += $amt; - } - } - $settlements = []; - for ($i = 0; $i < count($people); $i++) { - for ($j = $i + 1; $j < count($people); $j++) { - $a = $people[$i]; $b = $people[$j]; +} + +// Résolution des dettes (Liquidations) +$settlements = []; +$countPeople = count($people); +for ($i = 0; $i < $countPeople; $i++) { + for ($j = $i + 1; $j < $countPeople; $j++) { + $a = $people[$i]; + $b = $people[$j]; $net = $matrix[$a][$b] - $matrix[$b][$a]; - if ($net > 0.009) $settlements[] = [$a, $b, $net]; - elseif ($net < -0.009) $settlements[] = [$b, $a, -$net]; - } + + if ($net > 0.01) { + $settlements[] = ['from' => $a, 'to' => $b, 'amount' => $net]; + } elseif ($net < -0.01) { + $settlements[] = ['from' => $b, 'to' => $a, 'amount' => -$net]; + } } - ?> -
-

Tricount

-
- - - - - - - - - - - - - 0 ? 'cl-mtx-owe' : 'cl-mtx-empty'); - ?> - - - - - -
- Deutor ↓Creditor → -
-
-

Liquidacions

- -

Cap deute pendent.

- - - -
+} - -
-

Llista detallada de regals

-
- - - - - - - - - - - - - - - - -
AdultInfantFestaRegalEnllaç
- - 🔗 - -
+// --- 4. DÉBUT DU RENDU HTML --- +require __DIR__ . '/header.php'; +?> + +
+ +
+

Llista de regals

+
-
+ +
+

Vista per festa

+ + +

No hi ha cap regal registrat per a en aquesta vista.

+ + + +
+

+ + + + +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + +
+
+ + +
+
+ + + + + + +
+ ( €) + + + + +
+
+ + + (pagat per ) + +
+ + + +
+ +
+
+ +
+ +
+

Resum del pressupost

+ prepare("SELECT adult_name, child_name, occasion, SUM(amount) AS total FROM {$tableGifts} WHERE year = ? AND occasion IN ($inMarks) GROUP BY adult_name, child_name, occasion ORDER BY adult_name, child_name, occasion"); + $stmtSum->execute($params); + $sums = $stmtSum->fetchAll(PDO::FETCH_ASSOC); + ?> +
+ + + + + + + + + + + + + + +
AdultInfantFestaTotal
+
+
+ +
+

Tricount

+
+ + + + + + + + + + + + + 0 ? 'cl-mtx-owe' : 'cl-mtx-empty'); + $display = $isDiag || $val == 0 ? '—' : number_format($val, 0, ',', ' ') . ' €'; + ?> + + + + + +
Deutor ↓Creditor →
+
+ +

Liquidacions

+ +

Cap deute pendent.

+ + + +
+ +
+

Llista detallada de regals

+
+ + + + + + + + + + + + + + + + +
AdultInfantFestaRegalEnllaç
+ + 🔗 + +
+
+
- - - - \ No newline at end of file diff --git a/holidays.php b/holidays.php index dba16ac..5be4281 100644 --- a/holidays.php +++ b/holidays.php @@ -1,16 +1,21 @@ strong { - display: block; - margin-bottom: 4px; - font-weight: 600; - color: #111827; /* gris très foncé */ -} - -/* Grille pour disposer proprement les boutons dans une section */ -.fc-menu-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 4px; -} - -/* Boutons génériques du menu */ -.fc-selection-menu button.fc-menu-btn, -.fc-selection-menu button.fc-menu-leave-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 100%; - border-radius: 4px; - border: 1px solid #e5e7eb; - padding: 4px 6px; - font-size: 12px; - background: #f9fafb; - color: #111827; - cursor: pointer; - transition: - background-color 0.15s ease, - border-color 0.15s ease, - box-shadow 0.15s ease; -} - -.fc-selection-menu button.fc-menu-btn:hover, -.fc-selection-menu button.fc-menu-leave-btn:hover { - background-color: #e5f0ff; /* léger bleu */ - border-color: #93c5fd; - box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.3); -} - -/* Variantes par type pour un léger hint visuel -.fc-menu-btn--conge { - congés Carole -} - -.fc-menu-btn--garde { - mode de garde -} - -.fc-menu-btn--pep { - Pep malade -} */ - -/* Boutons "danger" (suppression) */ -.fc-selection-menu button.fc-menu-danger { - display: block; - width: 100%; - margin-top: 6px; - padding: 4px 6px; - border-radius: 4px; - border: 1px solid #fecaca; - background-color: #fef2f2; - color: #b91c1c; - font-size: 12px; - cursor: pointer; - text-align: left; -} - -.fc-selection-menu button.fc-menu-danger:hover { - background-color: #fee2e2; - border-color: #fca5a5; -} - -/* Tableau des congés Alex / Laia dans le menu */ -.fc-menu-leaves-table { - margin-top: 4px; -} - -.fc-menu-leaves-table table { - width: 100%; - border-collapse: collapse; - font-size: 11px; -} - -.fc-menu-leaves-table th, -.fc-menu-leaves-table td { - padding: 2px; - text-align: center; -} - -.fc-menu-leaves-table thead th { - font-weight: 600; - color: #374151; - border-bottom: 1px solid #e5e7eb; -} - -.fc-menu-leaves-table tbody td:first-child { - text-align: left; - font-weight: 500; - color: #4b5563; -} - -/* --- Couleurs de base pour la légende et les jours --- */ -.fc-day--school-holiday { - background-color: #e5d9f2; -} -.fc-day--public-holiday { - background-color: #e0e0e0; -} -.fc-day--off-carole { - background-color: #ffe9a7; -} -.fc-day--extra-off-carole { - background-color: #ffd59b; -} -.fc-day--has-guard { - box-sizing: border-box; -} - -/* Picto pour Centre */ -.fc-day--has-guard.fc-day--centre { - position: relative; -} -.fc-day--has-guard.fc-day--centre::after { - content: "🏫"; - position: absolute; - top: 2px; - right: 2px; - font-size: 14px; - line-height: 1; - z-index: 2; -} -/* Planning hebdo : picto Centre en haut à droite */ -#planningTable .fc-day--has-guard.fc-day--centre::after { - content: "🏫"; - position: absolute; - top: 2px; - right: 2px; - font-size: 12px; - line-height: 1; - z-index: 2; -} - -/* Combinaisons avec dégradés */ -.fc-day--school-holiday.fc-day--off-carole { - background-image: linear-gradient(45deg, #e5d9f2 49%, #ffe9a7 51%); -} -.fc-day--school-holiday.fc-day--extra-off-carole { - background-image: linear-gradient(45deg, #e5d9f2 49%, #ffd59b 51%); -} - -/* Légende */ .pf-legend-item { display: flex; align-items: center; - margin-bottom: 5px; + gap: 6px; + font-size: 0.85rem; } - .pf-legend-color { width: 18px; height: 18px; border-radius: 4px; - border: 1px solid #d9e2ec; - margin-right: 8px; -} - -.pf-legend-item span { - font-size: 12px; - color: #334e68; + border: 1px solid rgba(0, 0, 0, 0.1); + display: flex; + align-items: center; + justify-content: center; } .fc-legend-school-holiday { - background-color: #e5d9f2; + background: var(--c-school-holiday); } - .fc-legend-public-holiday { - background-color: #e0e0e0; + background: var(--c-public-holiday); } - .fc-legend-off-carole { - background-color: #ffe9a7; + background: var(--c-off-carole); } - .fc-legend-extra-off-carole { - background-color: #ffd59b; + background: var(--c-extra-off); } - .fc-legend-centre { - display: flex; - justify-content: center; - align-items: center; - font-size: 16px; - background: none; - border: none; + background-color: white; } - .fc-legend-centre::before { content: "🏫"; + font-size: 14px; } - .fc-legend-pep-sick { - display: flex; - justify-content: center; - align-items: center; - font-size: 16px; - background: none; - border: none; + background-color: white; } - .fc-legend-pep-sick::before { content: "🤒"; -} - -.fc-pep-sick-emoji { - position: absolute; - right: 2px; - bottom: 2px; font-size: 14px; - line-height: 1; } - -#planningTable .fc-pep-sick-emoji { - font-size: 12px; - right: 2px; - bottom: 2px; -} - -/* Avis indicateur */ .fc-legend-avis { - border-radius: 2px; width: 24px; height: 16px; - background: linear-gradient( + background: repeating-linear-gradient( to bottom, - #ffcc00 0%, - #ffcc00 11%, - #c8102e 11%, - #c8102e 22%, - #ffcc00 22%, - #ffcc00 33%, - #c8102e 33%, - #c8102e 44%, - #ffcc00 44%, - #ffcc00 55%, - #c8102e 55%, - #c8102e 66%, - #ffcc00 66%, - #ffcc00 77%, - #c8102e 77%, - #c8102e 88%, - #ffcc00 88%, - #ffcc00 100% + #fcd116, + #fcd116 2px, + #ce1126 2px, + #ce1126 4px ); - box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2); + border: 1px solid #ce1126; } -/* === CALENDRIER MENSUEL === */ +/* --- 3. PLANNING HEBDO --- */ -.fc-day--has-guard.fc-day--avis { +/* --- HEADER PLANNING HEBDO (Harmonisé avec Calendrier Mensuel) --- */ + +.fc-week-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; /* Même marge que le calendrier mensuel */ + padding-bottom: 0; + gap: 16px; + flex-wrap: wrap; +} + +/* Le titre "Planning hebdo" */ +.fc-week-header h2 { + margin: 0; + font-size: 1.25rem; /* Même taille que le titre du mois */ + font-weight: 800; + color: var(--text-main); + text-transform: none; /* On garde la casse normale ou uppercase selon préférence */ + border: none; + padding: 0; +} + +/* Conteneur des flèches et de la date */ +.fc-week-nav-controls { + display: flex; + align-items: center; + gap: 16px; /* Espace identique au header mensuel */ + background: white; + padding: 4px 12px; + border-radius: 50px; /* Petit conteneur pilule optionnel pour lier le tout */ + border: 1px solid transparent; /* ou var(--border-color) si tu veux un cadre */ +} + +/* LE TEXTE DE L'ANNÉE (Cible de ta demande) */ +#fc-current-school-year-label { + font-size: 1.1rem; + font-weight: 800; + color: var(--text-main); + text-align: center; + letter-spacing: -0.02em; + min-width: 120px; /* Empêche le saut quand les chiffres changent */ + + /* Pour l'alignement vertical parfait avec les boutons */ + display: inline-block; + line-height: 1; + padding-top: 2px; +} + +/* Ajustement spécifique pour les boutons de navigation dans ce header */ +.fc-week-nav-controls .fc-nav-button { + /* On s'assure qu'ils ont bien la taille définie globalement */ + width: 32px; + height: 32px; + font-size: 16px; + background: white; /* Fond blanc */ + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.fc-week-nav-controls .fc-nav-button:hover { + background: var(--primary); + color: white; + border-color: var(--primary); +} +#planningTable-wrapper { + max-height: 80vh; + overflow: auto; + border: 1px solid var(--border-color); + border-radius: 8px; + background: white; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); position: relative; } -.fc-day--has-guard.fc-day--avis::after { - content: ""; +#planningTable { + width: 100%; + border-collapse: separate; + border-spacing: 0; + table-layout: fixed; +} + +/* Sticky Header */ +#planningTable thead { + position: sticky; + top: 0; + z-index: 20; + background: var(--bg-header); +} +#planningTable thead th { + position: sticky; + top: 0; + background: var(--bg-header); + border-bottom: 1px solid var(--border-color); + border-right: 1px solid var(--border-color); + padding: 6px; + font-size: 0.75rem; + color: var(--text-main); + z-index: 20; + box-shadow: 0 1px 0 var(--border-color); +} + +/* Gestion hauteurs Sticky */ +#planningTable thead tr:nth-child(1) th { + top: 0; + height: 32px; +} +#planningTable thead tr:nth-child(2) th { + top: 32px; + height: 28px; + z-index: 19; +} +#planningTable thead tr:nth-child(3) th { + top: 60px; + height: 24px; + z-index: 19; + border-bottom: 2px solid var(--border-color); +} + +/* En-têtes Alex / Laia (Couleurs mises à jour) */ +.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); +} + +/* Cellules */ +#planningTable tbody td { + border-right: 1px solid #f1f5f9; + border-bottom: 1px solid #f1f5f9; + height: 36px; + padding: 0 4px; + text-align: center; + font-size: 0.85rem; + white-space: nowrap; + overflow: hidden; + cursor: pointer; + position: relative; +} + +/* Couleurs Cellules */ +.fc-day--school-holiday { + background: var(--c-school-holiday); +} +.fc-day--public-holiday { + background: var(--c-public-holiday); + font-weight: 700; +} +.fc-day--off-carole { + background: var(--c-off-carole); +} +.fc-day--extra-off-carole { + background: var(--c-extra-off); +} +.fc-day--selected { + background: var(--c-selected) !important; + outline: 2px solid var(--primary); + z-index: 5; +} + +/* Indicateurs */ +.fc-day--centre::after { + content: "🏫"; position: absolute; top: 2px; right: 2px; + font-size: 12px; + line-height: 1; + left: auto; + transform: none; +} +.fc-day--avis::after { + content: ""; + position: absolute; + top: 4px; + right: 4px; width: 14px; - height: 8px; + height: 10px; border-radius: 2px; - background: linear-gradient( + background: repeating-linear-gradient( to bottom, - #ffcc00 0%, - #ffcc00 11%, - #c8102e 11%, - #c8102e 22%, - #ffcc00 22%, - #ffcc00 33%, - #c8102e 33%, - #c8102e 44%, - #ffcc00 44%, - #ffcc00 55%, - #c8102e 55%, - #c8102e 66%, - #ffcc00 66%, - #ffcc00 77%, - #c8102e 77%, - #c8102e 88%, - #ffcc00 88%, - #ffcc00 100% + #fcd116, + #fcd116 2px, + #ce1126 2px, + #ce1126 4px ); - box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2); - z-index: 2; + border: 1px solid #b91c1c; +} +.fc-pep-sick-emoji { + position: absolute; + bottom: 2px; + right: 2px; + font-size: 12px; + line-height: 1; } +/* Colonnes spécifiques */ +.col-alex-av, +.col-laia-av { + background: #f8fafc; + color: var(--text-muted); + cursor: default; +} +.col-alex-sub { + background: var(--bg-alex); +} +.col-laia-sub { + background: var(--bg-laia); +} + +.col-month { + width: 50px; + background: white; + font-weight: 600; + border-right: 2px solid var(--border-color) !important; +} +.col-day { + width: 40px; + border-right: 2px solid var(--border-color) !important; +} +.col-total { + width: 30px; +} +.col-alex-sub, +.col-laia-sub { + width: 35px; +} + +.rotated-text span { + writing-mode: vertical-rl; + transform: rotate(180deg); + white-space: nowrap; + font-size: 0.7rem; + color: var(--text-muted); +} + +/* --- 4. CALENDRIER MENSUEL (Modernisé) --- */ .fc-month-calendar-wrapper { - background: #fff; - border-radius: 8px; - padding: 16px; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); - border: 1px solid #d9e2ec; -} - -.fc-calendar-and-summary { - display: block; - position: relative; + background: white; + border: 1px solid var(--border-color); + border-radius: 16px; /* Plus arrondi */ + padding: 24px; + margin-bottom: 24px; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05); } .fc-month-header { display: flex; - align-items: center; justify-content: space-between; - margin-bottom: 16px; - padding-bottom: 12px; - border-bottom: 2px solid #d9e2ec; + align-items: center; + margin-bottom: 20px; flex-wrap: wrap; - gap: 12px; -} - -.fc-view-controls { - display: flex; - gap: 8px; -} - -.fc-view-button { - background: #f0f4f8; - border: 1px solid #d9e2ec; - border-radius: 4px; - padding: 6px 12px; - font-size: 13px; - cursor: pointer; - color: #243b53; - transition: all 0.2s; -} - -.fc-view-button:hover { - background: #d9e2ec; - border-color: #bcccdc; -} - -.fc-view-button--active { - background: #243b53; - color: #fff; - border-color: #243b53; -} - -.fc-view-button--active:hover { - background: #102a43; - border-color: #102a43; + gap: 16px; } +/* NAVIGATION (Flèches) */ .fc-nav-controls { display: flex; align-items: center; - gap: 12px; -} - -.fc-month-header h3 { - margin: 0; - font-size: 18px; - font-weight: 600; - color: #243b53; - min-width: 200px; - text-align: center; + gap: 16px; + order: 2; /* Pour s'assurer qu'il est bien placé */ } .fc-nav-button { - background: #f0f4f8; - border: 1px solid #d9e2ec; - border-radius: 4px; - padding: 6px 12px; + width: 36px; + height: 36px; + border-radius: 50%; + border: 1px solid var(--border-color); + background: white; + color: var(--text-main); font-size: 18px; + display: flex; + align-items: center; + justify-content: center; cursor: pointer; - color: #243b53; - transition: all 0.2s; + transition: all 0.2s ease; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); } .fc-nav-button:hover { - background: #d9e2ec; - border-color: #bcccdc; + border-color: var(--primary); + color: var(--primary); + transform: translateY(-1px); + box-shadow: 0 4px 6px rgba(59, 130, 246, 0.15); } +.fc-nav-button:active { + transform: translateY(0); +} + +/* TITRE DU MOIS */ +#fc-current-month-year { + margin: 0; + font-size: 1.25rem; + font-weight: 800; + color: var(--text-main); + text-transform: capitalize; + min-width: 180px; + text-align: center; + letter-spacing: -0.02em; +} + +/* BOUTONS DE VUE (Segmented Control Style) */ +.fc-view-controls { + display: inline-flex; + background: #f1f5f9; /* Gris clair de fond */ + padding: 4px; + border-radius: 12px; + gap: 0; /* Collés */ + order: 1; +} + +.fc-view-button { + background: transparent; + border: none; + color: var(--text-muted); + padding: 8px 16px; + border-radius: 8px; /* Un peu moins que le conteneur */ + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.fc-view-button:hover { + color: var(--text-main); +} + +.fc-view-button--active { + background: white; + color: var(--primary); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + font-weight: 700; +} + +/* RESTE DU CALENDRIER (Inchangé mais nécessaire pour le contexte) */ .fc-month-calendar { width: 100%; + display: block; } - .fc-month-table { width: 100%; border-collapse: collapse; - font-size: 13px; + table-layout: fixed; } - -.fc-month-table thead th { - background: #f0f4f8; - padding: 8px 4px; - text-align: center; - font-weight: 600; - color: #243b53; - border: 1px solid #d9e2ec; +.fc-month-table th { + background: #f8fafc; + padding: 10px; + border: 1px solid var(--border-color); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); } - -.fc-month-table tbody td { - border: 1px solid #d9e2ec; - padding: 8px 4px; - text-align: center; +.fc-month-table td { + border: 1px solid var(--border-color); + height: 80px; vertical-align: top; - min-height: 60px; - height: 60px; + padding: 6px; position: relative; cursor: pointer; - transition: background-color 0.2s; } - -.fc-month-summary-inline { - margin-top: 8px; - background: #f8fafc; - border-radius: 6px; - border: 1px solid #d9e2ec; - font-size: 12px; - padding: 6px 8px; -} - -.fc-month-summary-inline table { - width: 100%; - border-collapse: collapse; -} - -.fc-month-summary-inline th, -.fc-month-summary-inline td { - padding: 4px 6px; - text-align: center; -} - -.fc-month-summary-inline th { - font-weight: 600; - color: #243b53; - border-bottom: 1px solid #d9e2ec; -} - -.fc-month-summary-inline td:first-child { - text-align: left; -} - -.fc-month-day { - background: #fff; -} - -.fc-month-day:hover { - background: #f8fafc !important; -} - .fc-day--other-month { - background: #f0f4f8; - color: #9fb3c8; - box-shadow: none; + background: #fcfcfc; } -.fc-day--weekend { - background: #f8fafc; -} - -.fc-day--weekend.fc-day--school-holiday, -.fc-day--weekend.fc-day--public-holiday, -.fc-day--weekend.fc-day--off-carole, -.fc-day--weekend.fc-day--extra-off-carole { - background: inherit; -} - -/* Application des couleurs existantes au calendrier mensuel */ -.fc-month-table .fc-day--school-holiday { - background-color: #e5d9f2; -} - -.fc-month-table .fc-day--public-holiday { - background-color: #e0e0e0; -} - -.fc-month-table .fc-day--off-carole { - background-color: #ffe9a7; -} - -.fc-month-table .fc-day--extra-off-carole { - background-color: #ffd59b; -} - -.fc-month-table .fc-day--has-guard { - box-sizing: border-box; -} - -.fc-month-table .fc-day--has-guard.fc-day--centre { - position: relative; -} - -.fc-month-table .fc-day--has-guard.fc-day--centre::after { - content: "🏫"; - position: absolute; - top: 2px; - right: 2px; - font-size: 12px; - line-height: 1; - z-index: 2; -} - -.fc-month-table .fc-day--has-guard.fc-day--avis { - position: relative; -} - -.fc-month-table .fc-day--has-guard.fc-day--avis::after { - content: ""; - position: absolute; - top: 2px; - right: 2px; - width: 12px; - height: 7px; - border-radius: 2px; - background: linear-gradient( - to bottom, - #ffcc00 0%, - #ffcc00 11%, - #c8102e 11%, - #c8102e 22%, - #ffcc00 22%, - #ffcc00 33%, - #c8102e 33%, - #c8102e 44%, - #ffcc00 44%, - #ffcc00 55%, - #c8102e 55%, - #c8102e 66%, - #ffcc00 66%, - #ffcc00 77%, - #c8102e 77%, - #c8102e 88%, - #ffcc00 88%, - #ffcc00 100% - ); - box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2); - z-index: 2; -} - -.fc-month-table .fc-day--selected { - background-color: #bde4ff !important; - cursor: pointer; -} - -.fc-leaves-label-container { - position: absolute; - top: 1px; - left: 2px; - display: flex; - flex-direction: column; - gap: 0; - z-index: 2; -} - -.fc-leaves-label { - font-size: 8px; - font-weight: 600; - color: #1f2933; - line-height: 1; -} - -/* Bordure épaisse pour délimiter les mois dans le planning hebdo */ -#planningTable tr.fc-month-first-week-row td { - border-top: 2px solid #243b53; /* couleur sombre cohérente avec ta charte */ -} - -#planningTable tr.fc-month-last-week-row td { - border-bottom: 2px solid #243b53; -} - -/* === TABLEAU RÉCAPITULATIF === */ - -.fc-month-summary { - min-width: 300px; -} - -.fc-summary-table-wrapper { - background: #f8fafc; - border-radius: 10px; - padding: 10px 10px 12px; - border: 1px solid #d9e2ec; - box-shadow: 0 4px 10px rgba(15, 23, 42, 0.06); -} - -.fc-summary-table { - font-size: 12px; -} - -.fc-summary-table thead th { - background: transparent; - border-bottom: 1px solid #d9e2ec; -} - -.fc-summary-table tbody td { - border: none; - border-bottom: 1px solid #edf2f7; -} - -.fc-summary-table tbody tr:last-child td { - border-bottom: none; -} - -.fc-summary-total-row { - background: #f0f4f8 !important; - border-top: 2px solid #243b53; -} - -.fc-summary-total-row td { - background: #f0f4f8 !important; - font-weight: 600; - color: #243b53; -} - -/* Responsive pour le calendrier et le récapitulatif */ -@media (max-width: 1200px) { - .fc-calendar-and-summary { - grid-template-columns: 1fr; - } - - .fc-month-summary { - min-width: 100%; - } -} - -/* === VUE 2 MOIS === */ +/* Conteneurs Vues */ .fc-two-months-container { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; -} - -.fc-month-container { width: 100%; } - -.fc-month-title { - font-weight: 600; - font-size: 15px; - color: #243b53; - margin-bottom: 12px; - text-align: center; - padding-bottom: 8px; - border-bottom: 1px solid #d9e2ec; -} - -/* === VUE ANNÉE === */ .fc-year-container { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; + width: 100%; } - +.fc-month-container, .fc-year-month { - background: #f8fafc; - border: 1px solid #d9e2ec; - border-radius: 6px; - padding: 12px; + background: white; } - +.fc-month-title, .fc-year-month-title { - font-weight: 600; - font-size: 14px; - color: #243b53; - margin-bottom: 8px; text-align: center; + font-weight: 700; + color: var(--text-main); + margin-bottom: 12px; padding-bottom: 8px; - border-bottom: 1px solid #d9e2ec; + border-bottom: 2px solid var(--primary); + text-transform: capitalize; } -.fc-year-month .fc-month-table { - font-size: 11px; +/* --- 5. MENU CONTEXTUEL --- */ +.fc-selection-menu { + position: absolute; + z-index: 9999; + background: rgba(255, 255, 255, 0.98); + border: 1px solid var(--border-color); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15); + border-radius: 8px; + padding: 8px; + min-width: 240px; + display: none; } - -.fc-year-month .fc-month-table thead th { - padding: 4px 2px; - font-size: 10px; +.fc-menu-section { + padding: 8px 0; + border-bottom: 1px solid #f1f5f9; } - -.fc-year-month .fc-month-table tbody td { - padding: 4px 2px; - height: 32px; - font-size: 11px; +.fc-menu-section:last-child { + border: none; } - -/* Responsive pour la vue année */ -@media (max-width: 1200px) { - .fc-year-container { - grid-template-columns: repeat(2, 1fr); - } +.fc-menu-section strong { + display: block; + font-size: 0.75rem; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 6px; } - -@media (max-width: 768px) { - .fc-two-months-container { - grid-template-columns: 1fr; - } - - .fc-year-container { - grid-template-columns: 1fr; - } - - .fc-month-header { - flex-direction: column; - align-items: stretch; - } - - .fc-view-controls { - justify-content: center; - } - - .fc-nav-controls { - justify-content: center; - } +.fc-menu-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; +} +.fc-menu-btn { + background: white; + border: 1px solid var(--border-color); + padding: 6px; + border-radius: 4px; + cursor: pointer; + width: 100%; +} +.fc-menu-btn:hover { + background: #eff6ff; + border-color: var(--primary); + color: var(--primary); +} +.fc-menu-danger { + width: 100%; + margin-top: 4px; + background: #fff1f2; + border: 1px solid #fda4af; + color: #be123c; + padding: 6px; + border-radius: 4px; + cursor: pointer; +} +.fc-menu-danger:hover { + background: #be123c; + color: white; + border-color: #be123c; +} +.fc-menu-leaves-table table { + width: 100%; +} +.fc-menu-leaves-table th { + font-size: 0.7rem; + color: var(--text-muted); + padding-bottom: 4px; +} +.fc-menu-leaves-table td { + padding: 2px; } diff --git a/modules/family-calendar/family-calendar.js b/modules/family-calendar/family-calendar.js index 1c47a5e..f227ec4 100644 --- a/modules/family-calendar/family-calendar.js +++ b/modules/family-calendar/family-calendar.js @@ -1,7 +1,7 @@ /** - * family-calendar.js - * Distinction congés / modes de garde + modif/suppression locales. + * family-calendar.js (FIXED: Selection, View Buttons, Colors) */ + document.addEventListener("DOMContentLoaded", () => { const CONGE_TYPES = ["OFF_CAROLE", "EXTRA_OFF_CAROLE"]; const GUARDE_TYPES = ["CENTRE", "AVIS"]; @@ -12,4506 +12,901 @@ document.addEventListener("DOMContentLoaded", () => { constructor() { this.planningBody = document.getElementById("planningBody"); this.selectionMenu = document.getElementById("selectionMenu"); + // Menu dans le body pour position absolue correcte + if ( + this.selectionMenu && + this.selectionMenu.parentElement !== document.body + ) { + document.body.appendChild(this.selectionMenu); + } this.schoolHolidaysTableBody = document.querySelector( - "#schoolHolidaysTable tbody" + "#schoolHolidaysTable tbody", ); this.monthCalendar = document.getElementById("fc-month-calendar"); this.monthSelectionMenu = document.getElementById( - "fc-month-selectionMenu" + "fc-month-selectionMenu", ); - - // Mois courant réel pour le calendrier mensuel - this.currentMonth = new Date(); - this.currentMonth.setDate(1); // premier jour du mois courant - - this.viewMode = "1month"; // "1month", "2months", "year" - - if (!this.planningBody || !this.selectionMenu) { - console.error("planningBody ou selectionMenu manquant"); - return; + if ( + this.monthSelectionMenu && + this.monthSelectionMenu.parentElement !== document.body + ) { + document.body.appendChild(this.monthSelectionMenu); } + // Etat + this.currentMonth = new Date(); + this.currentMonth.setDate(1); + this.viewMode = "1month"; + this.currentSchoolYearStart = null; + this.isSelecting = false; this.selectedCells = []; this.monthSelectedCells = []; this.isMonthSelecting = false; + this._currentBulkInfo = null; + + // Données this.dbEvents = []; this.fixedEvents = []; this.events = []; - this.menuJustOpened = false; this.leaves = []; + this.weeks = []; + this.monthlyLeaveBalances = { + 2: { CP: {}, JRA: {}, JA: {} }, + 3: { CP: {}, JRA: {}, JA: {} }, + }; + if (!this.planningBody) return; this.init(); - window.cal = this; } - // ================== INIT ================== async init() { this.setupEventListeners(); - - // Déterminer l'année scolaire en cours à partir de la date du jour const now = new Date(); - const nowMonth = now.getMonth(); // 0-11 - const nowYear = now.getFullYear(); - this.currentSchoolYearStart = nowMonth >= 8 ? nowYear : nowYear - 1; - - // Charger les semaines pour l'année scolaire courante - this.weeks = await this.fetchWeeksStructureScolaire( - this.currentSchoolYearStart - ); - - // Mettre à jour le label d'année scolaire dans l'UI + this.currentSchoolYearStart = + now.getMonth() >= 8 ? now.getFullYear() : now.getFullYear() - 1; + await this.refreshAllData(); this.updateSchoolYearLabel(); - - this.dbEvents = this.loadDbEvents(); - this.fixedEvents = await this.fetchPublicAndSchoolHolidays(); - this.events = [...this.dbEvents, ...this.fixedEvents]; - this.leaves = await this.fetchLeaves(); - this.publicHolidayDates = new Set( - this.fixedEvents - .filter((e) => e.type === "PUBLIC_HOLIDAY") - .map((e) => e.date) - ); - this.leaveBalances = await this.fetchLeaveBalances(); - this.leaveSnapshots = await this.fetchLeaveSnapshots(); - this.personLeaveMeta = await this.fetchPersonLeaveMeta(); - - this.reprocessAndRender(); - this.renderMonthCalendar(); } - async fetchLeaveBalances() { + async refreshAllData() { try { - const res = await fetch( - "/modules/family-calendar/includes/api/get-leave-balances.php" + const weeksData = await this.fetchApi( + `/modules/family-calendar/includes/api/get-calendar-weeks-scolaire.php?school_year_start=${this.currentSchoolYearStart}`, ); - if (!res.ok) { - throw new Error("Erreur HTTP " + res.status); - } - const data = await res.json(); - return data.balances || []; - } catch (err) { - console.error("Erreur lors du chargement des soldes de congés:", err); - return []; - } - } + this.weeks = this.processWeeks(weeksData.weeks || []); - async fetchLeaveSnapshots() { - try { - const res = await fetch( - "/modules/family-calendar/includes/api/get-leave-snapshots.php" + const eventsData = await this.fetchApi( + "/modules/family-calendar/includes/api/get-events.php", ); - if (!res.ok) { - throw new Error("Erreur HTTP " + res.status); - } - const data = await res.json(); - return data.snapshots || []; - } catch (err) { - console.error( - "Erreur lors du chargement des snapshots de congés:", - err - ); - return []; - } - } - - async fetchPersonLeaveMeta() { - try { - const res = await fetch( - "/modules/family-calendar/includes/api/get-person-leave-meta.php" - ); - if (!res.ok) { - throw new Error("Erreur HTTP " + res.status); - } - const data = await res.json(); - // meta: [ { person_id: 2, anniversary_date: "2020-04-30" }, ... ] - const map = {}; - (data.meta || []).forEach((row) => { - const pid = parseInt(row.person_id, 10); - map[pid] = row.anniversary_date; // string "YYYY-MM-DD" - }); - return map; // { 2: "2020-04-30", 3: "2020-04-30", ... } - } catch (err) { - console.error("Erreur fetchPersonLeaveMeta:", err); - return {}; - } - } - - getAvailableAtMonthStart(personId, leaveType, dateStr) { - if (!this.monthlyLeaveBalances) return null; - - const dateObj = new Date(dateStr + "T00:00:00"); - const ymKey = `${dateObj.getFullYear()}-${String( - dateObj.getMonth() + 1 - ).padStart(2, "0")}`; - - const personBal = (this.monthlyLeaveBalances[personId] || {})[leaveType]; - if (!personBal) return null; - - const info = personBal[ymKey]; - if (!info) return null; - - return info.availableAtMonthStart != null - ? parseFloat(info.availableAtMonthStart) - : null; - } - - loadDbEvents() { - if (typeof serverData !== "undefined" && Array.isArray(serverData)) { - return serverData.map((evt) => ({ - id: evt.id, - date: evt.event_date, - type: evt.event_type, - duration: parseFloat(evt.duration), - person_id: evt.person_id, + this.dbEvents = (eventsData.events || []).map((e) => ({ + ...e, + duration: parseFloat(e.duration), })); - } - return []; - } - async fetchLeaves() { - try { - const res = await fetch( - "/modules/family-calendar/includes/api/get-leaves.php" + this.fixedEvents = await this.fetchPublicAndSchoolHolidays(); + + const leavesData = await this.fetchApi( + "/modules/family-calendar/includes/api/get-leaves.php", ); - if (!res.ok) { - throw new Error("Erreur HTTP " + res.status); - } - const data = await res.json(); - return data.leaves || []; - } catch (err) { - console.error("Erreur lors du chargement des congés Alex/Laia :", err); - return []; + this.leaves = leavesData.leaves || []; + + const balancesData = await this.fetchApi( + "/modules/family-calendar/includes/api/get-leave-balances.php", + ); + this.leaveBalances = balancesData.balances || []; + + this.events = [...this.dbEvents, ...this.fixedEvents]; + this.publicHolidayDates = new Set( + this.fixedEvents + .filter((e) => e.type === "PUBLIC_HOLIDAY") + .map((e) => e.date), + ); + + this.reprocessAndRender(); + } catch (e) { + console.error("Erreur chargement", e); } } + // Appel API pour les vacances scolaires (Zone C) avec dédoublonnage async fetchPublicAndSchoolHolidays() { - // 1. Jours fériés (fixes) + // 1. Jours fériés 2025-2026 (Statique) const publicHolidays = [ - { date: "2025-11-01", type: "PUBLIC_HOLIDAY", duration: 1 }, - { date: "2025-11-11", type: "PUBLIC_HOLIDAY", duration: 1 }, - { date: "2025-12-25", type: "PUBLIC_HOLIDAY", duration: 1 }, - { date: "2026-01-01", type: "PUBLIC_HOLIDAY", duration: 1 }, - { date: "2026-04-06", type: "PUBLIC_HOLIDAY", duration: 1 }, - { date: "2026-05-01", type: "PUBLIC_HOLIDAY", duration: 1 }, - { date: "2026-05-08", type: "PUBLIC_HOLIDAY", duration: 1 }, - { date: "2026-05-14", type: "PUBLIC_HOLIDAY", duration: 1 }, - { date: "2026-05-25", type: "PUBLIC_HOLIDAY", duration: 1 }, - ].map((e, idx) => ({ + "2025-11-01", + "2025-11-11", + "2025-12-25", + "2026-01-01", + "2026-04-06", + "2026-05-01", + "2026-05-08", + "2026-05-14", + "2026-05-25", + "2026-07-14", + "2026-08-15", + ].map((date, idx) => ({ id: `ph-${idx}`, - ...e, + date, + type: "PUBLIC_HOLIDAY", + duration: 1, })); - // 2. Vacances scolaires (API) + // 2. Vacances Scolaires (API Gouv) const schoolHolidayEvents = []; - let holidayRecords = []; - try { - const response = await fetch( - "https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/fr-en-calendrier-scolaire/records?where=annee_scolaire='2025-2026' AND zones LIKE '%Zone C%'&limit=100" - ); - const schoolHolidaysData = await response.json(); - holidayRecords = schoolHolidaysData.results || []; + const url = + "https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/fr-en-calendrier-scolaire/records?where=annee_scolaire='2025-2026' AND zones LIKE '%Zone C%'&limit=100"; + const res = await fetch(url); + const data = await res.json(); + const rawRecords = data.results || []; - holidayRecords.forEach((record, index) => { - let current = new Date(record.start_date); - let end = new Date(record.end_date); + // --- DÉDOUBLONNAGE --- + // On utilise une Map pour ne garder qu'une entrée unique par (Description + Date Début) + const uniqueHolidaysMap = new Map(); - while (current < end) { - const isoDate = `${current.getFullYear()}-${String( - current.getMonth() + 1 - ).padStart(2, "0")}-${String(current.getDate()).padStart(2, "0")}`; - - schoolHolidayEvents.push({ - id: `sh-${isoDate}-${index}`, - date: isoDate, - type: "VACANCES_SCOLAIRES", - duration: 1, - }); - - current.setDate(current.getDate() + 1); + rawRecords.forEach((r) => { + // Clé unique : "Vacances de Noël|2025-12-20" + const key = `${r.description}|${r.start_date}`; + if (!uniqueHolidaysMap.has(key)) { + uniqueHolidaysMap.set(key, r); } }); - // Remplir le tableau HTML des vacances - this.renderSchoolHolidaysTable(holidayRecords); - } catch (error) { - console.error("Impossible de charger les vacances scolaires.", error); + // Convertir la Map en tableau + const uniqueRecords = Array.from(uniqueHolidaysMap.values()); + + // Remplir le tableau HTML du haut (avec la liste propre) + this.renderSchoolHolidaysTable(uniqueRecords); + + // Générer les événements jour par jour (uniquement sur la liste propre) + uniqueRecords.forEach((r, idx) => { + let curr = new Date(r.start_date); + const end = new Date(r.end_date); + // Boucle jour par jour + while (curr < end) { + const iso = curr.toISOString().split("T")[0]; + schoolHolidayEvents.push({ + id: `sh-${iso}-${idx}`, + date: iso, + type: "VACANCES_SCOLAIRES", + duration: 1, + }); + curr.setDate(curr.getDate() + 1); + } + }); + } catch (e) { + console.warn( + "Impossible de charger les vacances scolaires depuis l'API Gouv.", + e, + ); } - // 3. Retourne tous les événements "fixes" return [...publicHolidays, ...schoolHolidayEvents]; } - async fetchWeeksStructureScolaire(schoolYearStart) { - try { - const year = schoolYearStart || new Date().getFullYear(); - const res = await fetch( - `/modules/family-calendar/includes/api/get-calendar-weeks-scolaire.php?school_year_start=${year}` - ); - if (!res.ok) { - throw new Error("Erreur HTTP " + res.status); - } - const data = await res.json(); - const weeks = data.weeks || []; - - return weeks.map((w) => { - const mon = new Date(w.mon_date + "T00:00:00"); - const tue = new Date(w.tue_date + "T00:00:00"); - const wed = new Date(w.wed_date + "T00:00:00"); - const thu = new Date(w.thu_date + "T00:00:00"); - const fri = new Date(w.fri_date + "T00:00:00"); - - return { - id: `${w.week_iso_year}-W${w.week_iso_number}`, // ex: 2026-W01 - monthKey: `${w.year}-${String(w.month).padStart(2, "0")}`, // année calendaire + mois - monthName: w.month_name, - weekLabel: w.week_label, - dayDates: { - mon, - tue, - wed, - thu, - fri, - }, - dayFlags: { - mon: { eventsOnDay: [] }, - tue: { eventsOnDay: [] }, - wed: { eventsOnDay: [] }, - thu: { eventsOnDay: [] }, - fri: { eventsOnDay: [] }, - }, - }; - }); - } catch (err) { - console.error("Erreur chargement calendar weeks scolaire:", err); - return []; - } - } - - reprocessAndRender() { - this.reprocessEvents(); - this.calculateMonthlyLeaveBalances(); - this.renderTable(); - this.updateGlobalSummary(); - this.renderMonthCalendar(); - } - - reprocessEvents() { - // Réinitialisation des semaines - this.weeks.forEach((w) => { - w.totals = { + 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, - // Nouveaux totaux pour Alex / Laia alexCP: 0, alexJRA: 0, alexJA: 0, laiaCP: 0, laiaJRA: 0, laiaJA: 0, - }; - Object.values(w.dayFlags).forEach((df) => { - df.eventsOnDay = []; - }); - }); - // ================== Événements Carole / garde / Pep ================== - this.events.forEach((evt) => { - const evtDate = new Date(evt.date + "T00:00:00"); - // Trouver la semaine correspondant à la date de l'événement - const week = this.weeks.find( - (w) => evtDate >= w.dayDates.mon && evtDate <= w.dayDates.fri - ); - if (!week) return; - // Identifier le jour (mon, tue, wed, thu, fri) - const dayKey = Object.keys(week.dayDates).find( - (key) => week.dayDates[key].toDateString() === evtDate.toDateString() - ); - if (dayKey) { - week.dayFlags[dayKey].eventsOnDay.push(evt); - } - const dur = parseFloat(evt.duration) || 1; - // Mettre à jour les totaux hebdo selon le type - switch (evt.type) { - case "OFF_CAROLE": - week.totals.offCarole += dur; - break; - case "EXTRA_OFF_CAROLE": - week.totals.extraOffCarole += dur; - break; - case "CENTRE": - week.totals.centre += dur; - break; - case "AVIS": - week.totals.avis += dur; - break; - case "PEP_SICK": - week.totals.pepSick += dur; - break; - default: - break; - } - }); - // ================== Congés Alex / Laia (CP / JRA / JA) ================== - (this.leaves || []).forEach((lv) => { - const lvDate = new Date(lv.leave_date + "T00:00:00"); - const week = this.weeks.find( - (w) => lvDate >= w.dayDates.mon && lvDate <= w.dayDates.fri - ); - if (!week) return; - const isAlex = lv.person_id === 2; - const isLaia = lv.person_id === 3; - const type = lv.leave_type; // "CP", "JRA" ou "JA" - const dur = parseFloat(lv.duration) || 1; - if (isAlex) { - if (type === "CP") week.totals.alexCP += dur; - if (type === "JRA") week.totals.alexJRA += dur; - if (type === "JA") week.totals.alexJA += dur; - } else if (isLaia) { - if (type === "CP") week.totals.laiaCP += dur; - if (type === "JRA") week.totals.laiaJRA += dur; - if (type === "JA") week.totals.laiaJA += dur; - } - }); + }, + })); + } - // Calcul présence Pep par semaine + reprocessAndRender() { + this.reprocessEvents(); + this.calculateMonthlyBalances(); + this.updateGlobalSummary(); + this.renderTable(); + this.renderMonthCalendar(); + } + + reprocessEvents() { this.weeks.forEach((w) => { - // Calcul du nombre de jours potentiels d'accueil Pep dans la semaine + 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 === 2 ? "alex" : l.person_id === 3 ? "laia" : null; + if (prefix) w.totals[`${prefix}${l.leave_type}`] += dur; + } + }); + let workingDays = 0; - ["mon", "tue", "wed", "thu", "fri"].forEach((dayKey) => { - const d = w.dayDates[dayKey]; - const isoDate = `${d.getFullYear()}-${String( - d.getMonth() + 1 - ).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; - - const isPublicHoliday = - this.publicHolidayDates && this.publicHolidayDates.has(isoDate); - - if (!isPublicHoliday) { + Object.values(w.dayDates).forEach((d) => { + if (!this.publicHolidayDates.has(d.toISOString().split("T")[0])) workingDays++; - } }); - - const absencesPep = - (w.totals.offCarole || 0) + - (w.totals.extraOffCarole || 0) + - (w.totals.pepSick || 0); - - w.totals.presencePep = Math.max(0, workingDays - absencesPep); - w.totals.workingDays = workingDays; // si tu veux l'afficher un jour + w.totals.presencePep = Math.max( + 0, + workingDays - + (w.totals.offCarole + w.totals.extraOffCarole + w.totals.pepSick), + ); }); } - calculateMonthlyLeaveBalances() { - const usageMonth = {}; // usageMonth[person_id][leave_type][ym] = total days in THAT month - const pivotDateStr = "2025-09-01"; - const pivotDate = new Date(pivotDateStr + "T00:00:00"); - - const getSnapshotRemaining = (personId, leaveType, targetDateStr) => { - if (!this.leaveSnapshots || !this.leaveSnapshots.length) return null; - - const targetDate = new Date(targetDateStr + "T00:00:00"); - let best = null; - - this.leaveSnapshots.forEach((s) => { - if ( - parseInt(s.person_id, 10) === personId && - s.leave_type === leaveType - ) { - const d = new Date(s.snapshot_date + "T00:00:00"); - if (d <= targetDate) { - if (!best || d > best.date) { - best = { - date: d, - remaining: parseFloat(s.remaining_balance) || 0, - }; - } - } - } - }); - - return best ? best.remaining : null; + calculateMonthlyBalances() { + const balances = { + 2: { CP: {}, JRA: {}, JA: {} }, + 3: { CP: {}, JRA: {}, JA: {} }, }; + const ymSet = new Set(); + this.weeks.forEach((w) => ymSet.add(w.monthKey)); + const ymList = Array.from(ymSet).sort(); - // 1. Usage mensuel à partir des leaves (pf_leaves) - (this.leaves || []).forEach((lv) => { - const personId = parseInt(lv.person_id, 10); - const leaveType = lv.leave_type; - const dateObj = new Date(lv.leave_date + "T00:00:00"); - const year = dateObj.getFullYear(); - const month = dateObj.getMonth() + 1; - const ymKey = `${year}-${String(month).padStart(2, "0")}`; - const dur = parseFloat(lv.duration) || 1; - - if (!usageMonth[personId]) usageMonth[personId] = {}; - if (!usageMonth[personId][leaveType]) - usageMonth[personId][leaveType] = {}; - usageMonth[personId][leaveType][ymKey] = - (usageMonth[personId][leaveType][ymKey] || 0) + dur; + 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); }); - // 2. Liste des mois présents dans le planning (YYYY-MM) - const allYmKeys = new Set(); - (this.weeks || []).forEach((w) => { - const d = w.dayDates.mon; // lundi - const ymKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart( - 2, - "0" - )}`; - allYmKeys.add(ymKey); - }); - const ymList = Array.from(allYmKeys).sort(); // ex: "2025-09", "2025-10", ... + [2, 3].forEach((pid) => { + ["CP", "JRA", "JA"].forEach((type) => { + ymList.forEach((ym) => { + const [currYear, currMonth] = ym.split("-").map(Number); + let refYear = currYear; + if (type === "CP") + refYear = currMonth >= 6 ? currYear : currYear - 1; - const monthlyBalances = {}; // monthlyBalances[person_id][leave_type][ym] = { usedInMonth, availableAtMonthStart } + const initialObj = this.leaveBalances.find( + (b) => + b.person_id == pid && + b.leave_type == type && + b.balance_year == refYear, + ); + const initial = initialObj + ? parseFloat(initialObj.initial_balance) + : type === "CP" + ? 25 + : type === "JRA" + ? 10 + : 0; - // 3. Indexer les balances annuelles (droits) - // balancesByYear[person_id][leave_type][year] = initial_balance - const balancesByYear = {}; - (this.leaveBalances || []).forEach((b) => { - const personId = parseInt(b.person_id, 10); - const leaveType = b.leave_type; - const balanceYear = parseInt(b.balance_year, 10); - const initial = parseFloat(b.initial_balance) || 0; + let usedBefore = 0; + let periodStartMonth = type === "CP" ? "06" : "01"; + const periodStart = `${refYear}-${periodStartMonth}`; - if (!balancesByYear[personId]) balancesByYear[personId] = {}; - if (!balancesByYear[personId][leaveType]) - balancesByYear[personId][leaveType] = {}; - balancesByYear[personId][leaveType][balanceYear] = initial; - }); - - const persons = [2, 3]; // Alex, Laia - const types = ["CP", "JRA", "JA"]; - - persons.forEach((personId) => { - if (!monthlyBalances[personId]) monthlyBalances[personId] = {}; - - types.forEach((leaveType) => { - if (!monthlyBalances[personId][leaveType]) - monthlyBalances[personId][leaveType] = {}; - - const personUsageAll = (usageMonth[personId] || {})[leaveType] || {}; - - // ===================== CP ===================== - // 25/an, utilisables du 01/08 N au 31/07 N+1. - // On considère que les 25 sont dispo au 01/08. - if (leaveType === "CP") { - ymList.forEach((ym) => { - const [yStr, mStr] = ym.split("-"); - const year = parseInt(yStr, 10); - const month = parseInt(mStr, 10); - - // Déterminer l'année scolaire CP (N) - // Si mois >= 8 -> année scolaire = year - // Sinon -> année scolaire = year - 1 - const cpSchoolYear = month >= 8 ? year : year - 1; - - const initial = - ((balancesByYear[personId] || {}).CP || {})[cpSchoolYear] || 0; - - // Période effective de cette "cohorte" CP: - // [cpSchoolYear-08-01, (cpSchoolYear+1)-07-31] - const periodStart = `${cpSchoolYear}-08-01`; - const periodEnd = `${cpSchoolYear + 1}-07-31`; - - // Date du début du mois courant - const monthStart = `${year}-${String(month).padStart(2, "0")}-01`; - - // usedBeforeMonth = somme des CP sur la période, avant le 1er de ce mois - let usedBeforeMonth = 0; - - Object.entries(personUsageAll).forEach(([ym2, val]) => { - // Convertir ym2 -> date 1er du mois - const [yyStr, mmStr] = ym2.split("-"); - const yy = parseInt(yyStr, 10); - const mm = parseInt(mmStr, 10); - - // Jour 01 pour comparaison lexicographique - const ym2MonthStart = `${yy}-${mmStr}-01`; - - // On ne compte que les CP dans la période de validité - if ( - ym2MonthStart >= periodStart && - ym2MonthStart < monthStart && - ym2MonthStart <= periodEnd - ) { - usedBeforeMonth += parseFloat(val) || 0; - } - }); - - const usedInMonth = parseFloat(personUsageAll[ym] || 0); - - let availableAtMonthStart = initial - usedBeforeMonth; - if (availableAtMonthStart < 0) availableAtMonthStart = 0; - - monthlyBalances[personId][leaveType][ym] = { - usedInMonth, - usedBeforeMonth, - availableAtMonthStart, - }; + Object.keys(usageByMonth[pid]?.[type] || {}).forEach((usedYm) => { + if (usedYm >= periodStart && usedYm < ym) { + usedBefore += usageByMonth[pid][type][usedYm]; + } }); - return; // CP géré, on passe au type suivant - } - - // ===================== JRA (version simple pour affichage) ===================== - // On considère les JRA de l'année Y comme un bloc de initial_balance, - // utilisable du 01/01/Y au 28/02/Y+1 (tolérance incluse), - // et on affiche pour chaque mois le solde disponible en fonction de l'usage. - if (leaveType === "JRA") { - ymList.forEach((ym) => { - const [yStr, mStr] = ym.split("-"); - const year = parseInt(yStr, 10); - const month = parseInt(mStr, 10); - - // Année "source" des JRA: c'est simplement l'année civile - const jraYear = year; - const totalYear = - ((balancesByYear[personId] || {}).JRA || {})[jraYear] || 0; - - // Période de validité: [01/01/jraYear, 28/02/jraYear+1] - const periodStart = `${jraYear}-01-01`; - const periodEnd = `${jraYear + 1}-02-28`; - - // Début du mois courant - const monthStart = `${year}-${String(month).padStart(2, "0")}-01`; - - // usedBeforeMonth: somme des JRA sur la période, avant le 1er de ce mois - let usedBeforeMonth = 0; - - Object.entries(personUsageAll).forEach(([ym2, val]) => { - const [yyStr, mmStr] = ym2.split("-"); - const yy = parseInt(yyStr, 10); - const mm = parseInt(mmStr, 10); - const ym2MonthStart = `${yy}-${mmStr}-01`; - - if ( - ym2MonthStart >= periodStart && - ym2MonthStart < monthStart && - ym2MonthStart <= periodEnd - ) { - usedBeforeMonth += parseFloat(val) || 0; - } - }); - - const usedInMonth = parseFloat(personUsageAll[ym] || 0); - - let availableAtMonthStart = totalYear - usedBeforeMonth; - if (availableAtMonthStart < 0) availableAtMonthStart = 0; - - monthlyBalances[personId][leaveType][ym] = { - usedInMonth, - usedBeforeMonth, - availableAtMonthStart, - }; - }); - - return; // JRA géré - } - - // ===================== JA (jours d'ancienneté) ===================== - // Gérés par cycle anniversaire (pf_person_leave_meta). - if (leaveType === "JA") { - const anniversaryStr = - (this.personLeaveMeta && this.personLeaveMeta[personId]) || null; - - ymList.forEach((ym) => { - const [yStr, mStr] = ym.split("-"); - const year = parseInt(yStr, 10); - const month = parseInt(mStr, 10); - - const personUsage = personUsageAll; - - // Si pas de date anniversaire connue, fallback sur ancienne logique annuelle - if (!anniversaryStr) { - const initial = - ((balancesByYear[personId] || {}).JA || {})[year] || 0; - - let usedBeforeMonth = 0; - Object.entries(personUsage).forEach(([ym2, val]) => { - const [y2Str] = ym2.split("-"); - const y2 = parseInt(y2Str, 10); - if (y2 === year && ym2 < ym) { - usedBeforeMonth += parseFloat(val) || 0; - } - }); - - const usedInMonth = parseFloat(personUsage[ym] || 0); - - let availableAtMonthStart = initial - usedBeforeMonth; - if (availableAtMonthStart < 0) availableAtMonthStart = 0; - - monthlyBalances[personId][leaveType][ym] = { - usedInMonth, - usedBeforeMonth, - availableAtMonthStart, - }; - return; // pour ce ym - } - - // --- Logique cycle anniversaire --- - // anniversaryStr ex: "2020-04-30" - const annDate = new Date(anniversaryStr + "T00:00:00"); - const annDay = annDate.getDate(); // 30 - const annMonth = annDate.getMonth(); // 0-11 -> 3 pour Avril - - // Date du mois courant (on prend le 1er pour simplifier) - const currentMonthStart = new Date(year, month - 1, 1); - - // On calcule le cycleStart / cycleEnd autour de ce currentMonthStart - // 1) Anniversaire de l'année courante - const anniversaryThisYear = new Date(year, annMonth, annDay); - - let cycleStart, cycleEnd, cycleYear; - - if (currentMonthStart >= anniversaryThisYear) { - // Le cycle courant a commencé cette année - cycleStart = new Date(year, annMonth, annDay + 1); // lendemain - cycleEnd = new Date(year + 1, annMonth, annDay); // prochain anniv - cycleYear = year; - } else { - // Le cycle courant a commencé l'année précédente - cycleStart = new Date(year - 1, annMonth, annDay + 1); - cycleEnd = new Date(year, annMonth, annDay); - cycleYear = year - 1; - } - - // On formate ces dates pour comparer avec les ym - const toIso = (d) => - `${d.getFullYear()}-${String(d.getMonth() + 1).padStart( - 2, - "0" - )}-${String(d.getDate()).padStart(2, "0")}`; - - const periodStart = toIso(cycleStart); - const periodEnd = toIso(cycleEnd); - - // Début du mois courant - const monthStartIso = `${year}-${String(month).padStart( - 2, - "0" - )}-01`; - - // Initial pour ce cycle: soit pf_leave_balances[cycleYear], soit 4 par défaut - let initial = - ((balancesByYear[personId] || {}).JA || {})[cycleYear] || 0; - if (!initial) { - initial = 4; // fallback si tu ne remplis pas pf_leave_balances pour JA tous les ans - } - - // usedBeforeMonth: JA posés sur ce cycle avant le 1er du mois courant - let usedBeforeMonth = 0; - Object.entries(personUsage).forEach(([ym2, val]) => { - // ym2 = "YYYY-MM" - const [yyStr, mmStr] = ym2.split("-"); - const yy = parseInt(yyStr, 10); - const mm = parseInt(mmStr, 10); - const ym2Start = `${yy}-${mmStr}-01`; - - if ( - ym2Start >= periodStart && - ym2Start < monthStartIso && - ym2Start <= periodEnd - ) { - usedBeforeMonth += parseFloat(val) || 0; - } - }); - - const usedInMonth = parseFloat(personUsage[ym] || 0); - - let availableAtMonthStart = initial - usedBeforeMonth; - if (availableAtMonthStart < 0) availableAtMonthStart = 0; - - monthlyBalances[personId][leaveType][ym] = { - usedInMonth, - usedBeforeMonth, - availableAtMonthStart, - }; - }); - - return; // JA géré - } + const available = Math.max(0, initial - usedBefore); + const usedInMonth = usageByMonth[pid]?.[type]?.[ym] || 0; + balances[pid][type][ym] = { + availableAtMonthStart: available, + usedInMonth: usedInMonth, + }; + }); }); }); - - this.monthlyLeaveBalances = monthlyBalances; - } - - calculateLeaveUsage() { - // Agrège les jours utilisés par personne et type pour l'année de référence - const usage = {}; // key: `${person_id}|${leave_type}|${year}` - - (this.leaves || []).forEach((lv) => { - // lv.leave_date est au format 'YYYY-MM-DD' - const year = new Date(lv.leave_date + "T00:00:00").getFullYear(); - const key = `${lv.person_id}|${lv.leave_type}|${year}`; - const dur = parseFloat(lv.duration) || 1; - usage[key] = (usage[key] || 0) + dur; - }); - - return usage; - } - - calculateMonthlyLeaveUsage() { - const usageMonth = {}; // usageMonth[person_id][leave_type][ym] = total days - - (this.leaves || []).forEach((lv) => { - const personId = parseInt(lv.person_id, 10); - const leaveType = lv.leave_type; - const dateObj = new Date(lv.leave_date + "T00:00:00"); - const year = dateObj.getFullYear(); - const month = dateObj.getMonth() + 1; - const ymKey = `${year}-${String(month).padStart(2, "0")}`; - const dur = parseFloat(lv.duration) || 1; - - if (!usageMonth[personId]) usageMonth[personId] = {}; - if (!usageMonth[personId][leaveType]) - usageMonth[personId][leaveType] = {}; - usageMonth[personId][leaveType][ymKey] = - (usageMonth[personId][leaveType][ymKey] || 0) + dur; - }); - - return usageMonth; - } - - calculateAvailableBalances() { - const usage = this.calculateLeaveUsage(); - - // balances[person_id][leave_type][year] = { initial, used, remaining } - const balances = {}; - - (this.leaveBalances || []).forEach((b) => { - const personId = parseInt(b.person_id, 10); - const leaveType = b.leave_type; - const year = parseInt(b.balance_year, 10); - const initial = parseFloat(b.initial_balance) || 0; - - const key = `${personId}|${leaveType}|${year}`; - const used = usage[key] || 0; - let remaining = initial - used; - if (remaining < 0) remaining = 0; - - if (!balances[personId]) balances[personId] = {}; - if (!balances[personId][leaveType]) balances[personId][leaveType] = {}; - balances[personId][leaveType][year] = { initial, used, remaining }; - }); - - return balances; - } - - updateGlobalSummary() { - const summaryDiv = document.getElementById("globalSummary"); - if (!summaryDiv) return; - - const allEvents = this.dbEvents || []; - - const totalOff = allEvents - .filter((e) => e.type === "OFF_CAROLE") - .reduce((sum, e) => sum + Number(e.duration || 1), 0); - - const totalExtraOff = allEvents - .filter((e) => e.type === "EXTRA_OFF_CAROLE") - .reduce((sum, e) => sum + Number(e.duration || 1), 0); - - const totalPepSick = allEvents - .filter((e) => e.type === "PEP_SICK") - .reduce((sum, e) => sum + Number(e.duration || 1), 0); - - // Calculer les jours ouvrés & présence Pep sur l'année scolaire - let totalWorkingDays = 0; - let totalPresencePep = 0; - - // On parcourt l'année scolaire en mois, en réutilisant calculateMonthTotals - const start = new Date(2025, 8, 1); // 1 sept 2025 - const end = new Date(2026, 7, 31); // 31 août 2026 - - const current = new Date(start); - while (current <= end) { - const y = current.getFullYear(); - const m = current.getMonth(); - const monthTotals = this.calculateMonthTotals(y, m); - - totalWorkingDays += monthTotals.workingDays || 0; - totalPresencePep += monthTotals.presencePep || 0; - - // Passer au 1er du mois suivant - current.setMonth(current.getMonth() + 1); - current.setDate(1); - } - - summaryDiv.innerHTML = ` -

Off Carole : ${totalOff} jours

-

Extra Off Carole : ${totalExtraOff} jours

-

Pep malade : ${totalPepSick} jours

-

Jours potentiels d'accueil Pep (année scolaire, hors fériés) : ${totalWorkingDays}

-

Présence Pep (année scolaire) : ${totalPresencePep} jours

- `; - } - - renderSchoolHolidaysTable(holidayRecords) { - if (!this.schoolHolidaysTableBody) return; - this.schoolHolidaysTableBody.innerHTML = ""; - - const uniqueHolidays = new Map(); - holidayRecords.forEach((r) => - uniqueHolidays.set(`${r.start_date}|${r.end_date}`, r) - ); - - [...uniqueHolidays.values()] - .sort((a, b) => new Date(a.start_date) - new Date(b.start_date)) - .forEach((record) => { - const tr = document.createElement("tr"); - const startDate = new Date(record.start_date); - const endDate = new Date(record.end_date); - tr.innerHTML = ` - ${record.description} - ${startDate.toLocaleDateString("fr-FR")} - ${endDate.toLocaleDateString("fr-FR")} - ${record.zones} - `; - this.schoolHolidaysTableBody.appendChild(tr); - }); + this.monthlyLeaveBalances = balances; } renderTable() { 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)) : ""; - const monthSpans = this.weeks.reduce( - (acc, w) => ({ ...acc, [w.monthKey]: (acc[w.monthKey] || 0) + 1 }), - {} - ); - const monthRowRendered = {}; - // map pour savoir si on a déjà rendu les cellules Alex/Laia pour un mois - const alexLaiaRowRendered = {}; - - const formatTotal = (total) => - total > 0 ? (Number.isInteger(total) ? total : total.toFixed(1)) : ""; - - this.weeks.forEach((week, index) => { + this.weeks.forEach((w, idx) => { const tr = document.createElement("tr"); - - // Déterminer si c'est la première ou la dernière semaine du mois - const isFirstWeekOfMonth = - index === 0 || this.weeks[index - 1].monthKey !== week.monthKey; - const isLastWeekOfMonth = - index === this.weeks.length - 1 || - this.weeks[index + 1].monthKey !== week.monthKey; - - if (isFirstWeekOfMonth) { + if (idx === 0 || this.weeks[idx - 1].monthKey !== w.monthKey) tr.classList.add("fc-month-first-week-row"); - } - if (isLastWeekOfMonth) { + if ( + idx === this.weeks.length - 1 || + this.weeks[idx + 1].monthKey !== w.monthKey + ) tr.classList.add("fc-month-last-week-row"); - } - // Colonne Mois (avec rowSpan) - if (!monthRowRendered[week.monthKey]) { - monthRowRendered[week.monthKey] = true; - const tdMonth = document.createElement("td"); - tdMonth.rowSpan = monthSpans[week.monthKey] || 1; - tdMonth.textContent = week.monthName; - tdMonth.classList.add("col-month"); - tr.appendChild(tdMonth); - } - - // Semaine - const tdWeek = document.createElement("td"); - tdWeek.textContent = week.weekLabel; - tdWeek.classList.add("col-month"); - tr.appendChild(tdWeek); - - // Jours Lundi–Vendredi - ["mon", "tue", "wed", "thu", "fri"].forEach((dayKey) => { + if (!processedMonths[w.monthKey]) { + processedMonths[w.monthKey] = true; const td = document.createElement("td"); - td.classList.add("col-day"); + td.className = "col-month"; + td.textContent = w.monthName; + td.rowSpan = monthSpans[w.monthKey]; + tr.appendChild(td); + } - const dayDate = week.dayDates[dayKey]; - td.dataset.date = `${dayDate.getFullYear()}-${String( - dayDate.getMonth() + 1 - ).padStart(2, "0")}-${String(dayDate.getDate()).padStart(2, "0")}`; - td.textContent = `${String(dayDate.getDate()).padStart( - 2, - "0" - )}/${String(dayDate.getMonth() + 1).padStart(2, "0")}`; + const tdW = document.createElement("td"); + tdW.className = "col-month"; + tdW.textContent = w.weekLabel; + tr.appendChild(tdW); - let hasGarde = false; - let gardeType = null; - let hasPepSick = false; + ["mon", "tue", "wed", "thu", "fri"].forEach((d) => { + const td = document.createElement("td"); + const dateObj = w.dayDates[d]; + const iso = dateObj.toISOString().split("T")[0]; + td.dataset.date = iso; + td.textContent = String(dateObj.getDate()).padStart(2, "0"); + td.className = "col-day"; - week.dayFlags[dayKey].eventsOnDay.forEach((evt) => { - const classMap = { - VACANCES_SCOLAIRES: "fc-day--school-holiday", - PUBLIC_HOLIDAY: "fc-day--public-holiday", - OFF_CAROLE: "fc-day--off-carole", - EXTRA_OFF_CAROLE: "fc-day--extra-off-carole", - // pas de couleur pour PEP_SICK dans l'hebdo - }; - - if (classMap[evt.type]) { - td.classList.add(classMap[evt.type]); - } - - if (GUARDE_TYPES.includes(evt.type)) { - hasGarde = true; - gardeType = evt.type; - } - - if (evt.type === "PEP_SICK") { - hasPepSick = true; - } + const dayEvents = w.dayFlags[d].events; + dayEvents.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 += `🤒`; }); - if (hasGarde) { - td.classList.add("fc-day--has-guard"); - if (gardeType === "CENTRE") td.classList.add("fc-day--centre"); - if (gardeType === "AVIS") td.classList.add("fc-day--avis"); + const dayLeaves = this.leaves.filter((l) => l.leave_date === iso); + if (dayLeaves.length) { + let html = `
`; + // --- CORRECTION COULEURS --- + if (dayLeaves.some((l) => l.person_id === 2)) + html += `A`; // Teal + if (dayLeaves.some((l) => l.person_id === 3)) + html += `L`; // Amber + html += `
`; + td.innerHTML += html; } - - if (hasPepSick) { - const pepSpan = document.createElement("span"); - pepSpan.className = "fc-pep-sick-emoji"; - pepSpan.textContent = "🤒"; - td.appendChild(pepSpan); - } - - // Congés Alex/Laia - const isoDate = td.dataset.date; - const leavesOnDay = this.leaves.filter( - (lv) => lv.leave_date === isoDate - ); - - if (leavesOnDay.length > 0) { - const container = document.createElement("div"); - container.className = "fc-leaves-label-container"; - - const hasAlex = leavesOnDay.some((lv) => lv.person_id === 2); - const hasLaia = leavesOnDay.some((lv) => lv.person_id === 3); - - if (hasAlex) { - const alexSpan = document.createElement("span"); - alexSpan.className = "fc-leaves-label"; - alexSpan.textContent = "AF"; - container.appendChild(alexSpan); - } - - if (hasLaia) { - const laiaSpan = document.createElement("span"); - laiaSpan.className = "fc-leaves-label"; - laiaSpan.textContent = "LM"; - container.appendChild(laiaSpan); - } - - td.appendChild(container); - } - tr.appendChild(td); }); - // Totaux - const tdOff = document.createElement("td"); - tdOff.textContent = formatTotal(week.totals.offCarole); - tdOff.classList.add("col-total"); - tr.appendChild(tdOff); + [ + "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); + }); - const tdExtra = document.createElement("td"); - tdExtra.textContent = formatTotal(week.totals.extraOffCarole); - tdExtra.classList.add("col-total"); - tr.appendChild(tdExtra); - - const tdCentre = document.createElement("td"); - tdCentre.textContent = formatTotal(week.totals.centre); - tdCentre.classList.add("col-total"); - tr.appendChild(tdCentre); - - const tdAvis = document.createElement("td"); - tdAvis.textContent = formatTotal(week.totals.avis); - tdAvis.classList.add("col-total"); - tr.appendChild(tdAvis); - - const tdPepSick = document.createElement("td"); - tdPepSick.textContent = formatTotal(week.totals.pepSick); - tdPepSick.classList.add("col-total"); - tr.appendChild(tdPepSick); - - const tdPresencePep = document.createElement("td"); - tdPresencePep.textContent = formatTotal(week.totals.presencePep); - tdPresencePep.classList.add("col-total"); - tr.appendChild(tdPresencePep); - - // ===== Colonnes Alex/Laia Av./Use fusionnées par mois ===== - - // Mois de la semaine pour les soldes mensuels - const weekMonthDate = week.dayDates.mon; // lundi de la semaine - const ymKey = `${weekMonthDate.getFullYear()}-${String( - weekMonthDate.getMonth() + 1 - ).padStart(2, "0")}`; - - const monthlyBalances = this.monthlyLeaveBalances || {}; - - const computeMonthInfo = (personId, leaveType, ymKey) => { - const personBal = (monthlyBalances[personId] || {})[leaveType]; - if (!personBal) return null; - const info = personBal[ymKey]; - if (!info) return null; - return info; - }; - - const alexCPMonth = computeMonthInfo(2, "CP", ymKey); - const alexJRAMonth = computeMonthInfo(2, "JRA", ymKey); - const alexJAMonth = computeMonthInfo(2, "JA", ymKey); - - const laiaCPMonth = computeMonthInfo(3, "CP", ymKey); - const laiaJRAMonth = computeMonthInfo(3, "JRA", ymKey); - const laiaJAMonth = computeMonthInfo(3, "JA", ymKey); - - const getMonthVal = (info, field) => - info && info[field] != null ? info[field] : 0; - - // On ne rend les colonnes Alex/Laia qu'une fois par mois - if (!alexLaiaRowRendered[week.monthKey]) { - alexLaiaRowRendered[week.monthKey] = true; - - const rowSpan = monthSpans[week.monthKey] || 1; - - // 6 colonnes ALEX : [CP Av., CP Use, JRA Av., JRA Use, JA Av., JA Use] - for (let i = 0; i < 6; i++) { - const td = document.createElement("td"); - td.classList.add("col-alex-sub"); - td.rowSpan = rowSpan; - - if (i % 2 === 0) { - // Av. - td.classList.add("col-alex-av"); - let value = ""; - if (i === 0) { - value = alexCPMonth - ? formatTotal( - getMonthVal(alexCPMonth, "availableAtMonthStart") - ) - : ""; - } else if (i === 2) { - value = alexJRAMonth - ? formatTotal( - getMonthVal(alexJRAMonth, "availableAtMonthStart") - ) - : ""; - } else if (i === 4) { - value = alexJAMonth - ? formatTotal( - getMonthVal(alexJAMonth, "availableAtMonthStart") - ) - : ""; - } - td.textContent = value; - } else { - // Use - td.classList.add("col-alex-use"); - let value = ""; - if (i === 1) { - value = alexCPMonth - ? formatTotal(getMonthVal(alexCPMonth, "usedInMonth")) - : ""; - } else if (i === 3) { - value = alexJRAMonth - ? formatTotal(getMonthVal(alexJRAMonth, "usedInMonth")) - : ""; - } else if (i === 5) { - value = alexJAMonth - ? formatTotal(getMonthVal(alexJAMonth, "usedInMonth")) - : ""; - } - td.textContent = value; - } - - tr.appendChild(td); - } - - // 6 colonnes LAIA : [CP Av., CP Use, JRA Av., JRA Use, JA Av., JA Use] - for (let i = 0; i < 6; i++) { - const td = document.createElement("td"); - td.classList.add("col-laia-sub"); - td.rowSpan = rowSpan; - - if (i % 2 === 0) { - td.classList.add("col-laia-av"); - let value = ""; - if (i === 0) { - value = laiaCPMonth - ? formatTotal( - getMonthVal(laiaCPMonth, "availableAtMonthStart") - ) - : ""; - } else if (i === 2) { - value = laiaJRAMonth - ? formatTotal( - getMonthVal(laiaJRAMonth, "availableAtMonthStart") - ) - : ""; - } else if (i === 4) { - value = laiaJAMonth - ? formatTotal( - getMonthVal(laiaJAMonth, "availableAtMonthStart") - ) - : ""; - } - td.textContent = value; - } else { - td.classList.add("col-laia-use"); - let value = ""; - if (i === 1) { - value = laiaCPMonth - ? formatTotal(getMonthVal(laiaCPMonth, "usedInMonth")) - : ""; - } else if (i === 3) { - value = laiaJRAMonth - ? formatTotal(getMonthVal(laiaJRAMonth, "usedInMonth")) - : ""; - } else if (i === 5) { - value = laiaJAMonth - ? formatTotal(getMonthVal(laiaJAMonth, "usedInMonth")) - : ""; - } - td.textContent = value; - } - - 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(2, "col-alex"); + renderPersonCols(3, "col-laia"); } - this.planningBody.appendChild(tr); }); } - // ================== INTERACTIONS ================== - setupEventListeners() { - this.planningBody.addEventListener("mousedown", (e) => - this.handleMouseDown(e) - ); - document.addEventListener("mousemove", (e) => this.handleMouseMove(e)); - document.addEventListener("mouseup", (e) => this.handleMouseUp(e)); - document.addEventListener( - "click", - (e) => this.handleClickOutsideMenu(e), - true - ); - this.selectionMenu.addEventListener("click", (e) => - this.handleMenuClick(e) - ); - - // Événements pour le calendrier mensuel - if (this.monthCalendar) { - this.monthCalendar.addEventListener("mousedown", (e) => - this.handleMonthMouseDown(e) - ); - document.addEventListener("mousemove", (e) => - this.handleMonthMouseMove(e) - ); - document.addEventListener("mouseup", (e) => this.handleMonthMouseUp(e)); - if (this.monthSelectionMenu) { - // Gestion add / add-single / delete / update sur le calendrier mensuel - this.monthSelectionMenu.addEventListener("click", (e) => - this.handleMonthMenuClick(e) - ); - } - } - - // Navigation du calendrier mensuel - const prevBtn = document.getElementById("fc-prev-month"); - const nextBtn = document.getElementById("fc-next-month"); - if (prevBtn) { - prevBtn.addEventListener("click", () => this.navigateMonth(-1)); - } - if (nextBtn) { - nextBtn.addEventListener("click", () => this.navigateMonth(1)); - } - - // Navigation année scolaire (planning hebdo) - const prevSchoolYearBtn = document.getElementById("fc-prev-school-year"); - const nextSchoolYearBtn = document.getElementById("fc-next-school-year"); - if (prevSchoolYearBtn) { - prevSchoolYearBtn.addEventListener("click", () => - this.changeSchoolYear(-1) - ); - } - if (nextSchoolYearBtn) { - nextSchoolYearBtn.addEventListener("click", () => - this.changeSchoolYear(1) - ); - } - - // Boutons de changement de vue - const viewButtons = document.querySelectorAll(".fc-view-button"); - viewButtons.forEach((btn) => { - btn.addEventListener("click", (e) => { - const view = e.target.dataset.view; - this.setViewMode(view); - }); - }); - } - - handleMouseDown(e) { - console.log("handleMouseDown raw target:", e.target); - - const cell = e.target.closest("#planningTable td[data-date]"); - console.log("handleMouseDown cell:", cell); - - if (!cell) return; - - e.preventDefault(); - - this.clearSelection(); - this.isSelecting = true; - - cell.classList.add("fc-day--selected"); - this.selectedCells = [cell]; - - console.log( - "handleMouseDown after select, selectedCells =", - this.selectedCells - ); - } - - handleMouseMove(e) { - if (!this.isSelecting) return; - - const cell = e.target.closest("#planningTable td[data-date]"); - if (!cell) return; - - if (!this.selectedCells.includes(cell)) { - cell.classList.add("fc-day--selected"); - this.selectedCells.push(cell); - } - } - - handleMouseUp(e) { - console.log( - "handleMouseUp called, selectedCells.length =", - this.selectedCells.length - ); - - if (!this.isSelecting) { - return; - } - - this.isSelecting = false; - - if (this.selectedCells.length === 0) return; - - // ===== Cas 1 seule cellule ===== - if (this.selectedCells.length === 1) { - const date = this.selectedCells[0].dataset.date; - const eventsOnDay = this.events.filter( - (evt) => evt.date === date && MODIFIABLE_TYPES.includes(evt.type) - ); - const conge = eventsOnDay.find((ev) => CONGE_TYPES.includes(ev.type)); - const garde = eventsOnDay.find((ev) => GUARDE_TYPES.includes(ev.type)); - const pep = eventsOnDay.find((ev) => PEP_TYPES.includes(ev.type)); - - this.showEditMenuForDay(e, { conge, garde, pep, date }); - return; - } - - // ===== Multi-jours ===== - const selectedDates = this.selectedCells.map((c) => c.dataset.date); - const eventsOnDates = this.events.filter( - (evt) => - selectedDates.includes(evt.date) && - MODIFIABLE_TYPES.includes(evt.type) - ); - - const conges = eventsOnDates.filter((ev) => - CONGE_TYPES.includes(ev.type) - ); - const gardes = eventsOnDates.filter((ev) => - GUARDE_TYPES.includes(ev.type) - ); - - const uniqueCongeTypes = new Set(conges.map((c) => c.type)); - const uniqueGardeTypes = new Set(gardes.map((g) => g.type)); - - const allDatesHaveConge = - conges.length === selectedDates.length && uniqueCongeTypes.size === 1; - const allDatesHaveGarde = - gardes.length === selectedDates.length && uniqueGardeTypes.size === 1; - - // On construit quand même un bulkInfo partiel (même si pas homogène) - const bulkInfo = { - selectedDates, - conges, - gardes, - congeType: allDatesHaveConge ? conges[0].type : null, - gardeType: allDatesHaveGarde ? gardes[0].type : null, - }; - - // On affiche toujours le menu bulk pour permettre Alex/Laia, - // et éventuellement les actions Carole/garde si congeType/gardeType sont définis. - this.showBulkMenu(e, bulkInfo); - } - - showBulkMenu(e, bulkInfo) { - const { selectedDates } = bulkInfo; - const nbDays = selectedDates.length; - - let html = ` -
- Actions multi-jours -
- `; - - // Section Congés Carole - html += ` -
- Congés Carole (${nbDays} jours) -
- - -
- -
-`; - - // Section Mode de garde – toujours visible - html += ` -
- Mode de garde (${nbDays} jours) -
- - -
- -
-`; - - // Section bulk congés Alex / Laia – tableau identique au single - html += ` -
- Congés Alex / Laia (${nbDays} jours) -
- - - - - - - - - - - - - - - - - - - - - -
AlexLaia
- - - -
- - - -
- - - -
-
-
-`; - - // Après le tableau CP/JRA/JA - html += ` - - -`; - - this.selectionMenu.innerHTML = html; - this._currentBulkInfo = bulkInfo; - this.positionAndShowMenu(e); - } - - handleClickOutsideMenu(e) { - if (this.menuJustOpened) { - this.menuJustOpened = false; - return; - } - if ( - this.selectionMenu && - this.selectionMenu.style.display === "block" && - !this.selectionMenu.contains(e.target) - ) { - this.clearSelection(); - } - if ( - this.monthSelectionMenu && - this.monthSelectionMenu.style.display === "block" && - !this.monthSelectionMenu.contains(e.target) - ) { - this.clearMonthSelection(); - } - } - - clearSelection() { - this.selectionMenu.style.display = "none"; - this.selectedCells.forEach((cell) => - cell.classList.remove("fc-day--selected") - ); - this.selectedCells = []; - } - - showAddMenu(e) { - this.selectionMenu.innerHTML = ` -
- Ajouter -
- - -
-
- -
- Mode de garde -
- - -
-
- -
- Pep -
- -
-
- `; - this.positionAndShowMenu(e); - } - - showEditMenuForDay(e, { conge, garde, pep, date }) { - console.log( - "[EDIT MENU] pour date", - date, - "conge =", - conge, - "garde =", - garde - ); - - // --- Section congé Carole --- - let congeSection = ""; - if (conge) { - const oppositeConge = - conge.type === "OFF_CAROLE" ? "EXTRA_OFF_CAROLE" : "OFF_CAROLE"; - congeSection = ` -
- Congé Carole -
- - -
-
- `; - } else { - congeSection = ` -
- Ajouter un congé -
- - -
-
- `; - } - - // --- Section mode de garde --- - let gardeSection = ""; - if (garde) { - const oppositeGarde = garde.type === "CENTRE" ? "AVIS" : "CENTRE"; - gardeSection = ` -
- Mode de garde -
- - -
-
- `; - } else { - gardeSection = ` -
- Ajouter mode de garde -
- - -
-
- `; - } - - // --- Section Pep malade --- - let pepSection = ""; - if (pep) { - pepSection = ` -
- Pep - -
- `; - } else { - pepSection = ` -
- Pep - -
- `; - } - - // --- Section congés Alex / Laia --- - console.log( - "[EDIT MENU] this.leaves length =", - (this.leaves || []).length - ); - - const leavesOnDay = (this.leaves || []).filter( - (lv) => lv.leave_date === date - ); - console.log("[EDIT MENU] leavesOnDay =", leavesOnDay); - - let leavesSection = ` -
- Congés Alex / Laia -
- - - - - - - - - - - - - - - - - - - - - - - - - -
AlexLaia
- - - -
- - - -
- - - -
-
-
- `; - - // Bouton de suppression si congés existants ce jour-là - if (leavesOnDay.length > 0) { - leavesSection += ` - - `; - } - - const fullHtml = congeSection + gardeSection + pepSection + leavesSection; - console.log("[EDIT MENU] full menu HTML length =", fullHtml.length); - - this.selectionMenu.innerHTML = fullHtml; - this.positionAndShowMenu(e); - } - - positionAndShowMenu(e) { - const wrapper = document.getElementById("planningTable-wrapper"); - if (!wrapper) { - // fallback sécurité - this.selectionMenu.style.display = "block"; - this.selectionMenu.style.left = `${e.clientX + 5}px`; - this.selectionMenu.style.top = `${e.clientY + 5}px`; - return; - } - - const rect = wrapper.getBoundingClientRect(); - - this.selectionMenu.style.display = "block"; - - // Position relative au wrapper (qui scrolle) - const x = e.clientX - rect.left + wrapper.scrollLeft; - const y = e.clientY - rect.top + wrapper.scrollTop; - - this.selectionMenu.style.left = `${x + 5}px`; - this.selectionMenu.style.top = `${y + 5}px`; - - this.menuJustOpened = true; - setTimeout(() => { - this.menuJustOpened = false; - }, 0); - } - - // ===== Helpers Carole / garde ===== - - // Single day – Carole - async setCaroleSingle(date, type) { - try { - // 1) supprimer OFF_CAROLE / EXTRA_OFF_CAROLE sur ce jour - await this.manageEvent({ - action: "delete_day_types", - date, - types: CONGE_TYPES, // ["OFF_CAROLE", "EXTRA_OFF_CAROLE"] - }); - - // 2) ajouter le nouveau congé - await this.manageEvent({ - action: "add_multiple", - events: [ - { - date, - type, - person: "Carole", - duration: 1.0, - }, - ], - }); - } catch (err) { - console.error("setCaroleSingle error:", err); - alert("Erreur lors de la mise à jour du congé Carole : " + err.message); - } - } - - // Single day – Garde - async setGardeSingle(date, type) { - try { - await this.manageEvent({ - action: "delete_day_types", - date, - types: GUARDE_TYPES, // ["CENTRE","AVIS"] - }); - - await this.manageEvent({ - action: "add_multiple", - events: [ - { - date, - type, - duration: 1.0, - }, - ], - }); - } catch (err) { - console.error("setGardeSingle error:", err); - alert( - "Erreur lors de la mise à jour du mode de garde : " + err.message - ); - } - } - - // Bulk – Carole - async setCaroleBulk(dates, type) { - try { - await this.manageEvent({ - action: "bulk_delete_day_types", - dates, - types: CONGE_TYPES, - }); - - await this.manageEvent({ - action: "add_multiple", - events: dates.map((date) => ({ - date, - type, - person: "Carole", - duration: 1.0, - })), - }); - } catch (err) { - console.error("setCaroleBulk error:", err); - alert("Erreur bulk congés Carole : " + err.message); - } - } - - // Bulk – Garde - async setGardeBulk(dates, type) { - try { - await this.manageEvent({ - action: "bulk_delete_day_types", - dates, - types: GUARDE_TYPES, - }); - - await this.manageEvent({ - action: "add_multiple", - events: dates.map((date) => ({ - date, - type, - duration: 1.0, - })), - }); - } catch (err) { - console.error("setGardeBulk error:", err); - alert("Erreur bulk mode de garde : " + err.message); - } - } - - async handleMenuClick(e) { - const button = e.target.closest("button[data-action]"); - if (!button) return; - - const { action, eventId, newType, type, person, date } = button.dataset; - - // ===================== SINGLE DAY – Carole / Garde / Pep ===================== - - // Single day Carole/garde (add-single depuis le menu d’un jour) - if (action === "add-single") { - const targetDate = date; - - // Switch congé Carole (OFF_CAROLE / EXTRA_OFF_CAROLE) - if (CONGE_TYPES.includes(type)) { - try { - // Récupérer les events de ce jour - const existingOnDate = this.dbEvents.filter( - (evt) => evt.date === targetDate && CONGE_TYPES.includes(evt.type) - ); - - // Supprimer tous les congés Carole ce jour-là - for (const evt of existingOnDate) { - await fetch( - "/modules/family-calendar/includes/api/manage-event.php", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - action: "delete", - event_id: evt.id, - }), - } - ); - } - - // Ajouter le nouveau type - const response = await fetch( - "/modules/family-calendar/includes/api/save-events.php", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify([ - { - date: targetDate, - type, - person: "Carole", - duration: 1.0, - }, - ]), - } - ); - if (!response.ok) throw new Error("Erreur HTTP " + response.status); - - const resEvents = await fetch( - "/modules/family-calendar/includes/api/get-events.php" - ); - const dataEvents = await resEvents.json(); - this.dbEvents = dataEvents.events || []; - this.events = [...this.dbEvents, ...this.fixedEvents]; - - this.reprocessAndRender(); - } catch (err) { - console.error("Erreur congé Carole:", err); - alert("Erreur congé Carole : " + err.message); - } - - this.clearSelection(); - return; - } - - // Switch mode de garde (CENTRE / AVIS) - if (GUARDE_TYPES.includes(type)) { - try { - const existingOnDate = this.dbEvents.filter( - (evt) => - evt.date === targetDate && GUARDE_TYPES.includes(evt.type) - ); - - // Supprimer tous les modes de garde ce jour-là - for (const evt of existingOnDate) { - await fetch( - "/modules/family-calendar/includes/api/manage-event.php", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - action: "delete", - event_id: evt.id, - }), - } - ); - } - - // Ajouter le nouveau type - const response = await fetch( - "/modules/family-calendar/includes/api/save-events.php", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify([ - { - date: targetDate, - type, - duration: 1.0, - }, - ]), - } - ); - if (!response.ok) throw new Error("Erreur HTTP " + response.status); - - const resEvents = await fetch( - "/modules/family-calendar/includes/api/get-events.php" - ); - const dataEvents = await resEvents.json(); - this.dbEvents = dataEvents.events || []; - this.events = [...this.dbEvents, ...this.fixedEvents]; - - this.reprocessAndRender(); - } catch (err) { - console.error("Erreur mode de garde:", err); - alert("Erreur mode de garde : " + err.message); - } - - this.clearSelection(); - return; - } - - // Pep (comportement existant) - const newEvent = { - date: targetDate, - type, - person, - duration: 1.0, - }; - - this.clearSelection(); - - try { - const response = await fetch( - "/modules/family-calendar/includes/api/save-events.php", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify([newEvent]), - } - ); - if (!response.ok) throw new Error("Erreur HTTP " + response.status); - - const resEvents = await fetch( - "/modules/family-calendar/includes/api/get-events.php" - ); - const dataEvents = await resEvents.json(); - this.dbEvents = dataEvents.events || []; - this.events = [...this.dbEvents, ...this.fixedEvents]; - - this.reprocessAndRender(); - } catch (err) { - console.error("Erreur ajout Pep:", err); - alert("Erreur lors de l'ajout."); - } - - return; - } - - // ===================== BULK – Carole / Garde / Pep =========================== - - if (action === "add") { - const selectedDates = this.selectedCells.map((c) => c.dataset.date); - - // Bulk congés Carole - if (CONGE_TYPES.includes(type)) { - try { - // Supprimer tous les OFF/EXTRA_OFF sur ces dates - const existingOnDates = this.dbEvents.filter( - (evt) => - selectedDates.includes(evt.date) && - CONGE_TYPES.includes(evt.type) - ); - for (const evt of existingOnDates) { - await fetch( - "/modules/family-calendar/includes/api/manage-event.php", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - action: "delete", - event_id: evt.id, - }), - } - ); - } - - // Ajouter le type choisi sur chaque date - const newEvents = selectedDates.map((d) => ({ - date: d, - type, - person: "Carole", - duration: 1.0, - })); - - const response = await fetch( - "/modules/family-calendar/includes/api/save-events.php", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(newEvents), - } - ); - if (!response.ok) throw new Error("Erreur HTTP " + response.status); - - const resEvents = await fetch( - "/modules/family-calendar/includes/api/get-events.php" - ); - const dataEvents = await resEvents.json(); - this.dbEvents = dataEvents.events || []; - this.events = [...this.dbEvents, ...this.fixedEvents]; - - this.reprocessAndRender(); - } catch (err) { - console.error("Erreur bulk congés Carole:", err); - alert("Erreur bulk congés Carole : " + err.message); - } - - this.clearSelection(); - return; - } - - // Bulk mode de garde - if (GUARDE_TYPES.includes(type)) { - try { - const existingOnDates = this.dbEvents.filter( - (evt) => - selectedDates.includes(evt.date) && - GUARDE_TYPES.includes(evt.type) - ); - for (const evt of existingOnDates) { - await fetch( - "/modules/family-calendar/includes/api/manage-event.php", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - action: "delete", - event_id: evt.id, - }), - } - ); - } - - const newEvents = selectedDates.map((d) => ({ - date: d, - type, - duration: 1.0, - })); - - const response = await fetch( - "/modules/family-calendar/includes/api/save-events.php", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(newEvents), - } - ); - if (!response.ok) throw new Error("Erreur HTTP " + response.status); - - const resEvents = await fetch( - "/modules/family-calendar/includes/api/get-events.php" - ); - const dataEvents = await resEvents.json(); - this.dbEvents = dataEvents.events || []; - this.events = [...this.dbEvents, ...this.fixedEvents]; - - this.reprocessAndRender(); - } catch (err) { - console.error("Erreur bulk mode de garde:", err); - alert("Erreur bulk mode de garde : " + err.message); - } - - this.clearSelection(); - return; - } - - // Bulk Pep (comportement existant) - const newEvents = this.selectedCells.map((cell) => ({ - date: cell.dataset.date, - type, - person, - duration: 1.0, - })); - - this.clearSelection(); - - try { - const response = await fetch( - "/modules/family-calendar/includes/api/save-events.php", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(newEvents), - } - ); - if (!response.ok) throw new Error("Erreur HTTP " + response.status); - - const resEvents = await fetch( - "/modules/family-calendar/includes/api/get-events.php" - ); - const dataEvents = await resEvents.json(); - this.dbEvents = dataEvents.events || []; - this.events = [...this.dbEvents, ...this.fixedEvents]; - - this.reprocessAndRender(); - } catch (err) { - console.error("Erreur bulk add:", err); - alert("Erreur lors de l'ajout."); - } - - return; - } - - // ===================== ALEX / LAIA (single / bulk) ========================== - - // Single day leaves Alex/Laia - if (action === "add-leave") { - const leaveDate = date; - const personId = parseInt(button.dataset.personId, 10); - const leaveType = button.dataset.leaveType; // "CP", "JRA", "JA" - - if (!leaveDate || !personId || !leaveType) { - this.clearSelection(); - return; - } - - // Bloquer tous les types de congés (CP, JRA, JA) pour Alex (2) et Laia (3) - if (personId === 2 || personId === 3) { - const available = this.getAvailableAtMonthStart( - personId, - leaveType, - leaveDate - ); - // On pose 1 jour - if (available != null && available < 1) { - const disp = available.toFixed(2); - alert( - `Impossible d'ajouter ${leaveType} pour ${ - personId === 2 ? "Alex" : "Laia" - } : il reste ${disp} jour(s) disponible(s) au début de ce mois.` - ); - this.clearSelection(); - return; - } - } - - this.clearSelection(); - - try { - // 1) Supprimer tous les congés Alex/Laia pour cette personne ce jour-là - await fetch("/modules/family-calendar/includes/api/manage-leaf.php", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - action: "delete_day_person", - date: leaveDate, - person_id: personId, - }), - }); - - // 2) Ajouter le type choisi - const responseAdd = await fetch( - "/modules/family-calendar/includes/api/save-leaves.php", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify([ - { - date: leaveDate, - person_id: personId, - leave_type: leaveType, - duration: 1.0, - }, - ]), - } - ); - if (!responseAdd.ok) { - throw new Error("Erreur HTTP " + responseAdd.status); - } - - this.leaves = await this.fetchLeaves(); - this.reprocessAndRender(); - this.renderMonthCalendar(); - } catch (err) { - console.error("Erreur set leave single:", err); - alert("Erreur congé Alex/Laia : " + err.message); - } - - return; - } - - if (action === "delete-leaves-day") { - const leaveDate = date; - if (!leaveDate) { - this.clearSelection(); - return; - } - - try { - const response = await fetch( - "/modules/family-calendar/includes/api/manage-leaf.php", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "delete_day", date: leaveDate }), - } - ); - if (!response.ok) { - const errData = await response.json().catch(() => ({})); - throw new Error( - errData.message || "Erreur HTTP " + response.status - ); - } - - this.leaves = await this.fetchLeaves(); - this.reprocessAndRender(); - this.renderMonthCalendar(); - } catch (err) { - console.error("Erreur delete leaves day:", err); - alert( - "Erreur lors de la suppression des congés Alex/Laia pour ce jour: " + - err.message - ); - } - - this.clearSelection(); - return; - } - - // Bulk leaves Alex/Laia via bulk-add-leave - if (action === "bulk-add-leave") { - if (!this._currentBulkInfo) { - this.clearMonthSelection(); - return; - } - - const selectedDates = this._currentBulkInfo.selectedDates || []; - const personId = parseInt(button.dataset.personId, 10); - const leaveType = button.dataset.leaveType; // "CP", "JRA", "JA" - - // Blocage pour Alex / Laia - if (personId === 2 || personId === 3) { - // Regrouper les dates par mois - const datesByMonth = {}; // ymKey -> dates[] - selectedDates.forEach((d) => { - const dateObj = new Date(d + "T00:00:00"); - const ymKey = `${dateObj.getFullYear()}-${String( - dateObj.getMonth() + 1 - ).padStart(2, "0")}`; - if (!datesByMonth[ymKey]) datesByMonth[ymKey] = []; - datesByMonth[ymKey].push(d); - }); - - // Pour chaque mois, vérifier Av. >= nb jours demandés dans ce mois - for (const ymKey of Object.keys(datesByMonth)) { - const datesInMonth = datesByMonth[ymKey]; - const anyDate = datesInMonth[0]; - const available = this.getAvailableAtMonthStart( - personId, - leaveType, - anyDate - ); - const needed = datesInMonth.length; - - if (available != null && available < needed) { - const disp = available.toFixed(2); - alert( - `Impossible d'ajouter ${leaveType} pour ${ - personId === 2 ? "Alex" : "Laia" - } sur ${needed} jour(s) dans ${ymKey} : il reste ${disp} jour(s) disponible(s).` - ); - this.clearMonthSelection(); - this._currentBulkInfo = null; - return; - } - } - } - - this.clearMonthSelection(); - this._currentBulkInfo = null; - - try { - // 1) supprimer les congés existants pour cette personne sur toutes les dates - await fetch("/modules/family-calendar/includes/api/manage-leaf.php", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - action: "bulk_delete_day_person", - dates: selectedDates, - person_id: personId, - }), - }); - - // 2) ajouter le nouveau type sur toutes les dates - const newLeaves = selectedDates.map((leaveDate) => ({ - date: leaveDate, - person_id: personId, - leave_type: leaveType, - duration: 1.0, - })); - - const responseAdd = await fetch( - "/modules/family-calendar/includes/api/save-leaves.php", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(newLeaves), - } - ); - if (!responseAdd.ok) { - throw new Error("Erreur HTTP " + responseAdd.status); - } - - this.leaves = await this.fetchLeaves(); - this.reprocessAndRender(); - this.renderMonthCalendar(); - } catch (err) { - console.error("Erreur bulk-add-leave (month):", err); - alert("Erreur bulk congés Alex/Laia : " + err.message); - } - - return; - } - - // ===================== SUPPRESSION SIMPLE EVENT ============================= - - if (action === "delete") { - if (!eventId) { - this.clearSelection(); - return; - } - - try { - const response = await fetch( - "/modules/family-calendar/includes/api/manage-event.php", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "delete", event_id: eventId }), - } - ); - if (!response.ok) { - const errData = await response.json().catch(() => ({})); - throw new Error( - errData.message || "Erreur HTTP " + response.status - ); - } - - const resEvents = await fetch( - "/modules/family-calendar/includes/api/get-events.php" - ); - const dataEvents = await resEvents.json(); - this.dbEvents = dataEvents.events || []; - this.events = [...this.dbEvents, ...this.fixedEvents]; - - this.reprocessAndRender(); - } catch (err) { - console.error("Erreur delete:", err); - alert("Erreur lors de la suppression : " + err.message); - } - - this.clearSelection(); - return; - } - - // ===================== SUPPRESSION BULK LEAVE ============================= - - if (action === "bulk-clear-leave") { - if (!this._currentBulkInfo) { - this.clearSelection(); - return; - } - const selectedDates = this._currentBulkInfo.selectedDates || []; - const personId = parseInt(button.dataset.personId, 10); - - try { - await fetch("/modules/family-calendar/includes/api/manage-leaf.php", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - action: "bulk_delete_day_person", - dates: selectedDates, - person_id: personId, - }), - }); - - this.leaves = await this.fetchLeaves(); - this.reprocessAndRender(); - this.renderMonthCalendar(); - } catch (err) { - console.error("Erreur bulk-clear-leave:", err); - alert( - "Erreur lors de la suppression des congés sur ces jours : " + - err.message - ); - } - - this._currentBulkInfo = null; - this.clearSelection(); - return; - } - - // ===================== UPDATE SIMPLE (utilisé par les boutons "Remplacer par") ===== - - if (action === "update") { - if (!eventId) { - this.clearSelection(); - return; - } - - try { - const response = await fetch( - "/modules/family-calendar/includes/api/manage-event.php", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - action: "update", - event_id: eventId, - new_type: newType, - }), - } - ); - if (!response.ok) { - const errData = await response.json().catch(() => ({})); - throw new Error( - errData.message || "Erreur HTTP " + response.status - ); - } - - const resEvents = await fetch( - "/modules/family-calendar/includes/api/get-events.php" - ); - const dataEvents = await resEvents.json(); - this.dbEvents = dataEvents.events || []; - this.events = [...this.dbEvents, ...this.fixedEvents]; - - this.reprocessAndRender(); - } catch (err) { - console.error("Erreur update:", err); - alert("Erreur lors de la modification : " + err.message); - } - - this.clearSelection(); - return; - } - } - - async manageEvent(payload) { - try { - const response = await fetch( - "/modules/family-calendar/includes/api/manage-event.php", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - } - ); - if (!response.ok) { - const errData = await response.json().catch(() => ({})); - throw new Error(errData.message || "Erreur HTTP " + response.status); - } - - const resEvents = await fetch( - "/modules/family-calendar/includes/api/get-events.php" - ); - if (!resEvents.ok) { - throw new Error("Erreur lors du rechargement des événements."); - } - const dataEvents = await resEvents.json(); - - this.dbEvents = dataEvents.events || []; - this.events = [...this.dbEvents, ...this.fixedEvents]; - - this.reprocessAndRender(); - this.renderMonthCalendar(); - } catch (error) { - console.error("Erreur manageEvent :", error); - alert("Erreur: " + error.message); - } - } - - // ================== CALENDRIER MENSUEL ================== - setViewMode(mode) { - this.viewMode = mode; - // Mettre à jour les boutons actifs - document.querySelectorAll(".fc-view-button").forEach((btn) => { - btn.classList.remove("fc-view-button--active"); - if (btn.dataset.view === mode) { - btn.classList.add("fc-view-button--active"); - } - }); - - // Si on passe en vue "year", caler currentMonth sur septembre - if (mode === "year" && this.currentSchoolYearStart != null) { - this.currentMonth = new Date(this.currentSchoolYearStart, 8, 1); - } - - this.renderMonthCalendar(); - } - - async navigateMonth(direction) { - if (this.viewMode === "year") { - // Navigation par année scolaire via les flèches : - // on réutilise changeSchoolYear - await this.changeSchoolYear(direction); - return; - } - - // Navigation simple par mois (1 ou 2 mois) - const year = this.currentMonth.getFullYear(); - const month = this.currentMonth.getMonth() + direction; - - this.currentMonth = new Date(year, month, 1); - this.renderMonthCalendar(); - } - renderMonthCalendar() { if (!this.monthCalendar) return; + this.monthCalendar.innerHTML = ""; // CLEAN UP - // Mettre à jour le titre selon le mode - const monthTitle = document.getElementById("fc-current-month-year"); - if (monthTitle) { - const year = this.currentMonth.getFullYear(); - const month = this.currentMonth.getMonth(); - - // Calcul de l'année scolaire à partir du mois courant - const schoolYearStart = month >= 8 ? year : year - 1; - const schoolYearEnd = schoolYearStart + 1; + const y = this.currentMonth.getFullYear(); + const m = this.currentMonth.getMonth(); + const titleEl = document.querySelector("#fc-current-month-year"); + if (titleEl) { if (this.viewMode === "year") { - // Année scolaire complète - monthTitle.textContent = `Année scolaire ${schoolYearStart}–${schoolYearEnd}`; + titleEl.textContent = `Année scolaire ${this.currentSchoolYearStart} – ${this.currentSchoolYearStart + 1}`; } else if (this.viewMode === "2months") { - const monthName = getMonthNameFr(month); - const next = new Date(year, month + 1, 1); - const nextMonthIndex = next.getMonth(); - const nextYear = next.getFullYear(); - const nextMonthName = getMonthNameFr(nextMonthIndex); - - monthTitle.textContent = `${monthName} ${year} – ${nextMonthName} ${nextYear} (${schoolYearStart}–${schoolYearEnd})`; + const nextM = new Date(y, m + 1, 1); + const m1 = new Intl.DateTimeFormat("fr-FR", { month: "long" }).format( + this.currentMonth, + ); + const m2 = new Intl.DateTimeFormat("fr-FR", { + month: "long", + year: "numeric", + }).format(nextM); + titleEl.textContent = `${m1} - ${m2}`; } else { - // Vue 1 mois - const monthName = getMonthNameFr(month); - monthTitle.textContent = `${monthName} ${year} (${schoolYearStart}–${schoolYearEnd})`; + titleEl.textContent = new Intl.DateTimeFormat("fr-FR", { + month: "long", + year: "numeric", + }).format(this.currentMonth); } } - // Appeler la fonction appropriée selon le mode if (this.viewMode === "year") { this.renderYearView(); - } else if (this.viewMode === "2months") { + return; + } + if (this.viewMode === "2months") { this.renderTwoMonthsView(); - } else { - this.renderSingleMonthView(); - } - } - - updateSchoolYearLabel() { - const label = document.getElementById("fc-current-school-year-label"); - if (!label || this.currentSchoolYearStart == null) return; - const start = this.currentSchoolYearStart; - const end = start + 1; - label.textContent = `Année scolaire ${start}–${end}`; - } - - async changeSchoolYear(delta) { - // delta = -1 ou +1 - this.currentSchoolYearStart = (this.currentSchoolYearStart || 0) + delta; - - // Mettre currentMonth sur septembre de cette nouvelle année pour la vue "year" - // (pour le mensuel, on garde currentMonth tel quel, sauf si tu passes explicitement en vue year) - if (this.viewMode === "year") { - this.currentMonth = new Date(this.currentSchoolYearStart, 8, 1); // septembre + return; } - // Recharger les semaines de cette année scolaire - this.weeks = await this.fetchWeeksStructureScolaire( - this.currentSchoolYearStart - ); - - // Mettre à jour données / affichage - this.reprocessAndRender(); - this.updateSchoolYearLabel(); - this.renderMonthCalendar(); - } - - renderSingleMonthView() { - const year = this.currentMonth.getFullYear(); - const month = this.currentMonth.getMonth(); - const calendarHTML = this.generateMonthHTML(year, month); - const summaryHTML = this.generateMonthSummaryHTML(year, month); - this.monthCalendar.innerHTML = calendarHTML + summaryHTML; - } - - calculateMonthTotals(year, month) { - const lastDay = new Date(year, month + 1, 0); - const totals = { - offCarole: 0, - extraOffCarole: 0, - centre: 0, - avis: 0, - pepSick: 0, - presencePep: 0, - workingDays: 0, // jours potentiels d'accueil Pep - }; - - for (let day = 1; day <= lastDay.getDate(); day++) { - const date = new Date(year, month, day); - const dayOfWeek = date.getDay(); // 0=dim, 6=sam - - if (dayOfWeek === 0 || dayOfWeek === 6) { - continue; // on saute samedi/dimanche - } - - const isoDate = `${year}-${String(month + 1).padStart(2, "0")}-${String( - day - ).padStart(2, "0")}`; - - // Si jour férié, ce n'est PAS un jour potentiel d'accueil Pep - const isPublicHoliday = - this.publicHolidayDates && this.publicHolidayDates.has(isoDate); - - if (!isPublicHoliday) { - totals.workingDays++; - } - - // Événements de base (pf_events) pour ce jour - const dayEvents = this.dbEvents.filter((evt) => evt.date === isoDate); - - dayEvents.forEach((evt) => { - const dur = parseFloat(evt.duration) || 1; - switch (evt.type) { - case "OFF_CAROLE": - totals.offCarole += dur; - break; - case "EXTRA_OFF_CAROLE": - totals.extraOffCarole += dur; - break; - case "CENTRE": - totals.centre += dur; - break; - case "AVIS": - totals.avis += dur; - break; - case "PEP_SICK": - totals.pepSick += dur; - break; - } - }); - } - - const absencesPep = - (totals.offCarole || 0) + - (totals.extraOffCarole || 0) + - (totals.pepSick || 0); - - totals.presencePep = Math.max(0, totals.workingDays - absencesPep); - - return totals; - } - - generateMonthSummaryHTML(year, month) { - const totals = this.calculateMonthTotals(year, month); - const formatTotal = (total) => - total > 0 ? (Number.isInteger(total) ? total : total.toFixed(2)) : ""; - - const monthLabel = `${getMonthNameFr(month)} ${year}`; - - return ` -
- - - - - - - - - - - - - - - - - - - - - -
MoisJours ouvrés# Off Carole# Extra off Carole# Pep malade# Présence Pep
${monthLabel}${formatTotal(totals.workingDays)}${formatTotal(totals.offCarole)}${formatTotal(totals.extraOffCarole)}${formatTotal(totals.pepSick)}${formatTotal(totals.presencePep)}
-
- `; - } - - renderMonthSummary() { - const summaryDiv = document.getElementById("fc-month-summary"); - if (!summaryDiv) return; - - let html = '
'; - html += ''; - html += ""; - html += ""; - html += ""; - html += ""; - html += ""; - html += ""; - html += ""; - - const formatTotal = (total) => - total > 0 ? (Number.isInteger(total) ? total : total.toFixed(1)) : ""; - - if (this.viewMode === "year") { - // Afficher tous les mois de l'année scolaire - const year = this.currentMonth.getFullYear(); - const schoolYearMonths = []; - for (let month = 8; month < 12; month++) { - schoolYearMonths.push({ year, month }); - } - for (let month = 0; month < 8; month++) { - schoolYearMonths.push({ year: year + 1, month }); - } - - schoolYearMonths.forEach(({ year: monthYear, month }) => { - const totals = this.calculateMonthTotals(monthYear, month); - html += ""; - html += ``; - html += ``; - html += ``; - html += ``; - html += ``; - html += ""; - }); - - // Ligne de total - const yearTotals = schoolYearMonths.reduce( - (acc, { year: monthYear, month }) => { - const totals = this.calculateMonthTotals(monthYear, month); - acc.offCarole += totals.offCarole; - acc.extraOffCarole += totals.extraOffCarole; - acc.centre += totals.centre; - acc.avis += totals.avis; - return acc; - }, - { offCarole: 0, extraOffCarole: 0, centre: 0, avis: 0 } - ); - html += ''; - html += ``; - html += ``; - html += ``; - html += ``; - html += ``; - html += ""; - } else if (this.viewMode === "2months") { - // Afficher les 2 mois - const year = this.currentMonth.getFullYear(); - const month = this.currentMonth.getMonth(); - const nextMonth = month + 1; - const nextYear = nextMonth > 11 ? year + 1 : year; - const nextMonthIndex = nextMonth > 11 ? 0 : nextMonth; - - // Premier mois - const totals1 = this.calculateMonthTotals(year, month); - html += ""; - html += ``; - html += ``; - html += ``; - html += ``; - html += ``; - html += ""; - - // Deuxième mois - const totals2 = this.calculateMonthTotals(nextYear, nextMonthIndex); - html += ""; - html += ``; - html += ``; - html += ``; - html += ``; - html += ``; - html += ""; - - // Ligne de total - const combinedTotals = { - offCarole: totals1.offCarole + totals2.offCarole, - extraOffCarole: totals1.extraOffCarole + totals2.extraOffCarole, - centre: totals1.centre + totals2.centre, - avis: totals1.avis + totals2.avis, - }; - html += ''; - html += ""; - html += ``; - html += ``; - html += ``; - html += ``; - html += ""; - } else { - // Afficher 1 mois - const year = this.currentMonth.getFullYear(); - const month = this.currentMonth.getMonth(); - const totals = this.calculateMonthTotals(year, month); - html += ""; - html += ``; - html += ``; - html += ``; - html += ``; - html += ``; - html += ""; - } - - html += "
Mois# Off Carole# Extra off Carole# Centre# Avis
${getMonthNameFr(month)} ${monthYear}${formatTotal(totals.offCarole)}${formatTotal(totals.extraOffCarole)}${formatTotal(totals.centre)}${formatTotal(totals.avis)}
Total ${year}-${year + 1}${formatTotal( - yearTotals.offCarole - )}${formatTotal( - yearTotals.extraOffCarole - )}${formatTotal(yearTotals.centre)}${formatTotal(yearTotals.avis)}
${getMonthNameFr(month)} ${ - month >= 8 ? year : year + 1 - }${formatTotal(totals1.offCarole)}${formatTotal(totals1.extraOffCarole)}${formatTotal(totals1.centre)}${formatTotal(totals1.avis)}
${getMonthNameFr(nextMonthIndex)} ${ - nextMonthIndex >= 8 ? nextYear : nextYear + 1 - }${formatTotal(totals2.offCarole)}${formatTotal(totals2.extraOffCarole)}${formatTotal(totals2.centre)}${formatTotal(totals2.avis)}
Total${formatTotal( - combinedTotals.offCarole - )}${formatTotal( - combinedTotals.extraOffCarole - )}${formatTotal( - combinedTotals.centre - )}${formatTotal(combinedTotals.avis)}
${getMonthNameFr(month)} ${ - month >= 8 ? year : year + 1 - }${formatTotal(totals.offCarole)}${formatTotal(totals.extraOffCarole)}${formatTotal(totals.centre)}${formatTotal(totals.avis)}
"; - html += "
"; - summaryDiv.innerHTML = html; + this.monthCalendar.innerHTML = this.generateMonthHTML(y, m); } renderTwoMonthsView() { - const year = this.currentMonth.getFullYear(); - const month = this.currentMonth.getMonth(); - const nextMonth = month + 1; - const nextYear = nextMonth > 11 ? year + 1 : year; - const nextMonthIndex = nextMonth > 11 ? 0 : nextMonth; - - let html = '
'; - - // Premier mois - html += `
`; - html += `
${getMonthNameFr(month)} ${ - month >= 8 ? year : year + 1 - }
`; - html += this.generateMonthHTML(year, month); - html += this.generateMonthSummaryHTML(year, month); + const y = this.currentMonth.getFullYear(); + const m = this.currentMonth.getMonth(); + const nextDate = new Date(y, m + 1, 1); + let html = `
`; + html += `
${new Intl.DateTimeFormat("fr-FR", { month: "long" }).format(this.currentMonth)}
${this.generateMonthHTML(y, m)}
`; + html += `
${new Intl.DateTimeFormat("fr-FR", { month: "long" }).format(nextDate)}
${this.generateMonthHTML(nextDate.getFullYear(), nextDate.getMonth())}
`; html += `
`; - - // Deuxième mois - html += `
`; - html += `
${getMonthNameFr(nextMonthIndex)} ${ - nextMonthIndex >= 8 ? nextYear : nextYear + 1 - }
`; - html += this.generateMonthHTML(nextYear, nextMonthIndex); - html += this.generateMonthSummaryHTML(nextYear, nextMonthIndex); - html += `
`; - - html += "
"; this.monthCalendar.innerHTML = html; } renderYearView() { - const year = this.currentMonth.getFullYear(); + const startYear = this.currentSchoolYearStart; let html = '
'; - const schoolYearMonths = []; - for (let month = 8; month < 12; month++) { - schoolYearMonths.push({ year, month }); - } - for (let month = 0; month < 8; month++) { - schoolYearMonths.push({ year: year + 1, month }); - } - - schoolYearMonths.forEach(({ year: monthYear, month }) => { - html += `
`; - html += `
${getMonthNameFr( - month - )} ${monthYear}
`; - html += this.generateMonthHTML(monthYear, month); - html += this.generateMonthSummaryHTML(monthYear, month); - html += `
`; - }); + // Sept-Dec + for (let i = 8; i < 12; i++) + html += `
${new Intl.DateTimeFormat("fr-FR", { month: "long" }).format(new Date(startYear, i))}
${this.generateMonthHTML(startYear, i)}
`; + // Jan-Aug + for (let i = 0; i < 8; i++) + html += `
${new Intl.DateTimeFormat("fr-FR", { month: "long" }).format(new Date(startYear + 1, i))}
${this.generateMonthHTML(startYear + 1, i)}
`; html += "
"; this.monthCalendar.innerHTML = html; } generateMonthHTML(year, month) { - // Premier jour du mois et dernier jour + let html = ``; + ["L", "M", "M", "J", "V"].forEach((d) => (html += ``)); + html += ``; + const firstDay = new Date(year, month, 1); - const lastDay = new Date(year, month + 1, 0); - const daysInMonth = lastDay.getDate(); - // Calculer le premier lundi du mois (ou avant si le 1er n'est pas un lundi) - const firstDayOfWeek = - firstDay.getDay() === 0 ? 6 : firstDay.getDay() - 1; // Lundi = 0 - const startDayOfWeek = firstDayOfWeek; // 0 = Lundi, 4 = Vendredi + const startDay = (firstDay.getDay() + 6) % 7; + const daysInMonth = new Date(year, month + 1, 0).getDate(); + let dayCounter = 1; - // Noms des jours (uniquement semaine : Lun-Ven) - const dayNames = ["Lun", "Mar", "Mer", "Jeu", "Ven"]; + for (let row = 0; row < 6; row++) { + html += ``; + for (let col = 0; col < 5; col++) { + if ( + (row === 0 && col < startDay && startDay < 5) || + dayCounter > daysInMonth + ) { + html += ``; + } else if (row === 0 && startDay >= 5) { + html += ``; + } else { + const iso = `${year}-${String(month + 1).padStart(2, "0")}-${String(dayCounter).padStart(2, "0")}`; + 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 html = '
${d}
'; - dayNames.forEach((day) => { - html += ``; - }); - html += ""; - - // Jours du mois précédent (si nécessaire) - seulement les jours de semaine - let dayCount = 0; - html += ""; - // On ne remplit que jusqu'à vendredi (5 jours max) - const daysToFill = Math.min(startDayOfWeek, 5); - for (let i = 0; i < daysToFill; i++) { - html += ''; - dayCount++; - } - - // Jours du mois actuel (uniquement lundi à vendredi) - for (let day = 1; day <= daysInMonth; day++) { - const date = new Date(year, month, day); - const dayOfWeek = date.getDay(); // 0 = Dimanche, 1 = Lundi, ..., 6 = Samedi - - // Ignorer les samedi (6) et dimanche (0) - if (dayOfWeek === 0 || dayOfWeek === 6) { - continue; - } - - // Nouvelle ligne chaque lundi (quand dayCount est un multiple de 5) - if (dayCount > 0 && dayCount % 5 === 0) { - html += ""; - } - - const isoDate = `${year}-${String(month + 1).padStart(2, "0")}-${String( - day - ).padStart(2, "0")}`; - - // Trouver les événements pour ce jour - const dayEvents = this.events.filter((evt) => evt.date === isoDate); - - let classes = "fc-month-day"; - let hasGarde = false; - let gardeType = null; - let hasPepSick = false; // <- ajouté - - dayEvents.forEach((evt) => { - const classMap = { - VACANCES_SCOLAIRES: "fc-day--school-holiday", - PUBLIC_HOLIDAY: "fc-day--public-holiday", - OFF_CAROLE: "fc-day--off-carole", - EXTRA_OFF_CAROLE: "fc-day--extra-off-carole", - // PEP_SICK: "fc-day--pep-sick", - }; - - if (classMap[evt.type]) { - classes += " " + classMap[evt.type]; + 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 += ``; + dayCounter++; } - - if (GUARDE_TYPES.includes(evt.type)) { - hasGarde = true; - gardeType = evt.type; - } - - if (evt.type === "PEP_SICK") { - hasPepSick = true; - } - }); - - if (hasGarde) { - classes += " fc-day--has-guard"; - if (gardeType === "CENTRE") classes += " fc-day--centre"; - if (gardeType === "AVIS") classes += " fc-day--avis"; } - - // Construire la cellule avec indicateur congés Alex/Laia - // (on génère un TD "manuel" pour pouvoir inclure le conteneur) - const leavesOnDay = this.leaves.filter( - (lv) => lv.leave_date === isoDate - ); - - let cellInnerHTML = `${day}`; - - if (hasPepSick) { - cellInnerHTML += `🤒`; - } - - if (leavesOnDay.length > 0) { - const hasAlex = leavesOnDay.some((lv) => lv.person_id === 2); // Alex - const hasLaia = leavesOnDay.some((lv) => lv.person_id === 3); // Laia - - let leavesHtml = `
`; - if (hasAlex) leavesHtml += `AF`; - if (hasLaia) leavesHtml += `LM`; - leavesHtml += `
`; - - cellInnerHTML += leavesHtml; - } - - html += ``; - dayCount++; + html += ``; + if (dayCounter > daysInMonth) break; } - - // Jours du mois suivant (pour compléter la dernière ligne) - seulement jusqu'à vendredi - const remainingDays = 5 - (dayCount % 5); - if (remainingDays < 5 && remainingDays > 0) { - for (let i = 0; i < remainingDays; i++) { - html += ''; - } - } - html += "
${day}
${content}${cellInnerHTML}
"; - + html += ``; return html; } - handleMonthMouseDown(e) { - const cell = e.target.closest("td[data-date]"); - if (!cell) return; - e.preventDefault(); - this.clearMonthSelection(); - - this.isMonthSelecting = true; - cell.classList.add("fc-day--selected"); - this.monthSelectedCells.push(cell); + renderSchoolHolidaysTable(records) { + if (!this.schoolHolidaysTableBody) return; + records.sort((a, b) => new Date(a.start_date) - new Date(b.start_date)); + this.schoolHolidaysTableBody.innerHTML = records + .map( + (r) => ` + + ${r.description} + ${new Date(r.start_date).toLocaleDateString("fr-FR")} + ${new Date(r.end_date).toLocaleDateString("fr-FR")} + ${r.zones} + + `, + ) + .join(""); } - handleMonthMouseMove(e) { - if (!this.isMonthSelecting) return; - const cell = e.target.closest("td[data-date]"); - if (cell && !this.monthSelectedCells.includes(cell)) { - cell.classList.add("fc-day--selected"); - this.monthSelectedCells.push(cell); - } + updateGlobalSummary() { + const div = document.getElementById("globalSummary"); + if (!div) return; + const stats = { off: 0, extra: 0, sick: 0, pep: 0, totalWorking: 0 }; + this.weeks.forEach((w) => { + stats.off += w.totals.offCarole; + stats.extra += w.totals.extraOffCarole; + stats.sick += w.totals.pepSick; + stats.pep += w.totals.presencePep; + Object.values(w.dayDates).forEach((d) => { + if (!this.publicHolidayDates.has(d.toISOString().split("T")[0])) + stats.totalWorking++; + }); + }); + div.innerHTML = ` +

Off Carole : ${stats.off} jours

+

Extra Off Carole : ${stats.extra} jours

+

Pep malade : ${stats.sick} jours

+

Présence Pep : ${stats.pep} jours

+

(Sur jours ouvrés totaux : ${stats.totalWorking})

+ `; } - handleMonthMouseUp(e) { - if (!this.isMonthSelecting) return; - this.isMonthSelecting = false; - if (this.monthSelectedCells.length === 0) return; + updateSchoolYearLabel() { + const lbl = document.getElementById("fc-current-school-year-label"); + if (lbl) + lbl.textContent = `${this.currentSchoolYearStart} – ${this.currentSchoolYearStart + 1}`; + } - if (this.monthSelectedCells.length === 1) { - const date = this.monthSelectedCells[0].dataset.date; - const eventsOnDay = this.events.filter( - (evt) => evt.date === date && MODIFIABLE_TYPES.includes(evt.type) - ); - const conge = eventsOnDay.find((e) => CONGE_TYPES.includes(e.type)); - const garde = eventsOnDay.find((e) => GUARDE_TYPES.includes(e.type)); - const pep = eventsOnDay.find((e) => PEP_TYPES.includes(e.type)); + // ================== INTERACTIONS ================== - // Toujours ouvrir le menu complet qui inclut Alex/Laia - this.showMonthEditMenuForDay(e, { conge, garde, pep, date }); - return; + setupEventListeners() { + // Planning Hebdo + this.planningBody.addEventListener("mousedown", (e) => + this.handleMouseDown(e), + ); + document.addEventListener("mousemove", (e) => this.handleMouseMove(e)); + document.addEventListener("mouseup", (e) => this.handleMouseUp(e)); + + // Calendrier Mensuel (Clic simple pour ouvrir le menu) + if (this.monthCalendar) { + this.monthCalendar.addEventListener("click", (e) => { + const td = e.target.closest("td[data-date]"); + if (td) { + this.monthSelectedCells = [td]; // Simuler sélection unique + this.showMenu(e.pageX, e.pageY, [td.dataset.date], true); + } + }); } - // ===== Multi-jours : toujours ouvrir le menu bulk ===== - const selectedDates = this.monthSelectedCells.map((c) => c.dataset.date); - const eventsOnDates = this.events.filter( - (evt) => - selectedDates.includes(evt.date) && - MODIFIABLE_TYPES.includes(evt.type) - ); - - const conges = eventsOnDates.filter((ev) => - CONGE_TYPES.includes(ev.type) - ); - const gardes = eventsOnDates.filter((ev) => - GUARDE_TYPES.includes(ev.type) - ); - - const uniqueCongeTypes = new Set(conges.map((c) => c.type)); - const uniqueGardeTypes = new Set(gardes.map((g) => g.type)); - - const allDatesHaveConge = - conges.length === selectedDates.length && uniqueCongeTypes.size === 1; - const allDatesHaveGarde = - gardes.length === selectedDates.length && uniqueGardeTypes.size === 1; - - const bulkInfo = { - selectedDates, - conges, - gardes, - congeType: allDatesHaveConge ? conges[0].type : null, - gardeType: allDatesHaveGarde ? gardes[0].type : null, + // Menus Contextuels + document.addEventListener("click", (e) => this.closeMenusIfOutside(e)); + const handleMenu = (e) => { + const btn = e.target.closest("button"); + if (btn) this.handleMenuAction(btn.dataset); }; + if (this.selectionMenu) + this.selectionMenu.addEventListener("click", handleMenu); + if (this.monthSelectionMenu) + this.monthSelectionMenu.addEventListener("click", handleMenu); - // Peu importe qu'il y ait déjà des congés ou non, on ouvre le bulk - this.showMonthBulkMenu(e, bulkInfo); + // Boutons Navigation Mensuelle + 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(); + }); + + // Boutons Navigation Année Scolaire + document + .getElementById("fc-prev-school-year") + ?.addEventListener("click", () => this.changeSchoolYear(-1)); + document + .getElementById("fc-next-school-year") + ?.addEventListener("click", () => this.changeSchoolYear(1)); + + // Boutons de Vue (1 mois / 2 mois / Année) + 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(); + }); + }); } - clearMonthSelection() { - if (this.monthSelectionMenu) { + 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; + + // FIX CRITIQUE : Si on relâche la souris sur une case qu'on n'a pas eu le temps de "survoler" (mouvement rapide) + // on l'ajoute maintenant pour être sûr qu'elle est dans la sélection. + 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); + } + + 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 = []; + this.selectionMenu.style.display = "none"; + if (this.monthSelectionMenu) this.monthSelectionMenu.style.display = "none"; - } - this.monthSelectedCells.forEach((cell) => - cell.classList.remove("fc-day--selected") - ); - this.monthSelectedCells = []; } - showMonthAddMenu(e) { - if (!this.monthSelectionMenu) return; - - this.monthSelectionMenu.innerHTML = ` -
- Ajouter -
- - -
-
- -
- Mode de garde -
- - -
-
- -
- Pep -
- -
-
- `; - - this.positionAndShowMonthMenu(e); + closeMenusIfOutside(e) { + if ( + !this.selectionMenu.contains(e.target) && + !this.monthSelectionMenu?.contains(e.target) && + !this.menuJustOpened + ) { + this.clearSelection(); + } } - showMonthEditMenuForDay(e, { conge, garde, pep, date }) { - if (!this.monthSelectionMenu) return; + showMenu(x, y, dates, isMonthView) { + const menu = isMonthView ? this.monthSelectionMenu : this.selectionMenu; + if (!menu) return; - // --- Section congé Carole --- - let congeSection = ""; - if (conge) { - const oppositeConge = - conge.type === "OFF_CAROLE" ? "EXTRA_OFF_CAROLE" : "OFF_CAROLE"; - congeSection = ` -
- Congé Carole -
- - -
-
- `; - } else { - congeSection = ` -
- Ajouter un congé -
- - -
-
- `; - } + this._currentBulkInfo = { dates }; + const dateLabel = + dates.length > 1 + ? `${dates.length} jours` + : new Date(dates[0]).toLocaleDateString("fr-FR"); - // --- Section mode de garde --- - let gardeSection = ""; - if (garde) { - const oppositeGarde = garde.type === "CENTRE" ? "AVIS" : "CENTRE"; - gardeSection = ` -
- Mode de garde -
- - -
-
- `; - } else { - gardeSection = ` -
- Ajouter mode de garde -
- - -
-
- `; - } + let html = `
${dateLabel}
`; - // --- Section Pep malade --- - let pepSection = ""; - if (pep) { - pepSection = ` -
- Pep - -
- `; - } else { - pepSection = ` -
- Pep - -
- `; - } + // Carole + html += `
Congés Carole
`; + html += ``; + html += ``; + html += `
`; - // --- Section congés Alex / Laia --- - const leavesOnDay = (this.leaves || []).filter( - (lv) => lv.leave_date === date - ); + // Garde + html += `
Garde
`; + html += ``; + html += ``; + html += `
`; - let leavesSection = ` -
- Congés Alex / Laia -
- - - - - - - - - - - - - - - - - - - - - - - - - -
AlexLaia
- - - -
- - - -
- - - -
-
-
- `; + // Pep + html += `
Pep`; + html += ``; + html += `
`; - if (leavesOnDay.length > 0) { - leavesSection += ` - - `; - } + // Alex/Laia + html += `
Congés Alex / Laia
`; + ["CP", "JRA", "JA"].forEach((t) => { + html += ``; + html += ``; + }); + html += `
AlexLaia
`; + html += `
`; - this.monthSelectionMenu.innerHTML = - congeSection + gardeSection + pepSection + leavesSection; - this.positionAndShowMonthMenu(e); - } + menu.innerHTML = html; - showMonthBulkMenu(e, bulkInfo) { - if (!this.monthSelectionMenu) return; + const menuWidth = 240; + let left = x + 10; + if (left + menuWidth > window.innerWidth) left = x - menuWidth - 10; - const { selectedDates } = bulkInfo; - const nbDays = selectedDates.length; - - let html = ` -
- Actions multi-jours -
- `; - - // === Congés Carole (toujours visible) === - html += ` -
- Congés Carole (${nbDays} jours) -
- - -
- -
- `; - - // === Mode de garde (toujours visible) === - html += ` -
- Mode de garde (${nbDays} jours) -
- - -
- -
- `; - - // === Congés Alex / Laia (tableau identique au single) === - html += ` -
- Congés Alex / Laia (${nbDays} jours) -
- - - - - - - - - - - - - - - - - - - - - -
AlexLaia
- - - -
- - - -
- - - -
-
- - -
- `; - - this.monthSelectionMenu.innerHTML = html; - this._currentBulkInfo = bulkInfo; - this.positionAndShowMonthMenu(e); - } - - positionAndShowMonthMenu(e) { - if (!this.monthSelectionMenu) return; - - // parent direct qui contient le calendrier + le menu - const wrapper = this.monthSelectionMenu.parentElement; // .fc-calendar-and-summary - const rect = wrapper.getBoundingClientRect(); - - this.monthSelectionMenu.style.display = "block"; - - // Position relative au wrapper, comme pour l’hebdo - this.monthSelectionMenu.style.left = `${e.clientX - rect.left + 5}px`; - this.monthSelectionMenu.style.top = `${e.clientY - rect.top + 5}px`; + menu.style.left = `${left}px`; + menu.style.top = `${y + 10}px`; + menu.style.display = "block"; this.menuJustOpened = true; - setTimeout(() => { - this.menuJustOpened = false; - }, 0); + setTimeout(() => (this.menuJustOpened = false), 100); } - async handleMonthMenuClick(e) { - const button = e.target.closest("button[data-action]"); - if (!button) return; + async handleMenuAction(dataset) { + this.selectionMenu.style.display = "none"; + if (this.monthSelectionMenu) + this.monthSelectionMenu.style.display = "none"; - const { action, eventId, newType, type, person, date } = button.dataset; + const { action, type, pid, cat } = dataset; + const dates = this._currentBulkInfo.dates; - // === AJOUT MULTI-JOURS (Carole / garde / Pep) ============================ - if (action === "add") { - const selectedDates = this.monthSelectedCells.map( - (c) => c.dataset.date - ); + 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; - // Bulk congés Carole - if (CONGE_TYPES.includes(type)) { - try { - const existingOnDates = this.dbEvents.filter( - (evt) => - selectedDates.includes(evt.date) && - CONGE_TYPES.includes(evt.type) - ); - for (const evt of existingOnDates) { - await fetch( - "/modules/family-calendar/includes/api/manage-event.php", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - action: "delete", - event_id: evt.id, - }), - } - ); - } - - const newEvents = selectedDates.map((d) => ({ - date: d, - type, - person: "Carole", - duration: 1.0, - })); - - const response = await fetch( - "/modules/family-calendar/includes/api/save-events.php", + if (typesToClear.length) { + await this.postApi( + "/modules/family-calendar/includes/api/manage-event.php", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(newEvents), - } + action: "bulk_delete_day_types", + dates, + types: typesToClear, + }, ); - if (!response.ok) throw new Error("Erreur HTTP " + response.status); - - const resEvents = await fetch( - "/modules/family-calendar/includes/api/get-events.php" - ); - const dataEvents = await resEvents.json(); - this.dbEvents = dataEvents.events || []; - this.events = [...this.dbEvents, ...this.fixedEvents]; - - this.reprocessAndRender(); - this.renderMonthCalendar(); - } catch (err) { - console.error("Erreur bulk Carole (month):", err); - alert("Erreur bulk congés Carole : " + err.message); } - - this.clearMonthSelection(); - return; - } - - // Bulk mode de garde - if (GUARDE_TYPES.includes(type)) { - try { - const existingOnDates = this.dbEvents.filter( - (evt) => - selectedDates.includes(evt.date) && - GUARDE_TYPES.includes(evt.type) - ); - for (const evt of existingOnDates) { - await fetch( - "/modules/family-calendar/includes/api/manage-event.php", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - action: "delete", - event_id: evt.id, - }), - } - ); - } - - const newEvents = selectedDates.map((d) => ({ - date: d, - type, - duration: 1.0, - })); - - const response = await fetch( - "/modules/family-calendar/includes/api/save-events.php", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(newEvents), - } - ); - if (!response.ok) throw new Error("Erreur HTTP " + response.status); - - const resEvents = await fetch( - "/modules/family-calendar/includes/api/get-events.php" - ); - const dataEvents = await resEvents.json(); - this.dbEvents = dataEvents.events || []; - this.events = [...this.dbEvents, ...this.fixedEvents]; - - this.reprocessAndRender(); - this.renderMonthCalendar(); - } catch (err) { - console.error("Erreur bulk garde (month):", err); - alert("Erreur bulk mode de garde : " + err.message); - } - - this.clearMonthSelection(); - return; - } - - // Bulk Pep - const newEvents = this.monthSelectedCells.map((cell) => ({ - date: cell.dataset.date, - type, - person, - duration: 1.0, - })); - - this.clearMonthSelection(); - - try { - const response = await fetch( + const payload = dates.map((d) => ({ + date: d, + type: type, + duration: 1, + person: "Carole", + })); + await this.postApi( "/modules/family-calendar/includes/api/save-events.php", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(newEvents), - } + payload, ); - if (!response.ok) throw new Error("Erreur HTTP " + response.status); + } 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; - const resEvents = await fetch( - "/modules/family-calendar/includes/api/get-events.php" - ); - const dataEvents = await resEvents.json(); - this.dbEvents = dataEvents.events || []; - this.events = [...this.dbEvents, ...this.fixedEvents]; - - this.reprocessAndRender(); - this.renderMonthCalendar(); - } catch (err) { - console.error("Erreur bulk add (month):", err); - alert("Erreur lors de l'ajout."); - } - return; - } - - // === AJOUT SUR UNE SEULE DATE (Carole / garde / Pep) ===================== - if (action === "add-single") { - const targetDate = date; - - // Switch congé Carole - if (CONGE_TYPES.includes(type)) { - try { - const existingOnDate = this.dbEvents.filter( - (evt) => evt.date === targetDate && CONGE_TYPES.includes(evt.type) - ); - for (const evt of existingOnDate) { - await fetch( - "/modules/family-calendar/includes/api/manage-event.php", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - action: "delete", - event_id: evt.id, - }), - } - ); - } - - const response = await fetch( - "/modules/family-calendar/includes/api/save-events.php", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify([ - { - date: targetDate, - type, - person: "Carole", - duration: 1.0, - }, - ]), - } - ); - if (!response.ok) throw new Error("Erreur HTTP " + response.status); - - const resEvents = await fetch( - "/modules/family-calendar/includes/api/get-events.php" - ); - const dataEvents = await resEvents.json(); - this.dbEvents = dataEvents.events || []; - this.events = [...this.dbEvents, ...this.fixedEvents]; - - this.reprocessAndRender(); - this.renderMonthCalendar(); - } catch (err) { - console.error("Erreur Carole (month single):", err); - alert("Erreur congé Carole : " + err.message); - } - - this.clearMonthSelection(); - return; - } - - // Switch mode de garde - if (GUARDE_TYPES.includes(type)) { - try { - const existingOnDate = this.dbEvents.filter( - (evt) => - evt.date === targetDate && GUARDE_TYPES.includes(evt.type) - ); - for (const evt of existingOnDate) { - await fetch( - "/modules/family-calendar/includes/api/manage-event.php", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - action: "delete", - event_id: evt.id, - }), - } - ); - } - - const response = await fetch( - "/modules/family-calendar/includes/api/save-events.php", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify([ - { - date: targetDate, - type, - duration: 1.0, - }, - ]), - } - ); - if (!response.ok) throw new Error("Erreur HTTP " + response.status); - - const resEvents = await fetch( - "/modules/family-calendar/includes/api/get-events.php" - ); - const dataEvents = await resEvents.json(); - this.dbEvents = dataEvents.events || []; - this.events = [...this.dbEvents, ...this.fixedEvents]; - - this.reprocessAndRender(); - this.renderMonthCalendar(); - } catch (err) { - console.error("Erreur garde (month single):", err); - alert("Erreur mode de garde : " + err.message); - } - - this.clearMonthSelection(); - return; - } - - // Pep - const newEvent = { - date: targetDate, - type, - person, - duration: 1.0, - }; - - this.clearMonthSelection(); - - try { - const response = await fetch( - "/modules/family-calendar/includes/api/save-events.php", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify([newEvent]), - } - ); - if (!response.ok) throw new Error("Erreur HTTP " + response.status); - - const resEvents = await fetch( - "/modules/family-calendar/includes/api/get-events.php" - ); - const dataEvents = await resEvents.json(); - this.dbEvents = dataEvents.events || []; - this.events = [...this.dbEvents, ...this.fixedEvents]; - - this.reprocessAndRender(); - this.renderMonthCalendar(); - } catch (err) { - console.error("Erreur add (month single):", err); - alert("Erreur lors de l'ajout."); - } - return; - } - - // === SUPPRESSION SIMPLE (Carole / garde / Pep) =========================== - if (action === "delete") { - if (!eventId) { - this.clearMonthSelection(); - return; - } - - this.clearMonthSelection(); - - try { - const response = await fetch( + await this.postApi( "/modules/family-calendar/includes/api/manage-event.php", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "delete", event_id: eventId }), - } + action: "bulk_delete_day_types", + dates, + types: typesToClear, + }, ); - if (!response.ok) { - const errData = await response.json().catch(() => ({})); - throw new Error( - errData.message || "Erreur HTTP " + response.status - ); - } - - const resEvents = await fetch( - "/modules/family-calendar/includes/api/get-events.php" - ); - const dataEvents = await resEvents.json(); - this.dbEvents = dataEvents.events || []; - this.events = [...this.dbEvents, ...this.fixedEvents]; - - this.reprocessAndRender(); - this.renderMonthCalendar(); - } catch (err) { - console.error("Erreur delete (month):", err); - alert("Erreur lors de la suppression : " + err.message); - } - return; - } - - // === SUPPRESSION BULK ALEX / LAIA =========================== - if (action === "bulk-clear-leave") { - if (!this._currentBulkInfo) { - this.clearSelection(); - return; - } - const selectedDates = this._currentBulkInfo.selectedDates || []; - const personId = parseInt(button.dataset.personId, 10); - - try { - await fetch("/modules/family-calendar/includes/api/manage-leaf.php", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - action: "bulk_delete_day_person", - dates: selectedDates, - person_id: personId, - }), - }); - - this.leaves = await this.fetchLeaves(); - this.reprocessAndRender(); - this.renderMonthCalendar(); - } catch (err) { - console.error("Erreur bulk-clear-leave:", err); - alert( - "Erreur lors de la suppression des congés sur ces jours : " + - err.message - ); - } - - this._currentBulkInfo = null; - this.clearSelection(); - return; - } - - // === MODIFICATION SIMPLE =================================================== - if (action === "update") { - if (!eventId) { - this.clearMonthSelection(); - return; - } - - this.clearMonthSelection(); - - try { - const response = await fetch( - "/modules/family-calendar/includes/api/manage-event.php", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - action: "update", - event_id: eventId, - new_type: newType, - }), - } - ); - if (!response.ok) { - const errData = await response.json().catch(() => ({})); - throw new Error( - errData.message || "Erreur HTTP " + response.status - ); - } - - const resEvents = await fetch( - "/modules/family-calendar/includes/api/get-events.php" - ); - const dataEvents = await resEvents.json(); - this.dbEvents = dataEvents.events || []; - this.events = [...this.dbEvents, ...this.fixedEvents]; - - this.reprocessAndRender(); - this.renderMonthCalendar(); - } catch (err) { - console.error("Erreur update (month):", err); - alert("Erreur lors de la modification : " + err.message); - } - return; - } - - // === ALEX / LAIA (logique identique à handleMenuClick) =================== - - if (action === "add-leave") { - const leaveDate = date; - const personId = parseInt(button.dataset.personId, 10); - const leaveType = button.dataset.leaveType; // "CP", "JRA", "JA" - - if (!leaveDate || !personId || !leaveType) { - this.clearMonthSelection(); - return; - } - - // Bloquer tous les types de congés (CP, JRA, JA) pour Alex (2) et Laia (3) - if (personId === 2 || personId === 3) { - const available = this.getAvailableAtMonthStart( - personId, - leaveType, - leaveDate - ); - // On pose 1 jour - if (available != null && available < 1) { - const disp = available.toFixed(2); - alert( - `Impossible d'ajouter ${leaveType} pour ${ - personId === 2 ? "Alex" : "Laia" - } : il reste ${disp} jour(s) disponible(s) au début de ce mois.` - ); - this.clearMonthSelection(); - return; - } - } - - this.clearMonthSelection(); - - try { - // 1) supprimer les congés existants pour cette personne ce jour-là - await fetch("/modules/family-calendar/includes/api/manage-leaf.php", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - action: "delete_day_person", - date: leaveDate, - person_id: personId, - }), - }); - - // 2) ajouter le nouveau type - const newLeave = { - date: leaveDate, - person_id: personId, - leave_type: leaveType, - duration: 1.0, - }; - - const responseAdd = await fetch( - "/modules/family-calendar/includes/api/save-leaves.php", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify([newLeave]), - } - ); - if (!responseAdd.ok) { - throw new Error("Erreur HTTP " + responseAdd.status); - } - - this.leaves = await this.fetchLeaves(); - this.reprocessAndRender(); - this.renderMonthCalendar(); - } catch (err) { - console.error("Erreur add-leave (month):", err); - alert("Erreur congé Alex/Laia : " + err.message); - } - - return; - } - - if (action === "delete-leaves-day") { - const leaveDate = date; - if (!leaveDate) { - this.clearMonthSelection(); - return; - } - - try { - const response = await fetch( + } else if (action === "add-leave") { + await this.postApi( "/modules/family-calendar/includes/api/manage-leaf.php", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "delete_day", date: leaveDate }), - } - ); - if (!response.ok) { - const errData = await response.json().catch(() => ({})); - throw new Error( - errData.message || "Erreur HTTP " + response.status - ); - } - - this.leaves = await this.fetchLeaves(); - this.reprocessAndRender(); - this.renderMonthCalendar(); - } catch (err) { - console.error("Erreur delete_leaves_day (month):", err); - alert( - "Erreur lors de la suppression des congés Alex/Laia pour ce jour: " + - err.message - ); - } - - this.clearMonthSelection(); - return; - } - - if (action === "bulk-add-leave") { - if (!this._currentBulkInfo) { - this.clearMonthSelection(); - return; - } - - const selectedDates = this._currentBulkInfo.selectedDates || []; - const personId = parseInt(button.dataset.personId, 10); - const leaveType = button.dataset.leaveType; // "CP", "JRA", "JA" - - if (personId === 2 || personId === 3) { - // Regrouper les dates par mois - const datesByMonth = {}; // ymKey -> dates[] - selectedDates.forEach((d) => { - const dateObj = new Date(d + "T00:00:00"); - const ymKey = `${dateObj.getFullYear()}-${String( - dateObj.getMonth() + 1 - ).padStart(2, "0")}`; - if (!datesByMonth[ymKey]) datesByMonth[ymKey] = []; - datesByMonth[ymKey].push(d); - }); - - // Pour chaque mois, vérifier Av. >= nb jours demandés dans ce mois - for (const ymKey of Object.keys(datesByMonth)) { - const datesInMonth = datesByMonth[ymKey]; - const anyDate = datesInMonth[0]; - const available = this.getAvailableAtMonthStart( - personId, - leaveType, - anyDate - ); - const needed = datesInMonth.length; - - if (available != null && available < needed) { - const disp = available.toFixed(2); - alert( - `Impossible d'ajouter ${leaveType} pour ${ - personId === 2 ? "Alex" : "Laia" - } sur ${needed} jour(s) dans ${ymKey} : il reste ${disp} jour(s) disponible(s).` - ); - this.clearMonthSelection(); - this._currentBulkInfo = null; - return; - } - } - } - - this._currentBulkInfo = null; - - try { - // delete & add (ton code existant) - await fetch("/modules/family-calendar/includes/api/manage-leaf.php", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "bulk_delete_day_person", - dates: selectedDates, - person_id: personId, - }), - }); - - const newLeaves = selectedDates.map((d) => ({ + dates, + person_id: pid, + }, + ); + const payload = dates.map((d) => ({ date: d, - person_id: personId, - leave_type: leaveType, - duration: 1.0, + person_id: pid, + leave_type: type, + duration: 1, })); - - const responseAdd = await fetch( + await this.postApi( "/modules/family-calendar/includes/api/save-leaves.php", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(newLeaves), - } + payload, + ); + } else if (action === "clear-leaves") { + await this.postApi( + "/modules/family-calendar/includes/api/manage-leaf.php", + { action: "bulk_delete_day_person", dates, person_id: 2 }, + ); + await this.postApi( + "/modules/family-calendar/includes/api/manage-leaf.php", + { action: "bulk_delete_day_person", dates, person_id: 3 }, ); - if (!responseAdd.ok) - throw new Error("Erreur HTTP " + responseAdd.status); - - this.leaves = await this.fetchLeaves(); - this.reprocessAndRender(); - this.renderMonthCalendar(); - } catch (err) { - console.error("Erreur bulk-add-leave:", err); - alert("Erreur bulk congés Alex/Laia : " + err.message); } - this.clearMonthSelection(); - return; + await this.refreshAllData(); + } catch (e) { + alert("Erreur action: " + e.message); } } - } - function getWeekOfYear(date) { - const d = new Date( - Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) - ); - d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); - const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); - return Math.ceil(((d - yearStart) / 86400000 + 1) / 7); - } + async fetchApi(url) { + return fetch(url).then((r) => r.json()); + } - function getMonthNameFr(monthIndex) { - return ( - [ - "Janvier", - "Fevrier", - "Mars", - "Avril", - "Mai", - "Juin", - "Juillet", - "Aout", - "Septembre", - "Octobre", - "Novembre", - "Decembre", - ][monthIndex] || "" - ); + async postApi(url, data) { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); + } + + async changeSchoolYear(delta) { + this.currentSchoolYearStart += delta; + this.updateSchoolYearLabel(); + await this.refreshAllData(); + } } new FamilyCalendar(); diff --git a/modules/family-calendar/includes/api/events-debug.log b/modules/family-calendar/includes/api/events-debug.log index 0e6de9e..f845bd8 100644 --- a/modules/family-calendar/includes/api/events-debug.log +++ b/modules/family-calendar/includes/api/events-debug.log @@ -31,3 +31,11 @@ [2026-01-08T09:23:59+01:00] RAW INPUT: [{"date":"2025-12-22","type":"CENTRE","duration":1}] [2026-01-08T09:24:00+01:00] RAW INPUT: [{"date":"2025-12-23","type":"CENTRE","duration":1}] [2026-01-08T09:24:02+01:00] RAW INPUT: [{"date":"2025-12-24","type":"CENTRE","duration":1}] +[2026-01-30T22:49:07+01:00] RAW INPUT: [{"date":"2025-10-24","type":"AVIS","duration":1,"person":null}] +[2026-01-30T22:49:50+01:00] RAW INPUT: [{"date":"2025-10-16","type":"OFF_CAROLE","duration":1,"person":null}] +[2026-01-30T22:50:00+01:00] RAW INPUT: [{"date":"2025-09-11","type":"OFF_CAROLE","duration":1,"person":null}] +[2026-01-30T22:51:16+01:00] RAW INPUT: [{"date":"2025-09-05","type":"AVIS","duration":1,"person":null}] +[2026-01-30T22:53:23+01:00] RAW INPUT: [{"date":"2025-09-03","type":"AVIS","duration":1,"person":null}] +[2026-01-30T23:16:56+01:00] RAW INPUT: [{"date":"2025-09-28","type":"OFF_CAROLE","duration":1,"person":"Carole"},{"date":"2025-09-29","type":"OFF_CAROLE","duration":1,"person":"Carole"},{"date":"2025-09-30","type":"OFF_CAROLE","duration":1,"person":"Carole"},{"date":"2025-10-01","type":"OFF_CAROLE","duration":1,"person":"Carole"},{"date":"2025-10-02","type":"OFF_CAROLE","duration":1,"person":"Carole"}] +[2026-01-30T23:17:02+01:00] RAW INPUT: [{"date":"2025-10-05","type":"EXTRA_OFF_CAROLE","duration":1,"person":"Carole"},{"date":"2025-10-06","type":"EXTRA_OFF_CAROLE","duration":1,"person":"Carole"},{"date":"2025-10-07","type":"EXTRA_OFF_CAROLE","duration":1,"person":"Carole"},{"date":"2025-10-08","type":"EXTRA_OFF_CAROLE","duration":1,"person":"Carole"},{"date":"2025-10-09","type":"EXTRA_OFF_CAROLE","duration":1,"person":"Carole"}] +[2026-01-30T23:17:09+01:00] RAW INPUT: [{"date":"2025-10-05","type":"AVIS","duration":1,"person":"Carole"},{"date":"2025-10-06","type":"AVIS","duration":1,"person":"Carole"},{"date":"2025-10-07","type":"AVIS","duration":1,"person":"Carole"},{"date":"2025-10-08","type":"AVIS","duration":1,"person":"Carole"}] diff --git a/modules/family-calendar/includes/api/manage-event.php b/modules/family-calendar/includes/api/manage-event.php index e422735..cb744c3 100644 --- a/modules/family-calendar/includes/api/manage-event.php +++ b/modules/family-calendar/includes/api/manage-event.php @@ -1,103 +1,94 @@ 'error', 'message' => 'Action manquante.']); exit; } -$action = $input['action']; - try { + // --- SUPPRESSION UNITAIRE --- if ($action === 'delete') { - // Suppression d'un seul événement - if (!isset($input['event_id'])) { + $eventId = (int)($input['event_id'] ?? 0); + if ($eventId <= 0) { http_response_code(400); - echo json_encode(['status' => 'error', 'message' => 'ID manquant pour la suppression.']); + echo json_encode(['status' => 'error', 'message' => 'ID manquant.']); exit; } - - $eventId = (int)$input['event_id']; $stmt = $pdo->prepare("DELETE FROM pf_events WHERE id = ?"); $stmt->execute([$eventId]); + echo json_encode(['status' => 'success']); + exit; + } - echo json_encode(['status' => 'success', 'message' => 'Événement supprimé.']); - - } elseif ($action === 'update') { - // Mise à jour d'un seul événement - if (!isset($input['event_id'], $input['new_type'])) { + // --- 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' => 'ID ou nouveau type manquant pour la mise à jour.']); + echo json_encode(['status' => 'error', 'message' => 'Données manquantes.']); exit; } - - $eventId = (int)$input['event_id']; - $newType = $input['new_type']; - $stmt = $pdo->prepare("UPDATE pf_events SET event_type = ? WHERE id = ?"); $stmt->execute([$newType, $eventId]); - - echo json_encode(['status' => 'success', 'message' => 'Événement mis à jour.']); - - } elseif ($action === 'bulk_delete') { - // Suppression en masse - if (empty($input['event_ids']) || !is_array($input['event_ids'])) { - http_response_code(400); - echo json_encode(['status' => 'error', 'message' => 'Liste event_ids manquante pour bulk_delete.']); - exit; - } - - $eventIds = array_map('intval', $input['event_ids']); - $eventIds = array_filter($eventIds, fn($id) => $id > 0); - - if (empty($eventIds)) { - http_response_code(400); - echo json_encode(['status' => 'error', 'message' => 'Aucun ID valide pour bulk_delete.']); - exit; - } - - $placeholders = implode(',', array_fill(0, count($eventIds), '?')); - $stmt = $pdo->prepare("DELETE FROM pf_events WHERE id IN ($placeholders)"); - $stmt->execute($eventIds); - - echo json_encode(['status' => 'success', 'message' => 'Événements supprimés en masse.']); - - } elseif ($action === 'bulk_update') { - // Mise à jour en masse - if (empty($input['event_ids']) || !is_array($input['event_ids']) || !isset($input['new_type'])) { - http_response_code(400); - echo json_encode(['status' => 'error', 'message' => 'event_ids ou new_type manquant pour bulk_update.']); - exit; - } - - $eventIds = array_map('intval', $input['event_ids']); - $eventIds = array_filter($eventIds, fn($id) => $id > 0); - $newType = $input['new_type']; - - if (empty($eventIds)) { - http_response_code(400); - echo json_encode(['status' => 'error', 'message' => 'Aucun ID valide pour bulk_update.']); - exit; - } - - $placeholders = implode(',', array_fill(0, count($eventIds), '?')); - $params = array_merge([$newType], $eventIds); - - $stmt = $pdo->prepare("UPDATE pf_events SET event_type = ? WHERE id IN ($placeholders)"); - $stmt->execute($params); - - echo json_encode(['status' => 'success', 'message' => 'Événements mis à jour en masse.']); - - } else { - http_response_code(400); - echo json_encode(['status' => 'error', 'message' => 'Action non valide.']); + 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'] ?? []; + + if (empty($dates) || empty($types)) { + echo json_encode(['status' => 'success']); // Rien à faire + 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'] ?? []; + if (empty($dates)) { + 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()]); -} +} \ No newline at end of file diff --git a/modules/gift-list/gift-list.css b/modules/gift-list/gift-list.css index e60df7e..73c0363 100644 --- a/modules/gift-list/gift-list.css +++ b/modules/gift-list/gift-list.css @@ -1,186 +1,308 @@ -/* ========================================================= */ -/* Styles généraux page gift list */ -/* ========================================================= */ +/* modules/gift-list/gift-list.css */ + +/* --- 1. VARIABLES & BASE --- */ +:root { + --primary: #2563eb; + --bg-page: #f8fafc; + --text-main: #1e293b; + --text-muted: #64748b; + --border-light: #e2e8f0; + + --radius-card: 12px; + --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.pf-gift-list { + background-color: var(--bg-page); + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, + sans-serif; + color: var(--text-main); + font-size: 13px; +} .pf-gift-list h1 { - margin-bottom: 4px; + font-size: 1.6rem; + font-weight: 700; + color: #0f172a; + margin: 0; } -.pf-gift-list p { - margin-top: 0; - margin-bottom: 12px; - font-size: 13px; - color: #4b5563; -} - -/* Petit texte explicatif sous les titres */ .cl-legend { - font-size: 12px; - color: #6b7280; - margin-bottom: 8px; + font-size: 0.85rem; + color: var(--text-muted); + font-style: italic; + margin-top: 4px; } -/* ========================================================= */ -/* Vue par fête: mini-tableaux par enfant */ -/* ========================================================= */ +/* --- 2. HEADER & SWITCH --- */ +.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(--border-light); +} +.cl-view-switch { + display: flex; + background: white; + padding: 4px; + border-radius: 8px; + border: 1px solid #cbd5e1; +} + +.cl-view-btn { + padding: 6px 16px; + border-radius: 6px; + text-decoration: none; + font-size: 0.85rem; + font-weight: 600; + color: var(--text-muted); +} + +.cl-view-btn.is-active { + background: #334155; + color: white; +} + +/* --- 3. BLOCS OCCASIONS & GRILLE --- */ .cl-occasion-block { - margin-top: 2rem; - border-top: 2px solid #e5e5e5; + margin-bottom: 40px; } .cl-occasion-title { font-size: 1.2rem; - margin-bottom: 0.5rem; + font-weight: 700; + color: #334155; + margin-bottom: 16px; + display: flex; + align-items: center; + gap: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + border-bottom: 2px solid #e2e8f0; + padding-bottom: 8px; } +.cl-occasion-icon { + width: 24px; + height: 24px; + object-fit: contain; +} + +/* --- GRILLE INTELLIGENTE (Le cœur de la mise en page) --- */ + .cl-occasion-children-tables { display: grid; - grid-template-columns: repeat(3, minmax(280px, 1fr)); - gap: 7px; + gap: 20px; align-items: start; - margin-top: 8px; } -/* Désactiver la grille uniquement pour la vue Anniversary */ -.pf-gift-list .cl-view-anniversary .cl-occasion-children-tables { - display: block; /* remplace la grid */ +/* VUE STANDARD (Nadal) : 3 par ligne */ +.cl-view-nadal .cl-occasion-children-tables { + grid-template-columns: repeat(3, 1fr); } -.pf-gift-list .cl-view-anniversary .cl-child-table { - width: 100%; - min-width: 720px; /* ajuste si nécessaire avec 6 colonnes */ - margin-bottom: 12px; /* espacement entre tables */ - overflow-x: auto; /* sécurité si ça déborde sur petit écran */ +/* VUE ANNIVERSARY : Layout spécifique demandé */ +.cl-view-anniversary .cl-occasion-children-tables { + grid-template-columns: repeat(6, 1fr); /* Grille de 6 colonnes */ } -@media (max-width: 1200px) { - .cl-occasion-children-tables { - grid-template-columns: repeat(2, minmax(280px, 1fr)); - } +/* Pol et Pep : Prennent 3 colonnes sur 6 (donc 50% de largeur => 2 par ligne) */ +.cl-view-anniversary .child-pol, +.cl-view-anniversary .child-pep { + grid-column: span 3; } -@media (max-width: 780px) { - .cl-occasion-children-tables { + +/* Les autres : Prennent 2 colonnes sur 6 (donc 33% de largeur => 3 par ligne) */ +.cl-view-anniversary .child-elna, +.cl-view-anniversary .child-bru, +.cl-view-anniversary .child-guim { + grid-column: span 2; +} + +/* Responsive Mobile (tous sur 1 colonne) */ +@media (max-width: 1000px) { + .cl-view-nadal .cl-occasion-children-tables, + .cl-view-anniversary .cl-occasion-children-tables { grid-template-columns: 1fr; } + .cl-view-anniversary .cl-child-table { + grid-column: span 1; + } } -/* ========================================================= */ -/* Mini-tableau par enfant */ -/* ========================================================= */ +/* --- 4. TABLEAU PAR ENFANT (Card Unifiée) --- */ .cl-child-table { - display: table !important; /* robustesse même avec resets */ - table-layout: fixed; + background: white; + + /* L'ombre est sur le tableau global */ + box-shadow: var(--shadow-card); + + /* Arrondi uniquement en BAS (le caption gère le haut) */ + border-radius: 0 0 var(--radius-card) var(--radius-card); + + /* La bordure du tableau ne fait que gauche/droite/bas */ + border: 1px solid var(--border-light); + border-top: none; + + /* Structure tableau stricte pour garder le caption en haut */ + display: table; width: 100%; - border-collapse: collapse; - background: #fff; - border: 1px solid #d9e2ec; - overflow: hidden; -} -.cl-child-table thead { - display: table-header-group !important; -} -.cl-child-table tbody { - display: table-row-group !important; -} -.cl-child-table tr { - display: table-row !important; -} -.cl-child-table th, -.cl-child-table td { - display: table-cell !important; - box-sizing: border-box; + table-layout: fixed; + border-collapse: separate; + border-spacing: 0; } -/* Nom de l'enfant (caption) + bouton + */ +/* CAPTION (Nom de l'enfant - Haut de la carte) */ .cl-child-table caption { + /* Force l'affichage en haut */ caption-side: top; - position: relative; /* pour positionner le + */ - text-align: center; + display: table-caption; + + padding: 10px 16px; + text-align: left; font-weight: 700; - padding: 8px 10px; - background: #f8fafc; - border-bottom: 1px solid #e5e7eb; - font-size: 13px; - padding-right: 64px; + font-size: 1.1rem; + + /* Arrondi uniquement en HAUT */ + border-radius: var(--radius-card) var(--radius-card) 0 0; + + /* Bordure complète pour le caption */ + border: 1px solid var(--border-light); + border-bottom: 2px solid; /* Le trait de séparation coloré */ + + /* Colle le caption au tableau */ + margin-bottom: 0; + + position: relative; } -/* Bouton + dans le caption */ +/* Bouton Ajout (+) Rotatif */ .cl-child-add-btn { + /* Position absolue par rapport au caption */ position: absolute; - right: 8px; + right: 12px; top: 50%; transform: translateY(-50%); - width: 48px; - height: 48px; + + 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; - color: #243b53; - font-size: 15px; - font-weight: 700; - line-height: 1; - background: transparent; - border: none; - cursor: pointer; - isolation: isolate; + transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); + z-index: 2; } -.cl-child-add-btn::before { - content: ""; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: 24px; - height: 24px; - border-radius: 50%; - border: 1px solid #d9e2ec; - background: #f0f4f8; - pointer-events: none; - z-index: -1; +.cl-child-add-btn:hover { + background: white; + /* La rotation que tu aimais */ + transform: translateY(-50%) rotate(90deg) scale(1.1); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15); + border-color: currentColor; } -/* Hover/focus: même feedback visuel */ -.cl-child-add-btn:hover::before, -.cl-child-add-btn:focus-visible::before { - background: #d9e2ec; +/* --- 5. COULEURS PAR ENFANT (Appliquées au Caption ET au Tableau) --- */ + +/* POL (Bleu) */ +.child-pol { + border-color: #bcd3ff; +} /* Bordure du tableau (bas/cotés) */ +.child-pol caption { + background: #eaf2ff; + color: #1e3a8a; + border-color: #bcd3ff; /* Bordure du caption (haut/cotés) */ + border-bottom-color: #93c5fd; /* Trait de séparation un peu plus foncé */ } -/* Accessibilité focus clavier sur le hit-area */ -.cl-child-add-btn:focus-visible { - outline: 2px solid #2563eb; - outline-offset: 2px; - border-radius: 8px; /* outline carré autour du hit-area */ +.child-pol thead th { + background: #f5f9ff; } -@media (pointer: coarse) { - .cl-child-add-btn { - width: 56px; - height: 56px; - font-size: 22px; - } - .cl-child-table caption { - padding-right: 72px; - } +/* PEP (Vert) */ +.child-pep { + border-color: #b9e3b9; +} +.child-pep caption { + background: #eaf7ea; + color: #14532d; + border-color: #b9e3b9; + border-bottom-color: #86efac; +} +.child-pep thead th { + background: #f6fdf6; } -/* Colonnes égales (3 adultes) via colgroup */ -.cl-child-table col.cl-col { - width: 33.3333%; +/* ELNA (Rose) */ +.child-elna { + border-color: #f3bfd7; +} +.child-elna caption { + background: #fdeaf3; + color: #831843; + border-color: #f3bfd7; + border-bottom-color: #f9a8d4; +} +.child-elna thead th { + background: #fff5f9; } -/* En-têtes adultes: séparateurs + total à droite */ +/* BRU (Orange) */ +.child-bru { + border-color: #ffd0a8; +} +.child-bru caption { + background: #fff3e6; + color: #7c2d12; + border-color: #ffd0a8; + border-bottom-color: #fdba74; +} +.child-bru thead th { + background: #fffaf5; +} + +/* GUIM (Violet) */ +.child-guim { + border-color: #d3c6ff; +} +.child-guim caption { + background: #f0eaff; + color: #4c1d95; + border-color: #d3c6ff; + border-bottom-color: #c4b5fd; +} +.child-guim thead th { + background: #fbf9ff; +} + +/* --- 6. CONTENU DU TABLEAU --- */ + +/* En-têtes Adultes */ .cl-child-table thead th { - background: #f3f4f6; - font-weight: 600; - padding: 6px 8px; - border-bottom: 1px solid #e5e7eb; - font-size: 12px; - text-align: left; - vertical-align: middle; - white-space: nowrap; - border-right: 1px solid #e5e7eb; + padding: 8px; + font-size: 0.75rem; + color: var(--text-muted); + font-weight: 700; + text-transform: uppercase; + border-bottom: 1px solid var(--border-light); + border-right: 1px solid var(--border-light); + + /* Pas de largeur fixe, table-layout: fixed gère le partage équitable */ + white-space: normal; + overflow: hidden; + text-overflow: ellipsis; } .cl-child-table thead th:last-child { border-right: none; @@ -188,482 +310,338 @@ .cl-th-inner { display: flex; - justify-content: space-between; - align-items: center; + flex-direction: column; + align-items: flex-start; + gap: 2px; } -.cl-th-label { - overflow: hidden; - text-overflow: ellipsis; -} - -/* Total adulte (réutilise le style global) */ .cl-summary-adult-total { - font-size: 12px; - font-weight: 600; - color: #1d4ed8; + font-size: 0.7rem; + color: var(--primary); + background: rgba(37, 99, 235, 0.1); + padding: 1px 4px; + border-radius: 4px; } -/* Corps: séparateurs + gestion des noms longs */ +/* Corps du tableau */ .cl-child-table tbody td { + padding: 8px; vertical-align: top; - padding: 6px 4px; - border-top: 1px solid #edf2f7; - border-right: 1px solid #e5e7eb; - font-size: 12px; - line-height: 1.35; + border-bottom: 1px solid #f1f5f9; + border-right: 1px solid #f1f5f9; + font-size: 0.85rem; } .cl-child-table tbody td:last-child { border-right: none; } +.cl-child-table tr:last-child td { + border-bottom: none; +} -/* Cellules vides: un “—” par adulte, centré, pleine largeur */ -.cl-child-table .cl-empty { - display: inline-block; - width: 100%; +/* Cellule vide */ +.cl-empty { + color: #e2e8f0; text-align: center; - color: #9ca3af; + display: block; + font-size: 1.2rem; + line-height: 1; } +/* --- 7. CADEAUX & INTERACTION (Overlay) --- */ +.cl-gift-item { + display: flex; + flex-direction: column; + gap: 2px; +} + +.cl-gift-line { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 4px; + position: relative; /* Pour l'overlay */ + min-height: 20px; /* Hauteur min pour éviter le saut si vide */ +} + +/* Texte du cadeau */ +.cl-gift-desc, +.cl-gift-link { + font-weight: 500; + color: #0f172a; + line-height: 1.2; + font-size: 0.85rem; + + /* Césure forcée pour éviter le scroll */ + word-wrap: break-word; + word-break: break-word; + hyphens: auto; + + flex: 1; /* Prend toute la place dispo à gauche */ +} + +.cl-gift-link { + color: var(--primary); + text-decoration: none; +} +.cl-gift-link:hover { + text-decoration: underline; +} + +/* Zone Droite (Prix OU Actions) */ +.cl-gift-right { + display: flex; + align-items: center; + justify-content: flex-end; + /* Largeur fixe pour que le switch ne décale pas le texte de gauche */ + width: 50px; + flex-shrink: 0; +} + +/* Le Prix (Visible par défaut) */ .cl-gift-amount { - margin-left: auto; - text-align: right; - color: #627d98; + font-size: 0.75rem; + font-weight: 700; + color: #059669; + background: #ecfdf5; + padding: 1px 4px; + border-radius: 4px; white-space: nowrap; - min-width: fit-content; - transition: opacity 120ms ease; + + transition: + opacity 0.2s, + transform 0.2s; + opacity: 1; + transform: scale(1); } -/* Code couleur par enfant (bordure + header/caption) */ -.cl-child-table.child-pol { - border-color: #bcd3ff; -} -.cl-child-table.child-pol caption, -.cl-child-table.child-pol thead th { - background: #eaf2ff; - border-bottom-color: #bcd3ff; +/* Les Actions (Masquées par défaut, Absolute pour superposition) */ +.cl-gift-actions { + display: flex; + gap: 4px; + position: absolute; + right: 0; + top: 0; + + opacity: 0; + transform: scale(0.8); + pointer-events: none; /* Empêche le clic quand invisible */ + transition: + opacity 0.2s, + transform 0.2s; } -.cl-child-table.child-pep { - border-color: #b9e3b9; +/* === L'EFFET SWAP === */ +/* Au survol de la ligne cadeau... */ +.cl-gift-line:hover .cl-gift-amount { + opacity: 0; + transform: scale(0.8); } -.cl-child-table.child-pep caption, -.cl-child-table.child-pep thead th { - background: #eaf7ea; - border-bottom-color: #b9e3b9; +.cl-gift-line:hover .cl-gift-actions { + opacity: 1; + transform: scale(1); + pointer-events: auto; } -.cl-child-table.child-elna { - border-color: #f3bfd7; +/* Boutons d'action */ +.cl-gift-action-btn { + background: #f1f5f9; + border: 1px solid #cbd5e1; + width: 22px; + height: 22px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: #475569; + font-size: 14px; + padding: 0; } -.cl-child-table.child-elna caption, -.cl-child-table.child-elna thead th { - background: #fdeaf3; - border-bottom-color: #f3bfd7; +.cl-gift-action-btn:hover { + background: white; + border-color: var(--text-main); +} +.cl-gift-delete:hover { + color: #dc2626; + border-color: #dc2626; + background: #fef2f2; } -.cl-child-table.child-bru { - border-color: #ffd0a8; +/* --- 8. BUDGET & TRICOUNT --- */ +.pf-section--panel { + margin-bottom: 32px; } -.cl-child-table.child-bru caption, -.cl-child-table.child-bru thead th { - background: #fff3e6; - border-bottom-color: #ffd0a8; +.pf-section--panel h2 { + font-size: 1.3rem; + margin-bottom: 16px; + border-left: 5px solid var(--primary); + padding-left: 12px; + color: var(--text-main); + background: white; + padding: 12px; + border-radius: 0 8px 8px 0; } -.cl-child-table.child-guim { - border-color: #d3c6ff; -} -.cl-child-table.child-guim caption, -.cl-child-table.child-guim thead th { - background: #f0eaff; - border-bottom-color: #d3c6ff; +.cl-budget-wrapper { + background: white; + border-radius: var(--radius-card); + box-shadow: var(--shadow-card); + border: 1px solid var(--border-light); + overflow-x: auto; } -/* ========================================================= */ -/* Modale d’ajout de cadeau */ -/* ========================================================= */ +.pf-table { + width: 100%; + border-collapse: collapse; +} +.pf-table th, +.pf-table td { + padding: 10px 14px; + border-bottom: 1px solid var(--border-light); + font-size: 0.9rem; +} +.pf-table th { + background: #f8fafc; + font-weight: 600; + text-align: left; + color: var(--text-muted); +} +.cl-debt-matrix td { + text-align: right; +} + +.cl-mtx-owe { + color: #dc2626; + background: #fef2f2; + font-weight: 700; +} + +/* --- 9. MODALES --- */ .cl-modal { display: none; } .cl-modal.cl-open { - display: block; + display: flex; + align-items: center; + justify-content: center; + position: fixed; + inset: 0; + z-index: 9999; } .cl-modal-backdrop { - position: fixed; + position: absolute; inset: 0; - background: rgba(17, 24, 39, 0.35); - z-index: 999; + background: rgba(15, 23, 42, 0.5); + backdrop-filter: blur(2px); } .cl-modal-dialog { - position: fixed; - z-index: 1000; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - width: min(520px, calc(100% - 24px)); - background: #fff; - border: 1px solid #d9e2ec; - border-radius: 8px; - box-shadow: 0 10px 30px rgba(17, 24, 39, 0.25); + position: relative; + background: white; + width: min(450px, 90vw); + border-radius: 12px; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); + z-index: 10; + padding: 0; + animation: modalPop 0.2s ease-out; +} + +@keyframes modalPop { + from { + transform: scale(0.95); + opacity: 0; + } + to { + transform: scale(1); + opacity: 1; + } } .cl-modal-form { - display: grid; - gap: 10px; - padding: 14px; + padding: 24px; + display: flex; + flex-direction: column; + gap: 12px; } .cl-modal-form h3 { - margin: 0 0 4px; - font-size: 16px; - color: #243b53; + margin: 0 0 12px; + font-size: 1.2rem; + color: var(--text-main); } .clm-label { - display: grid; - gap: 6px; - font-size: 12px; - color: #374151; + display: flex; + flex-direction: column; + gap: 4px; + font-weight: 600; + font-size: 0.85rem; + color: #475569; } .clm-label input, .clm-label select { - font-size: 12px; - padding: 6px 8px; - border: 1px solid #d9e2ec; + padding: 8px; + border: 1px solid #cbd5e1; border-radius: 6px; + font-size: 0.95rem; + background: #f8fafc; +} +.clm-label input:focus, +.clm-label select:focus { + background: white; + border-color: var(--primary); + outline: 2px solid rgba(37, 99, 235, 0.2); } .cl-modal-actions { display: flex; justify-content: flex-end; - gap: 8px; - margin-top: 4px; + gap: 10px; + margin-top: 10px; } -.clm-cancel, -.clm-ok { - font-size: 12px; - padding: 6px 12px; - border: 1px solid #d9e2ec; - border-radius: 6px; - background: #f0f4f8; - cursor: pointer; -} -.clm-ok { - font-weight: 600; - color: #1f2933; -} .clm-cancel { - color: #d03050; -} -.clm-cancel:hover, -.clm-ok:hover { - background: #d9e2ec; -} - -/* Prix: sans spinners, largeur fixe */ -#clm-amount { - width: 150px; - font-size: 11px; - padding: 2px 3px; - -moz-appearance: textfield; /* Firefox legacy */ - appearance: textfield; /* Standard property (modern browsers) */ -} -/* Chrome/Safari */ -#clm-amount::-webkit-outer-spin-button, -#clm-amount::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; -} - -/* Icônes d’action (éditer / supprimer) à côté du nom du cadeau */ -/* Conteneur de la ligne cadeau: réserve un espace à gauche pour les icônes overlay */ -.cl-gift-item { - position: relative; - padding-left: 2px; -} -/* Icônes en overlay coin haut-gauche */ -.cl-gift-actions--overlay { - position: absolute; - top: 2px; - left: 2px; - display: flex; - gap: 4px; - opacity: 0.65; /* discrètes */ - transition: opacity 120ms ease; -} -.cl-gift-item:hover .cl-gift-actions--overlay { - opacity: 1; /* plus visibles au survol */ -} - -.cl-gift-action-btn { - border: none; - background: transparent; - padding: 0; + background: white; + border: 1px solid #cbd5e1; + padding: 8px 16px; + border-radius: 6px; cursor: pointer; - color: #374151; - line-height: 1; + color: var(--text-muted); } -.cl-gift-action-btn svg { - width: 12px; - height: 12px; - display: block; -} -.cl-gift-delete { - color: #b91c1c; -} /* poubelle rouge */ - -/* ========================================================= */ -/* Résumé du budget et Liste détaillée */ -/* ========================================================= */ - -.cl-budget-wrapper { - margin-top: 4px; -} -.cl-budget-wrapper .pf-table { - font-size: 11px; -} -.cl-budget-wrapper .pf-table th, -.cl-budget-wrapper .pf-table td { - padding: 4px 4px; -} - -.cl-detail-wrapper .pf-table { - font-size: 11px; -} -.cl-detail-wrapper .pf-table th, -.cl-detail-wrapper .pf-table td { - padding: 4px 4px; -} - -/* Icône devant le titre de fête */ -.cl-occasion-title { - display: flex; - align-items: center; - gap: 8px; -} -.cl-occasion-icon { - width: 20px; - height: 20px; - object-fit: contain; - flex: 0 0 20px; - filter: none; /* garde les couleurs des SVG/PNG */ -} -/* clarté de lecture */ -.cl-debt-matrix .cl-matrix-corner { - font-size: 11px; - color: #6b7280; - text-align: left; - line-height: 1.2; - min-width: 92px; -} - -.cl-debt-matrix td.cl-mtx-diag { - background-image: repeating-linear-gradient( - 45deg, - #111827 0, - #111827 6px, - #1f2937 6px, - #1f2937 12px - ); - color: transparent; /* masque le “—” pour un rendu propre */ -} - -.cl-debt-matrix td.cl-mtx-owe { - background: #fff7ed; /* léger orange */ - color: #7c2d12; /* brun/orange foncé */ +.clm-ok { + background: var(--primary); + color: white; + border: none; + padding: 8px 20px; + border-radius: 6px; + cursor: pointer; font-weight: 600; } -.cl-debt-matrix td.cl-mtx-empty { - color: #9ca3af; -} - -/* Ligne cadeau: gauche (nom) / droite (prix ou icônes) */ -.cl-gift-line { - display: flex; - align-items: baseline; - width: 100%; -} -.cl-gift-desc, -.cl-gift-link { - flex: 1 1 auto; - min-width: 40px; -} - -/* Zone droite: prix visible par défaut; icônes masquées */ -.cl-gift-right { - position: relative; - min-width: 48px; /* réserve un petit espace */ - display: flex; - justify-content: flex-end; -} - -/* Prix */ -.cl-gift-amount { - margin-left: auto; - text-align: right; - color: #627d98; - white-space: nowrap; - transition: opacity 120ms ease; -} - -/* Icônes (même zone, positionnées à droite) */ -.cl-gift-actions { - position: absolute; - right: 0; - top: 50%; - transform: translateY(-50%); - display: flex; - gap: 6px; - opacity: 0; - pointer-events: none; - transition: opacity 120ms ease; -} - -/* Survol (desktop) ou état actif (mobile): cacher prix, montrer icônes */ -.cl-gift-right:hover .cl-gift-amount, -.cl-gift-right.is-active .cl-gift-amount, -.cl-gift-right:focus-within .cl-gift-amount { - opacity: 0; -} -.cl-gift-right:hover .cl-gift-actions, -.cl-gift-right.is-active .cl-gift-actions, -.cl-gift-right:focus-within .cl-gift-actions { - opacity: 1; - pointer-events: auto; -} - -/* Boutons icônes compacts et couleurs */ -.cl-gift-action-btn { - border: none; - background: transparent; - padding: 0; - cursor: pointer; - color: #374151; - line-height: 1; -} -.cl-gift-action-btn svg { - width: 14px; - height: 14px; - display: block; - color: inherit; - fill: currentColor; -} -.cl-gift-delete { - color: #b91c1c !important; -} -.cl-gift-action-btn:focus-visible { - outline: 2px solid #2563eb; - outline-offset: 2px; -} - -.cl-titlebar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - flex-wrap: wrap; -} -.cl-view-switch { - display: flex; - gap: 8px; -} -.cl-view-btn { - padding: 6px 10px; - border: 1px solid #ddd; - border-radius: 6px; - text-decoration: none; - color: #333; - background: #f7f7f7; -} -.cl-view-btn.is-active { - background: #333; - color: #fff; - border-color: #333; -} - -/* Centrage fiable du + dans le cercle (micro-nudge du cercle) */ -.cl-child-add-btn { - display: flex; - align-items: center; - justify-content: center; - line-height: 1; -} - -/* On décale très légèrement le cercle vers le bas pour compenser l’optique du glyphe + */ -.cl-child-add-btn::before { - transform: translate(-50%, -50%) translateY(0.5px); -} - -/* Si nécessaire sur certains mobiles, augmente le nudge */ -@media (pointer: coarse) { - .cl-child-add-btn::before { - transform: translate(-50%, -50%) translateY(1px); +/* --- 10. MOBILE --- */ +@media (max-width: 768px) { + .cl-view-nadal .cl-occasion-children-tables, + .cl-view-anniversary .cl-occasion-children-tables { + grid-template-columns: 1fr; } -} -/* === Gift List: Mobile Comfort v2 === */ -@media (pointer: coarse), (max-width: 780px) { - .pf-gift-list h1 { - font-size: clamp(24px, 7vw, 30px); - margin: 10px 0 6px; - line-height: 1.2; - } - .pf-gift-list h2 { - font-size: clamp(20px, 5.6vw, 24px); - line-height: 1.25; - margin: 10px 0 6px; - } - .pf-gift-list h3 { - font-size: clamp(17px, 4.8vw, 20px); - line-height: 1.3; - margin: 8px 0 4px; + .cl-view-anniversary .child-pol, + .cl-view-anniversary .child-pep, + .cl-view-anniversary .child-elna, + .cl-view-anniversary .child-bru, + .cl-view-anniversary .child-guim { + grid-column: span 1; } - /* Switch de vue plus gros et confortable */ - .cl-view-btn { - font-size: 18px; - padding: 12px 14px; - min-height: 48px; - border-radius: 10px; + /* Force le scroll horizontal sur mobile uniquement */ + .cl-child-table { + display: block; + overflow-x: auto; } - - /* Grille/listes: un peu plus d’air */ - .cl-occasion-children-tables { - gap: 12px; - } - - /* Tableaux enfant: en-têtes et cellules plus lisibles */ - .cl-child-table thead th { - padding: 10px 10px; - font-size: 13.5px; - } - .cl-child-table tbody td { - padding: 10px 8px; - font-size: 13.5px; - } - .cl-summary-adult-total { - font-size: 13px; - } - - /* Bouton + : nous avons déjà agrandi le hit-area; on garde les réglages précédents */ - .cl-child-table caption { - padding-right: 72px; - } - - /* Tables Budget/Tricount/Detail: un cran au‑dessus aussi */ - .cl-budget-wrapper .pf-table, - .cl-detail-wrapper .pf-table { - font-size: 13.5px; - } - .cl-budget-wrapper .pf-table th, - .cl-budget-wrapper .pf-table td, - .cl-detail-wrapper .pf-table th, - .cl-detail-wrapper .pf-table td { - padding: 8px 8px; + /* On redonne de la largeur aux colonnes sur mobile */ + .cl-child-table th, + .cl-child-table td { + min-width: 120px; } } diff --git a/modules/gift-list/save-gift.php b/modules/gift-list/save-gift.php index 93f8f86..d2289be 100644 --- a/modules/gift-list/save-gift.php +++ b/modules/gift-list/save-gift.php @@ -1,73 +1,95 @@ 0) { - $stmt = $pdo->prepare("DELETE FROM pf_gift_gifts WHERE id = :id"); + $stmt = $pdo->prepare("DELETE FROM {$tableName} WHERE id = :id"); $stmt->execute(['id' => $gift_id]); } - - header('Location: /gift-list.php'); + header("Location: $redirectUrl"); exit; } // Champs communs - $year = (int)($_POST['year'] ?? date('Y')); - $adult_name = trim($_POST['adult_name'] ?? ''); - $payer_name = trim($_POST['payer_name'] ?? ''); // nouveau champ - $child_name = trim($_POST['child_name'] ?? ''); - $occasion = trim($_POST['occasion'] ?? ''); - $gift_desc = trim($_POST['gift_description'] ?? ''); - $product_link = trim($_POST['product_link'] ?? ''); - $amount = $_POST['amount'] !== '' ? (float)$_POST['amount'] : 0.0; + $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; } - if ($action === 'update') { - $gift_id = (int)($_POST['gift_id'] ?? 0); - if ($gift_id > 0 && $adult_name && $payer_name && $child_name && $occasion && $gift_desc) { - $stmt = $pdo->prepare(" - UPDATE pf_gift_gifts - 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' => $product_link ?: null, - 'amount' => $amount, - ]); - } - - header('Location: /gift-list.php'); + // 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; } - // create (par défaut) - if ($adult_name && $payer_name && $child_name && $occasion && $gift_desc) { + // --- UPDATE --- + if ($action === 'update' && $gift_id > 0) { $stmt = $pdo->prepare(" - INSERT INTO pf_gift_gifts + 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) @@ -79,11 +101,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { 'child_name' => $child_name, 'occasion' => $occasion, 'gift_description' => $gift_desc, - 'product_link' => $product_link ?: null, + 'product_link' => $prod_link ?: null, 'amount' => $amount, ]); } - header('Location: /gift-list.php'); - exit; +} catch (PDOException $e) { + // Log l'erreur si besoin } + +header("Location: $redirectUrl"); +exit; \ No newline at end of file diff --git a/modules/holidays/geocode.php b/modules/holidays/geocode.php index c2843fb..493d7b4 100644 --- a/modules/holidays/geocode.php +++ b/modules/holidays/geocode.php @@ -6,100 +6,142 @@ require __DIR__ . '/../../includes/db.php'; header('Content-Type: application/json; charset=utf-8'); +// 1. Validation de l'entrée $q = trim($_GET['q'] ?? ''); if ($q === '') { - http_response_code(400); - echo json_encode(['error' => 'missing q']); - exit; + http_response_code(400); + echo json_encode(['error' => 'missing_q']); + exit; } -// borne la limite entre 1 et 5 (usage perso) +// Borner la limite $limit = (int)($_GET['limit'] ?? 1); if ($limit < 1) $limit = 1; -if ($limit > 5) $limit = 5; +if ($limit > 10) $limit = 10; // Nominatim bloque souvent au-dessus de 10-50 -// Cache local (silencieux si table absente) +// 2. Normalisation pour le cache $qNorm = mb_strtolower($q); $qHash = hash('sha256', $qNorm); + +// 3. Création automatique de la table cache si elle n'existe pas (Sécurité) +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) { + // On continue même si ça échoue (l'admin devra créer la table manuellement) +} + +// 4. Vérification du cache (Même si limit > 1) +// Stratégie : Si on a déjà cherché exactement "Paris", on renvoie le résultat stocké +// pour économiser l'API et aller plus vite. Le JS gérera le résultat unique. try { - if ($limit === 1) { $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)) { - echo json_encode([ - 'lat' => (float)$row['lat'], - 'lng' => (float)$row['lng'], - 'display_name' => $row['display_name'], - 'cached' => true - ]); - exit; + echo json_encode([ + '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) { - // pas bloquant + // Erreur SQL silencieuse sur le cache } -// Appel Nominatim (respect des règles d'usage) +// 5. Appel Nominatim (Si pas en cache) $endpoint = 'https://nominatim.openstreetmap.org/search'; $params = http_build_query([ - 'format' => 'jsonv2', - 'addressdetails' => 1, - 'limit' => $limit, - 'q' => $q, + 'format' => 'jsonv2', + 'addressdetails' => 1, + 'limit' => $limit, + 'q' => $q, ], '', '&', PHP_QUERY_RFC3986); $url = $endpoint . '?' . $params; $ch = curl_init($url); curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_CONNECTTIMEOUT => 5, - CURLOPT_TIMEOUT => 10, - CURLOPT_HTTPHEADER => [ - // Contact fourni: ferlan.alexandre@gmail.com - 'User-Agent: PachaFamily-Holidays/1.0 (+contact: ferlan.alexandre@gmail.com)' - ], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 10, + CURLOPT_HTTPHEADER => [ + // Ton User-Agent est correct. + // Important : Nominatim demande une identification claire. + 'User-Agent: PachaFamily-Holidays/1.0 (+contact: ferlan.alexandre@gmail.com)' + ], ]); $body = curl_exec($ch); $http = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); +// Gestion erreur cURL / HTTP if ($body === false || $http !== 200) { - http_response_code(502); - echo json_encode(['error' => 'geocode_failed', 'details' => $err ?: ('HTTP '.$http)]); - exit; + http_response_code(502); // Bad Gateway + echo json_encode([ + 'error' => 'geocode_failed', + 'details' => $err ?: ('HTTP ' . $http) + ]); + exit; } $data = json_decode($body, true); + +// Gestion erreur JSON ou vide if (!is_array($data) || empty($data)) { - http_response_code(404); - echo json_encode(['error' => 'not_found']); - exit; + // On renvoie 200 avec une liste vide ou 404, le JS gère les deux. + // 404 est plus sémantique "Not Found". + http_response_code(404); + echo json_encode(['error' => 'not_found']); + exit; } +// 6. Mise en cache du PREMIER résultat (Le "meilleur") +// On ne cache que le top result pour simplifier la structure de la DB. +if (isset($data[0])) { + $r = $data[0]; + $lat = round((float)$r['lat'], 6); + $lng = round((float)$r['lon'], 6); + $display = $r['display_name'] ?? ''; + + 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, $lat, $lng, $display]); + } catch (Throwable $e) {} +} + +// 7. Retour des résultats +// Si limit=1, on renvoie format plat (pour compatibilité stricte) +// Si limit>1, on renvoie format liste if ($limit === 1) { - $r = $data[0]; - $lat = round((float)$r['lat'], 6); - $lng = round((float)$r['lon'], 6); - $display = $r['display_name'] ?? null; + $r = $data[0]; + echo json_encode([ + 'lat' => round((float)$r['lat'], 6), + 'lng' => round((float)$r['lon'], 6), + 'display_name' => $r['display_name'] ?? '' + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +} else { + $results = array_map(function ($r) { + return [ + 'lat' => round((float)$r['lat'], 6), + 'lng' => round((float)$r['lon'], 6), + 'display_name' => (string)($r['display_name'] ?? ''), + ]; + }, $data); - try { - $st = $pdo->prepare("REPLACE INTO pf_geocode_cache (q_hash, q, lat, lng, display_name) VALUES (?, ?, ?, ?, ?)"); - $st->execute([$qHash, $q, $lat, $lng, $display]); - } catch (Throwable $e) {} - - echo json_encode(['lat' => $lat, 'lng' => $lng, 'display_name' => $display]); - exit; -} - -// Multi-résultats -$results = array_map(function ($r) { - return [ - 'lat' => round((float)$r['lat'], 6), - 'lng' => round((float)$r['lon'], 6), - 'display_name' => (string)($r['display_name'] ?? ''), - ]; -}, $data); - -echo json_encode(['results' => $results], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + echo json_encode(['results' => $results], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +} \ No newline at end of file diff --git a/modules/holidays/holidays.css b/modules/holidays/holidays.css index fb533f9..705bc6f 100644 --- a/modules/holidays/holidays.css +++ b/modules/holidays/holidays.css @@ -1,604 +1,570 @@ -/* === Holidays === */ +/* modules/holidays/holidays.css */ -/* Titres et paragraphes (alignés sur pf-gift-list) */ +/* --- 1. VARIABLES & BASE --- */ +:root { + --primary: #2563eb; /* Bleu vibrant */ + --primary-hover: #1d4ed8; + --bg-page: #f1f5f9; /* Gris-bleu très pâle */ + --bg-card: #ffffff; + --text-main: #1e293b; /* Gris très foncé (pas noir) */ + --text-muted: #64748b; + --radius-l: 16px; + --radius-m: 10px; + --radius-s: 6px; + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --shadow-lg: + 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --shadow-hover: + 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); +} + +.pf-holidays { + background-color: var(--bg-page); + font-family: + "Segoe UI", + system-ui, + -apple-system, + sans-serif; + color: var(--text-main); +} + +/* Titres */ .pf-holidays h1 { - margin-bottom: 4px; -} -.pf-holidays p { - margin-top: 0; - margin-bottom: 12px; - font-size: 13px; - color: #4b5563; -} -/* Petit texte explicatif (même classe que gift-list pour cohérence) */ -.cl-legend { - font-size: 12px; - color: #6b7280; - margin-bottom: 8px; + font-size: 1.8rem; + font-weight: 800; + letter-spacing: -0.025em; + color: #0f172a; + margin-bottom: 0.5rem; } -/* Titlebar (cohérent avec gift-list .cl-titlebar) */ +.pf-holidays p { + font-size: 0.95rem; + color: var(--text-muted); + line-height: 1.5; +} + +/* --- 2. EN-TÊTE (TITLEBAR) --- */ .pf-holidays__titlebar { + background: transparent; + padding-bottom: 1rem; + margin-bottom: 2rem; + border-bottom: 1px solid #e2e8f0; display: flex; + flex-wrap: wrap; align-items: center; justify-content: space-between; - gap: 12px; - flex-wrap: wrap; + gap: 1rem; } + .hol-title-actions { display: flex; - gap: 8px; + gap: 12px; } -/* Boutons (proches de .cl-view-btn) */ +/* --- 3. BOUTONS MODERNES --- */ .btn, .hol-add-btn, .hol-map-toggle { - padding: 8px 12px; - border: 1px solid #d9e2ec; - border-radius: 8px; - background: #f0f4f8; - color: #243b53; - text-decoration: none; + border: none; + border-radius: 50px; /* Pill shape */ + padding: 10px 20px; + font-size: 0.9rem; + font-weight: 600; cursor: pointer; - font-size: 13px; + transition: all 0.2s ease; + display: inline-flex; + align-items: center; + justify-content: center; + text-decoration: none; + box-shadow: var(--shadow-sm); +} + +/* Bouton principal (Ajouter) */ +.hol-add-btn { + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); + color: white; +} +.hol-add-btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3); +} + +/* Bouton Carte */ +.hol-map-toggle { + background: white; + color: #0f766e; + border: 1px solid #ccfbf1; } -.btn:hover, -.hol-add-btn:hover, .hol-map-toggle:hover { - background: #e5eef5; + background: #f0fdfa; + border-color: #99f6e4; + color: #0d9488; +} + +/* Boutons classiques */ +.btn { + background: white; + color: var(--text-main); + border: 1px solid #cbd5e1; +} +.btn:hover { + background: #f8fafc; + border-color: #94a3b8; +} + +/* Actions contextuelles (edit/delete) dans les cards */ +.btn-edit, +.btn-delete { + border-radius: var(--radius-s); + padding: 6px 12px; + font-size: 0.8rem; + box-shadow: none; } .btn-edit { - background: #e0f2fe; - border-color: #93c5fd; - color: #0f4c81; -} -.btn-delete { - background: #fee2e2; - border-color: #fca5a5; - color: #7c2d12; -} - -/* Cards */ -.hol-idea-card { - background: #fff; - border: 1px solid #d9e2ec; - border-radius: 8px; - padding: 12px; -} -.hol-idea-card__head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} -.hol-card-actions { - display: flex; - gap: 6px; - margin-top: 8px; -} - -/* Statuts (match gift-list badges ton léger) */ -.hol-status { - font-size: 11px; - padding: 3px 6px; - border-radius: 12px; - background: #e5e7eb; -} -.hol-status--favorite { - background: #fde68a; -} -.hol-status--shortlist { - background: #bfdbfe; -} -.hol-status--planned { - background: #bbf7d0; -} - -/* === Modales (alignement visuel avec cl-modal de gift-list) === */ -.hol-modal { - display: none; -} -.hol-modal.open { - display: block; -} - -.hol-backdrop { - position: fixed; - inset: 0; - background: rgba(17, 24, 39, 0.35); /* même teinte que gift-list */ - z-index: 999; -} - -.hol-dialog { - position: fixed; - z-index: 1000; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - width: min( - 560px, - calc(100% - 24px) - ); /* légèrement plus large que gift-list */ - background: #fff; - border: 1px solid #d9e2ec; - border-radius: 8px; - box-shadow: 0 10px 30px rgba(17, 24, 39, 0.25); /* même shadow */ -} - -/* Modale carte (grande largeur) */ -.hol-dialog--map { - width: min(1200px, calc(100% - 24px)); -} - -.hol-map-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 12px; - border-bottom: 1px solid #e5e7eb; -} - -/* Formulaire modale (alignement gift-list) */ -.hol-form { - display: grid; - gap: 10px; - padding: 14px; -} -.hol-form h3 { - margin: 0 0 4px; - font-size: 16px; - color: #243b53; -} - -.hol-inline { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.hol-form label { - display: grid; - gap: 6px; - font-size: 12px; - color: #374151; -} - -.hol-form input, -.hol-form select, -.hol-form textarea { - font-size: 12px; - padding: 6px 8px; - border: 1px solid #d9e2ec; - border-radius: 6px; - background: #ffffff; - color: #111827; -} - -/* Actions modale */ -.hol-actions { - display: flex; - justify-content: flex-end; - gap: 8px; - margin-top: 4px; -} -.hol-cancel, -.hol-ok { - font-size: 12px; - padding: 6px 12px; - border: 1px solid #d9e2ec; - border-radius: 6px; - background: #f0f4f8; - cursor: pointer; -} -.hol-ok { - font-weight: 600; - color: #1f2933; -} -.hol-cancel { - color: #d03050; -} -.hol-cancel:hover, -.hol-ok:hover { - background: #d9e2ec; -} - -/* Focus accessible */ -.hol-form input:focus-visible, -.hol-form select:focus-visible, -.hol-form textarea:focus-visible, -.hol-cancel:focus-visible, -.hol-ok:focus-visible { - outline: 2px solid #2563eb; - outline-offset: 2px; -} - -/* === Picker multi-résultats Géocode (harmonisé) === */ -.hol-geocode-picker { - margin-top: 8px; - background: #fff; - border: 1px solid #d9e2ec; - border-radius: 8px; - box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08); - overflow: hidden; -} -.hol-geocode-picker__header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 10px; - background: #f8fafc; - border-bottom: 1px solid #e5e7eb; - font-size: 13px; - font-weight: 600; - color: #243b53; -} -.hol-geocode-picker__close { + background: #f1f5f9; + color: #475569; border: none; - background: transparent; - cursor: pointer; - font-size: 18px; - line-height: 1; - padding: 2px 6px; } -.hol-geocode-picker__list { - list-style: none; - margin: 0; - padding: 6px; - max-height: 260px; - overflow: auto; -} -.hol-geocode-picker__item { - display: grid; - grid-template-columns: 1fr auto auto; - align-items: center; - gap: 8px; - padding: 6px; - border-radius: 6px; -} -.hol-geocode-picker__item + .hol-geocode-picker__item { - margin-top: 4px; -} -.hol-geocode-picker__label { - font-size: 12px; - color: #111827; -} -.hol-geocode-picker__coords { - font-size: 11px; - color: #6b7280; -} -.hol-geocode-picker__pick { - padding: 6px 10px; - border: 1px solid #d9e2ec; - border-radius: 6px; - background: #f0f4f8; - cursor: pointer; - font-size: 12px; +.btn-edit:hover { + background: #e2e8f0; + color: #1e293b; } -/* === Mobile Comfort (aligné gift-list) === */ -@media (pointer: coarse), (max-width: 780px) { - .pf-holidays h1 { - font-size: clamp(24px, 7vw, 30px); - margin: 10px 0 6px; - line-height: 1.2; - } - .pf-holidays h2 { - font-size: clamp(20px, 5.6vw, 24px); - line-height: 1.25; - margin: 10px 0 6px; - } - .pf-holidays h3 { - font-size: clamp(17px, 4.8vw, 20px); - line-height: 1.3; - margin: 8px 0 4px; - } - - .btn, - .hol-add-btn, - .hol-map-toggle { - font-size: 18px; - padding: 12px 14px; - min-height: 48px; - border-radius: 10px; - } - - .hol-form input, - .hol-form select, - .hol-form textarea, - .hol-cancel, - .hol-ok { - font-size: 16px; /* évite zoom iOS */ - min-height: 44px; - } - - .hol-dialog { - width: min(640px, calc(100% - 24px)); - } +.btn-delete { + background: #fef2f2; + color: #dc2626; + border: none; +} +.btn-delete:hover { + background: #fee2e2; + color: #b91c1c; } -/* Cards: style proche gift-list */ +/* --- 4. GRILLE & CARTES (Le cœur du design) --- */ .hol-ideas-grid { display: grid; - grid-template-columns: repeat(3, minmax(260px, 1fr)); - gap: 10px; -} -@media (max-width: 1100px) { - .hol-ideas-grid { - grid-template-columns: repeat(2, minmax(240px, 1fr)); - } -} -@media (max-width: 700px) { - .hol-ideas-grid { - grid-template-columns: 1fr; - } + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 24px; + margin-bottom: 40px; } .hol-idea-card { - background: #fff; - border: 1px solid #d9e2ec; - border-radius: 8px; - padding: 12px; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); + background: var(--bg-card); + border-radius: var(--radius-l); + box-shadow: var(--shadow-md); + padding: 20px; + display: flex; + flex-direction: column; transition: - box-shadow 120ms ease, - transform 120ms ease; + transform 0.25s ease, + box-shadow 0.25s ease; + border: 1px solid transparent; /* Pour éviter le saut au hover */ + position: relative; + overflow: hidden; } + +/* Effet au survol : la carte "flotte" */ .hol-idea-card:hover { - box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08); - transform: translateY(-1px); + transform: translateY(-5px); + box-shadow: var(--shadow-hover); + border-color: #e2e8f0; } + +/* Bande de couleur décorative en haut de carte selon statut (Optionnel mais joli) */ +.hol-idea-card::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: #e2e8f0; /* Default */ +} +.hol-idea-card:has(.hol-status--planned)::before { + background: #22c55e; +} +.hol-idea-card:has(.hol-status--favorite)::before { + background: #f59e0b; +} +.hol-idea-card:has(.hol-status--shortlist)::before { + background: #3b82f6; +} + .hol-idea-card__head { display: flex; - align-items: center; justify-content: space-between; + align-items: flex-start; + margin-bottom: 12px; +} + +.hol-idea-card h3 { + margin: 0; + font-size: 1.15rem; + font-weight: 700; + line-height: 1.3; + color: var(--text-main); + margin-right: 8px; +} + +.hol-idea-meta { + font-size: 0.9rem; + color: var(--text-muted); + margin-bottom: 12px; + display: flex; + flex-direction: column; + gap: 4px; +} + +/* Notes stylisées */ +.hol-notes { + background: #fffbeb; /* Jaune très pâle, style post-it */ + color: #78350f; + padding: 10px 12px; + border-radius: var(--radius-m); + font-size: 0.85rem; + font-style: italic; + margin-bottom: 16px; + flex-grow: 1; + border-left: 3px solid #fcd34d; +} + +.hol-card-actions { + margin-top: auto; + display: flex; + gap: 8px; + padding-top: 16px; + border-top: 1px solid #f1f5f9; +} + +/* Statuts (Badges Modernes) */ +.hol-status { + padding: 4px 10px; + border-radius: 20px; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; +} +.hol-status--planned { + background: #dcfce7; + color: #15803d; +} /* Vert */ +.hol-status--favorite { + background: #fff7ed; + color: #c2410c; + border: 1px solid #ffedd5; +} /* Orange */ +.hol-status--shortlist { + background: #eff6ff; + color: #1d4ed8; +} /* Bleu */ +.hol-status--draft { + background: #f1f5f9; + color: #64748b; +} /* Gris */ +.hol-status--archived { + background: #f8fafc; + color: #94a3b8; + text-decoration: line-through; +} + +/* Style spécifique pour les archivés */ +.hol-idea-card--archived { + background: #f8fafc; + box-shadow: none; + border: 1px solid #e2e8f0; + opacity: 0.8; +} + +/* --- 5. VUE DÉTAIL (Panels) --- */ +.pf-section--panel { + background: white; + border-radius: var(--radius-l); + box-shadow: var(--shadow-sm); + padding: 24px; + margin-bottom: 24px; + border: 1px solid #f1f5f9; +} + +.pf-section--panel h2 { + font-size: 1.25rem; + color: var(--text-main); + margin-bottom: 16px; + display: flex; + align-items: center; gap: 8px; } -.hol-idea-meta, -.hol-tags, -.hol-notes { - font-size: 12px; - color: #374151; - margin: 6px 0; +/* Petite ligne décorative sous le titre h2 */ +.pf-section--panel h2::after { + content: ""; + flex: 1; + height: 1px; + background: #e2e8f0; + margin-left: 12px; } -.hol-card-actions { + +.hol-list li { + padding: 12px 0; + border-bottom: 1px solid #f1f5f9; display: flex; - gap: 6px; - margin-top: 8px; + align-items: center; + flex-wrap: wrap; + gap: 8px; } -.hol-idea-card--archived { - opacity: 0.85; +.hol-list li:last-child { + border-bottom: none; } -/* Boutons cohérents */ -.btn, -.hol-add-btn, -.hol-map-toggle { +.hol-budget-summary { + background: linear-gradient(to right, #eff6ff, #ffffff); + border: 1px solid #dbeafe; + color: #1e40af; + padding: 16px; + border-radius: var(--radius-m); + font-weight: 600; + display: flex; + justify-content: space-between; + margin-bottom: 16px; +} + +/* Formulaires Inline (Détail) */ +.hol-inline-form { + background: #f8fafc; + padding: 12px; + border-radius: var(--radius-m); + border: 1px dashed #cbd5e1; + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + margin-bottom: 20px; +} +.hol-inline-form input, +.hol-inline-form select { + border: 1px solid #cbd5e1; + border-radius: var(--radius-s); padding: 8px 12px; - border: 1px solid #d9e2ec; - border-radius: 8px; - background: #f0f4f8; - color: #243b53; - text-decoration: none; + font-size: 0.9rem; + transition: border-color 0.2s; +} +.hol-inline-form input:focus, +.hol-inline-form select:focus { + border-color: var(--primary); + outline: 2px solid rgba(37, 99, 235, 0.1); +} +.hol-inline-form button { + background: #334155; + color: white; + border: none; + padding: 8px 16px; + border-radius: var(--radius-s); cursor: pointer; - font-size: 13px; } -.btn:hover, -.hol-add-btn:hover, -.hol-map-toggle:hover { - background: #e5eef5; -} -.btn-edit { - background: #e0f2fe; - border-color: #93c5fd; - color: #0f4c81; -} -.btn-delete { - background: #fee2e2; - border-color: #fca5a5; - color: #7c2d12; +.hol-inline-form button:hover { + background: #1e293b; } -/* Modale: harmonisée avec gift-list */ +/* --- 6. MODALES MODERNES (Glassmorphism léger) --- */ .hol-modal { display: none; } .hol-modal.open { - display: block; -} -.hol-backdrop { - position: fixed; - inset: 0; - background: rgba(17, 24, 39, 0.35); - z-index: 999; -} -.hol-dialog { - position: fixed; - z-index: 1000; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - width: min(560px, calc(100% - 24px)); - background: #fff; - border: 1px solid #d9e2ec; - border-radius: 8px; - box-shadow: 0 10px 30px rgba(17, 24, 39, 0.25); -} -.hol-dialog--map { - width: min(1200px, calc(100% - 24px)); -} -.hol-map-header { display: flex; align-items: center; - justify-content: space-between; - padding: 8px 12px; - border-bottom: 1px solid #e5e7eb; + justify-content: center; + position: fixed; + inset: 0; + z-index: 9999; } +.hol-backdrop { + position: absolute; + inset: 0; + background: rgba(15, 23, 42, 0.6); /* Plus sombre */ + backdrop-filter: blur(4px); /* Flou d'arrière plan */ +} + +.hol-dialog { + position: relative; + background: white; + width: min(600px, 90vw); + max-height: 90vh; + overflow-y: auto; + border-radius: 20px; /* Très arrondi */ + box-shadow: var(--shadow-lg); + z-index: 10; + animation: modalPop 0.3s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes modalPop { + from { + transform: scale(0.95) translateY(10px); + opacity: 0; + } + to { + transform: scale(1) translateY(0); + opacity: 1; + } +} + +/* Modale Carte */ +.hol-dialog--map { + width: 95vw; + height: 90vh; + max-width: 1400px; + padding: 0; + display: flex; + flex-direction: column; +} + +/* Formulaires Modale */ .hol-form { - display: grid; - gap: 10px; - padding: 14px; + padding: 32px; + display: flex; + flex-direction: column; + gap: 16px; } .hol-form h3 { - margin: 0 0 4px; - font-size: 16px; - color: #243b53; -} -.hol-inline { - display: flex; - flex-wrap: wrap; - gap: 8px; + font-size: 1.5rem; + margin-bottom: 12px; + color: var(--text-main); } .hol-form label { - display: grid; - gap: 6px; - font-size: 12px; - color: #374151; + font-weight: 600; + font-size: 0.85rem; + color: #475569; + margin-bottom: 4px; } .hol-form input, .hol-form select, .hol-form textarea { - font-size: 12px; - padding: 6px 8px; - border: 1px solid #d9e2ec; - border-radius: 6px; - background: #fff; - color: #111827; + width: 100%; + padding: 10px 12px; + border: 1px solid #cbd5e1; + border-radius: var(--radius-m); + font-size: 1rem; + background: #f8fafc; + box-sizing: border-box; /* Important */ } -.hol-actions { - display: flex; - justify-content: flex-end; - gap: 8px; - margin-top: 4px; -} -.hol-cancel, -.hol-ok { - font-size: 12px; - padding: 6px 12px; - border: 1px solid #d9e2ec; - border-radius: 6px; - background: #f0f4f8; - cursor: pointer; -} -.hol-ok { - font-weight: 600; - color: #1f2933; -} -.hol-cancel { - color: #d03050; -} -.hol-cancel:hover, -.hol-ok:hover { - background: #d9e2ec; -} -.hol-form input:focus-visible, -.hol-form select:focus-visible, -.hol-form textarea:focus-visible, -.hol-cancel:focus-visible, -.hol-ok:focus-visible { - outline: 2px solid #2563eb; - outline-offset: 2px; +.hol-form input:focus, +.hol-form select:focus, +.hol-form textarea:focus { + background: white; + border-color: var(--primary); + outline: 4px solid rgba(37, 99, 235, 0.1); } -/* Geocode picker harmonisé */ +.hol-inline { + display: flex; + gap: 16px; +} +.hol-inline > label { + flex: 1; +} + +.hol-actions { + margin-top: 16px; + display: flex; + justify-content: flex-end; + gap: 12px; +} +.hol-cancel { + background: transparent; + color: var(--text-muted); + border: none; + font-weight: 600; +} +.hol-cancel:hover { + color: var(--text-main); + background: #f1f5f9; +} + +.hol-ok { + background: var(--primary); + color: white; + border: none; + padding: 10px 24px; + border-radius: 50px; + font-weight: 600; + box-shadow: 0 4px 6px rgba(37, 99, 235, 0.2); +} +.hol-ok:hover { + background: var(--primary-hover); + transform: translateY(-1px); +} + +/* --- 7. PICKER GEOCODE --- */ .hol-geocode-picker { + background: white; + border: 1px solid #e2e8f0; + border-radius: var(--radius-m); + box-shadow: var(--shadow-lg); margin-top: 8px; - background: #fff; - border: 1px solid #d9e2ec; - border-radius: 8px; - box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08); overflow: hidden; } .hol-geocode-picker__header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 10px; background: #f8fafc; - border-bottom: 1px solid #e5e7eb; - font-size: 13px; + padding: 8px 12px; font-weight: 600; - color: #243b53; -} -.hol-geocode-picker__close { - border: none; - background: transparent; - cursor: pointer; - font-size: 18px; - line-height: 1; - padding: 2px 6px; -} -.hol-geocode-picker__list { - list-style: none; - margin: 0; - padding: 6px; - max-height: 260px; - overflow: auto; + font-size: 0.85rem; + display: flex; + justify-content: space-between; } .hol-geocode-picker__item { - display: grid; - grid-template-columns: 1fr auto auto; - align-items: center; - gap: 8px; - padding: 6px; - border-radius: 6px; -} -.hol-geocode-picker__item + .hol-geocode-picker__item { - margin-top: 4px; -} -.hol-geocode-picker__label { - font-size: 12px; - color: #111827; -} -.hol-geocode-picker__coords { - font-size: 11px; - color: #6b7280; -} -.hol-geocode-picker__pick { - padding: 6px 10px; - border: 1px solid #d9e2ec; - border-radius: 6px; - background: #f0f4f8; + padding: 10px 12px; + border-bottom: 1px solid #f1f5f9; cursor: pointer; - font-size: 12px; + display: flex; + justify-content: space-between; + align-items: center; +} +.hol-geocode-picker__item:hover { + background: #f0f9ff; +} +.hol-geocode-picker__item button { + background: white; + border: 1px solid #cbd5e1; + border-radius: 4px; + font-size: 0.75rem; + padding: 4px 8px; } -/* Mobile comfort (match gift-list) */ -@media (pointer: coarse), (max-width: 780px) { +/* --- 8. MOBILE RESPONSIVE --- */ +@media (max-width: 768px) { + .hol-inline { + flex-direction: column; + gap: 12px; + } + .hol-inline-form { + flex-direction: column; + align-items: stretch; + } + .hol-inline-form button { + margin-top: 8px; + width: 100%; + } + .pf-holidays h1 { - font-size: clamp(24px, 7vw, 30px); - margin: 10px 0 6px; - line-height: 1.2; + font-size: 1.5rem; } - .pf-holidays h2 { - font-size: clamp(20px, 5.6vw, 24px); - line-height: 1.25; - margin: 10px 0 6px; + + .hol-idea-card { + padding: 16px; } - .pf-holidays h3 { - font-size: clamp(17px, 4.8vw, 20px); - line-height: 1.3; - margin: 8px 0 4px; + .hol-ideas-grid { + gap: 16px; } .btn, .hol-add-btn, .hol-map-toggle { - font-size: 18px; - padding: 12px 14px; - min-height: 48px; - border-radius: 10px; + width: 100%; /* Boutons pleine largeur sur mobile */ + margin-bottom: 8px; } - .hol-form input, - .hol-form select, - .hol-form textarea, - .hol-cancel, - .hol-ok { - font-size: 16px; - min-height: 44px; - } - .hol-dialog { - width: min(640px, calc(100% - 24px)); + .hol-title-actions { + flex-direction: column; + width: 100%; + gap: 4px; } } diff --git a/modules/holidays/holidays.js b/modules/holidays/holidays.js index 8147a2b..85651de 100644 --- a/modules/holidays/holidays.js +++ b/modules/holidays/holidays.js @@ -1,107 +1,128 @@ // modules/holidays/holidays.js document.addEventListener("DOMContentLoaded", () => { - // --- Modale "Ajouter une idée" - const addBtn = document.getElementById("hol-add-open"); - const addModal = document.getElementById("hol-add-modal"); - if (addBtn && addModal) { - const backdrop = addModal.querySelector(".hol-backdrop"); - const cancel = addModal.querySelector(".hol-cancel"); - const open = () => addModal.classList.add("open"); - const close = () => addModal.classList.remove("open"); - addBtn.addEventListener("click", open); - backdrop.addEventListener("click", close); - cancel.addEventListener("click", close); + // --- 1. FONCTIONS UTILITAIRES --- + + /** + * Configure les comportements de fermeture d'une modale (Backdrop, Cancel, Escape) + */ + function setupModal(modalId, openAction = null) { + const modal = document.getElementById(modalId); + if (!modal) return null; + + const backdrop = modal.querySelector(".hol-backdrop"); + const cancelBtn = modal.querySelector(".hol-cancel"); + + const close = () => modal.classList.remove("open"); + const open = () => { + modal.classList.add("open"); + if (openAction) openAction(); + }; + + if (backdrop) backdrop.addEventListener("click", close); + if (cancelBtn) cancelBtn.addEventListener("click", close); + + // Fermeture avec ECHAP (uniquement si cette modale est ouverte) document.addEventListener("keydown", (e) => { - if (e.key === "Escape") close(); + if (e.key === "Escape" && modal.classList.contains("open")) { + close(); + } }); + + return { modal, open, close }; } - // --- Helpers modale "Éditer" - const editModal = document.getElementById("hol-edit-modal"); - const openEditModal = () => { - if (editModal) editModal.classList.add("open"); - }; - const closeEditModal = () => { - if (editModal) editModal.classList.remove("open"); - }; + /** + * Échappe les caractères HTML pour éviter les failles XSS simples + */ + function esc(s) { + if (s === null || s === undefined) return ""; + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + // --- 2. GESTION MODALE "AJOUTER" --- + const addModalCtrl = setupModal("hol-add-modal"); + const addBtn = document.getElementById("hol-add-open"); + if (addBtn && addModalCtrl) { + addBtn.addEventListener("click", addModalCtrl.open); + } + + // --- 3. GESTION MODALE "ÉDITER" --- + const editModalCtrl = setupModal("hol-edit-modal"); + + // Fonction pour charger et ouvrir l'édition via AJAX async function openEditForId(id) { + if (!editModalCtrl) return; + try { const res = await fetch( `/modules/holidays/view.php?id=${encodeURIComponent(id)}`, - { - headers: { Accept: "application/json" }, - }, + { headers: { Accept: "application/json" } }, ); - if (!res.ok) throw new Error("HTTP " + res.status); - const it = await res.json(); + if (!res.ok) throw new Error("Erreur HTTP " + res.status); - if (!editModal) { - alert("Modale édition introuvable"); - return; - } - // Scope les sélecteurs à la modale pour éviter les null - const $ = (sel) => editModal.querySelector(sel); + const it = await res.json(); + const modal = editModalCtrl.modal; + + // Helper pour remplir les champs const setVal = (sel, val) => { - const el = $(sel); + const el = modal.querySelector(sel); if (el) el.value = val ?? ""; }; + // Remplissage du formulaire setVal("#edit-id", it.id); - setVal("#edit-title", it.title || ""); - setVal("#edit-country", it.country || ""); - setVal("#edit-region", it.region || ""); - setVal("#edit-city", it.city || ""); - setVal("#edit-lat", it.lat ?? ""); - setVal("#edit-lng", it.lng ?? ""); - setVal("#edit-start", it.desired_start_date ?? ""); - setVal("#edit-end", it.desired_end_date ?? ""); - setVal("#edit-season", it.season_hint || ""); - setVal("#edit-days", it.ideal_days ?? ""); + setVal("#edit-title", it.title); + setVal("#edit-country", it.country); + setVal("#edit-region", it.region); + setVal("#edit-city", it.city); + setVal("#edit-lat", it.lat); + setVal("#edit-lng", it.lng); + setVal("#edit-start", it.desired_start_date); + setVal("#edit-end", it.desired_end_date); + setVal("#edit-season", it.season_hint); + setVal("#edit-days", it.ideal_days); setVal("#edit-status", it.status || "draft"); - setVal("#edit-notes", it.notes || ""); + setVal("#edit-notes", it.notes); - openEditModal(); - } catch { - alert("Impossible de charger l’idée."); + editModalCtrl.open(); + } catch (err) { + console.error(err); + alert("Impossible de charger les données de l'idée."); } } - // --- Modale "Éditer" (liste + page détail) - if (editModal) { - const backdrop = editModal.querySelector(".hol-backdrop"); - const cancel = editModal.querySelector(".hol-cancel"); - if (backdrop) backdrop.addEventListener("click", closeEditModal); - if (cancel) cancel.addEventListener("click", closeEditModal); - document.addEventListener("keydown", (e) => { - if (e.key === "Escape") closeEditModal(); - }); - - // Boutons "Éditer" des cards (liste/planifiées/archivées) - document.querySelectorAll(".btn-edit[data-edit-id]").forEach((btn) => { - btn.addEventListener("click", () => { - const id = btn.getAttribute("data-edit-id"); - if (id) openEditForId(id); - }); - }); - - // Bouton "Éditer" sur la page détail (en-tête) - const headerEditBtn = document.getElementById("hol-edit-open"); - if (headerEditBtn) { - headerEditBtn.addEventListener("click", () => { - const id = headerEditBtn.getAttribute("data-edit-id"); - if (id) openEditForId(id); - }); + // Écouteurs sur les boutons "Éditer" (Liste + Détail) + document.body.addEventListener("click", (e) => { + // Utilisation de la délégation d'événement pour gérer tous les boutons (même dynamiques) + const btn = e.target.closest(".btn-edit"); + if (btn && btn.hasAttribute("data-edit-id")) { + const id = btn.getAttribute("data-edit-id"); + openEditForId(id); } - } + // Cas spécifique du bouton dans le header de la vue détail + else if (e.target.id === "hol-edit-open") { + const id = e.target.getAttribute("data-edit-id"); + openEditForId(id); + } + }); - // --- Suppression (liste + planifiées + page détail) - document.querySelectorAll(".btn-delete[data-del-id]").forEach((btn) => { - btn.addEventListener("click", () => { + // --- 4. GESTION SUPPRESSION --- + document.body.addEventListener("click", (e) => { + const btn = e.target.closest(".btn-delete"); + if (btn && btn.hasAttribute("data-del-id")) { const id = btn.getAttribute("data-del-id"); - if (!id) return; - if (confirm("Supprimer cette idée ?")) { + if ( + confirm( + "Voulez-vous vraiment supprimer cette idée ?\nCette action est irréversible.", + ) + ) { + // Création d'un formulaire temporaire pour le POST const form = document.createElement("form"); form.method = "post"; form.action = "/modules/holidays/save.php"; @@ -112,45 +133,51 @@ document.addEventListener("DOMContentLoaded", () => { document.body.appendChild(form); form.submit(); } - }); + } }); - // --- Géocodage via Nominatim + UI multi-résultats + // --- 5. GÉOCODAGE (Nominatim) --- document.querySelectorAll(".hol-geocode-btn").forEach((btn) => { btn.addEventListener("click", async () => { const scope = btn.getAttribute("data-scope"); // 'add' ou 'edit' - let form, city, region, country, latInput, lngInput; + let latInput, + lngInput, + qParts = []; + // Récupération des inputs selon le scope if (scope === "edit") { - form = btn.closest("form") || document; - city = (document.getElementById("edit-city")?.value || "").trim(); - region = (document.getElementById("edit-region")?.value || "").trim(); - country = (document.getElementById("edit-country")?.value || "").trim(); + const getVal = (id) => + (document.getElementById(id)?.value || "").trim(); + qParts = [ + getVal("edit-city"), + getVal("edit-region"), + getVal("edit-country"), + ]; latInput = document.getElementById("edit-lat"); lngInput = document.getElementById("edit-lng"); } else { - form = btn.closest("form"); - city = (form?.querySelector('input[name="city"]')?.value || "").trim(); - region = ( - form?.querySelector('input[name="region"]')?.value || "" - ).trim(); - country = ( - form?.querySelector('input[name="country"]')?.value || "" - ).trim(); + // Scope 'add' : on cherche dans le formulaire parent + const form = btn.closest("form"); + const getVal = (name) => + (form?.querySelector(`input[name="${name}"]`)?.value || "").trim(); + qParts = [getVal("city"), getVal("region"), getVal("country")]; latInput = form?.querySelector('input[name="lat"]'); lngInput = form?.querySelector('input[name="lng"]'); } - const q = [city, region, country].filter(Boolean).join(", "); + const q = qParts.filter(Boolean).join(", "); if (!q) { - alert("Renseigne au moins Ville/Pays."); + alert( + "Veuillez renseigner au moins une Ville ou un Pays pour géocoder.", + ); return; } + // UI : chargement removeNearbyPicker(btn); btn.disabled = true; - const original = btn.textContent; - btn.textContent = "Recherche..."; + const originalText = btn.textContent; + btn.textContent = "⏳..."; try { const res = await fetch( @@ -160,28 +187,30 @@ document.addEventListener("DOMContentLoaded", () => { }, ); const data = await res.json(); - if (!res.ok) throw new Error(data?.error || "Erreur géocodage"); - if ("lat" in data && "lng" in data) { + if (!res.ok) throw new Error(data?.error || "Erreur inconnue"); + + // Cas 1: Résultat direct (lat/lng uniques) + if (data.lat && data.lng) { if (latInput) latInput.value = data.lat; if (lngInput) lngInput.value = data.lng; - return; } - - if (Array.isArray(data.results) && data.results.length > 0) { + // Cas 2: Liste de choix + else if (Array.isArray(data.results) && data.results.length > 0) { renderGeocodePicker(btn, data.results, (choice) => { if (latInput) latInput.value = choice.lat; if (lngInput) lngInput.value = choice.lng; removeNearbyPicker(btn); }); } else { - alert("Aucun résultat."); + alert("Aucun résultat trouvé pour : " + q); } - } catch { - alert("Impossible de trouver les coordonnées pour: " + q); + } catch (err) { + console.error(err); + alert("Erreur lors du géocodage."); } finally { btn.disabled = false; - btn.textContent = original; + btn.textContent = originalText; } }); }); @@ -192,46 +221,36 @@ document.addEventListener("DOMContentLoaded", () => { const wrapper = document.createElement("div"); wrapper.className = "hol-geocode-picker"; + // Header const header = document.createElement("div"); header.className = "hol-geocode-picker__header"; - header.textContent = "Plusieurs résultats trouvés"; - const closeBtn = document.createElement("button"); - closeBtn.type = "button"; - closeBtn.className = "hol-geocode-picker__close"; - closeBtn.textContent = "×"; - closeBtn.addEventListener("click", () => removeNearbyPicker(anchorBtn)); - header.appendChild(closeBtn); + header.innerHTML = `Choix multiples`; + header + .querySelector(".hol-close") + .addEventListener("click", () => removeNearbyPicker(anchorBtn)); + wrapper.appendChild(header); + // Liste const list = document.createElement("ul"); list.className = "hol-geocode-picker__list"; results.forEach((r) => { const li = document.createElement("li"); li.className = "hol-geocode-picker__item"; - - const label = document.createElement("div"); - label.className = "hol-geocode-picker__label"; - label.textContent = r.display_name || `${r.lat}, ${r.lng}`; - - const coords = document.createElement("div"); - coords.className = "hol-geocode-picker__coords"; - coords.textContent = `(${r.lat}, ${r.lng})`; - - const pickBtn = document.createElement("button"); - pickBtn.type = "button"; - pickBtn.className = "hol-geocode-picker__pick"; - pickBtn.textContent = "Choisir"; - pickBtn.addEventListener("click", () => onPick(r)); - - li.appendChild(label); - li.appendChild(coords); - li.appendChild(pickBtn); + li.innerHTML = ` +
+ ${esc(r.display_name)} + (${r.lat}, ${r.lng}) +
+ + `; + li.querySelector("button").addEventListener("click", () => onPick(r)); list.appendChild(li); }); - wrapper.appendChild(header); wrapper.appendChild(list); + // Insertion après le conteneur du bouton const container = anchorBtn.closest(".hol-inline") || anchorBtn.parentElement; container.insertAdjacentElement("afterend", wrapper); @@ -246,123 +265,96 @@ document.addEventListener("DOMContentLoaded", () => { } } - // --- Carte (Leaflet) + // --- 6. CARTE LEAFLET --- + let mapInitialized = false; + let mapInstance; + + // Fonction d'initialisation de la carte (appelée à l'ouverture de la modale) + function initMap() { + if (mapInitialized) { + // Si déjà init, on force juste le redimensionnement pour éviter les bugs d'affichage + setTimeout(() => mapInstance.invalidateSize(), 200); + return; + } + + if (typeof L === "undefined") { + console.error("Leaflet n'est pas chargé."); + return; + } + + // Récupération sécurisée des données injectées par PHP + const MAP_DATA = Array.isArray(window.HOL_MAP_DATA) + ? window.HOL_MAP_DATA + : []; + + mapInstance = L.map("hol-map", { scrollWheelZoom: true }); + L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { + attribution: "© OpenStreetMap", + }).addTo(mapInstance); + + const markers = []; + + MAP_DATA.forEach((it) => { + const lat = parseFloat(it.lat); + const lng = parseFloat(it.lng); + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return; + + // Code couleur selon statut + const colors = { + planned: "#16a34a", + favorite: "#f59e0b", + shortlist: "#3b82f6", + default: "#6b7280", + }; + const color = colors[it.status] || colors.default; + + const m = L.circleMarker([lat, lng], { + radius: 7, + color: "#ffffff", + weight: 1, + fillColor: color, + fillOpacity: 0.9, + }).addTo(mapInstance); + + // Construction du popup + const loc = [it.city, it.region, it.country].filter(Boolean).join(", "); + const dates = it.desired_start_date + ? `${it.desired_start_date}${it.desired_end_date ? " → " + it.desired_end_date : ""}` + : null; + + m.bindPopup(` +
+ ${esc(it.title)} +
${esc(loc)}
+ ${dates ? `
📅 ${esc(dates)}
` : ""} +
+ ${esc(it.status)} + Voir +
+
+ `); + + markers.push(m); + }); + + // Centrage de la carte + if (markers.length > 0) { + const group = L.featureGroup(markers); + mapInstance.fitBounds(group.getBounds(), { padding: [50, 50] }); + } else { + mapInstance.setView([46.603354, 1.888334], 5); // France par défaut si vide + } + + mapInitialized = true; + + // Hack indispensable pour que Leaflet calcule la bonne taille dans une modale + setTimeout(() => mapInstance.invalidateSize(), 200); + } + + // Connexion de la carte à la modale const mapBtn = document.getElementById("hol-map-open"); - const mapModal = document.getElementById("hol-map-modal"); - if (mapBtn && mapModal) { - const backdrop = mapModal.querySelector(".hol-backdrop"); - const cancel = mapModal.querySelector(".hol-cancel"); - const open = () => mapModal.classList.add("open"); - const close = () => mapModal.classList.remove("open"); - - let mapInitialized = false; - let map; - - function initMap() { - // Évite double initialisation - if (mapInitialized) return; - mapInitialized = true; - - // Données carte (safe fallback) - const MAP_DATA = Array.isArray(window.HOL_MAP_DATA) - ? window.HOL_MAP_DATA - : []; - console.log("HOL_MAP_DATA (safe):", MAP_DATA); - - // Leaflet dispo ? - if (typeof L === "undefined") { - console.error("Leaflet non chargé"); - return; - } - - // Init carte une seule fois (ne pas faire ça dans la boucle) - map = L.map("hol-map", { scrollWheelZoom: true }); - L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { - attribution: "© OpenStreetMap", - }).addTo(map); - - // Corrige la taille après affichage de la modale - setTimeout(() => map.invalidateSize(), 0); - - // Ajout des marqueurs - const markers = []; - MAP_DATA.forEach((it) => { - const lat = parseFloat(it.lat); - const lng = parseFloat(it.lng); - if (!Number.isFinite(lat) || !Number.isFinite(lng)) return; - - const color = - it.status === "planned" - ? "#16a34a" - : it.status === "favorite" - ? "#f59e0b" - : it.status === "shortlist" - ? "#3b82f6" - : "#6b7280"; - - const m = L.circleMarker([lat, lng], { - radius: 6, - color, - fillColor: color, - fillOpacity: 0.85, - }).addTo(map); - - const loc = [it.city, it.region, it.country].filter(Boolean).join(", "); - const dates = it.desired_start_date - ? `${it.desired_start_date}${it.desired_end_date ? " → " + it.desired_end_date : ""}` - : ""; - - m.bindPopup(` - ${esc(it.title || "")}
- ${esc(loc)}
- ${dates ? "Dates: " + esc(dates) + "
" : ""} - Statut: ${esc(it.status || "")}
- Ouvrir - `); - - markers.push(m); - }); - - // Vue par défaut selon nombre de points - if (markers.length === 1) { - map.setView(markers[0].getLatLng(), 7); - } else if (markers.length > 1) { - const group = L.featureGroup(markers); - map.fitBounds(group.getBounds(), { padding: [20, 20] }); - } else { - console.warn( - "HOL_MAP_DATA vide ou coordonnées non valides.", - window.HOL_MAP_DATA, - ); - map.setView([20, 0], 2); - } - - // Re-valider la taille après rendu complet - setTimeout(() => map.invalidateSize(), 100); - } - - function esc(s) { - return String(s).replace( - /[&<>"']/g, - (c) => - ({ - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", - })[c], - ); - } - - mapBtn.addEventListener("click", () => { - open(); - setTimeout(initMap, 0); - }); - if (backdrop) backdrop.addEventListener("click", close); - if (cancel) cancel.addEventListener("click", close); - document.addEventListener("keydown", (e) => { - if (e.key === "Escape") close(); - }); + if (mapBtn) { + const mapModalCtrl = setupModal("hol-map-modal", initMap); // On passe initMap en callback d'ouverture + mapBtn.addEventListener("click", mapModalCtrl.open); } }); diff --git a/modules/holidays/index.php b/modules/holidays/index.php index 582ad38..713e87b 100644 --- a/modules/holidays/index.php +++ b/modules/holidays/index.php @@ -1,506 +1,459 @@ prepare($sql); - $st->execute($params); - return $st->fetchAll(PDO::FETCH_ASSOC); - } + function hol_q(PDO $pdo, string $sql, array $params = []): array { + $st = $pdo->prepare($sql); + $st->execute($params); + return $st->fetchAll(PDO::FETCH_ASSOC); + } } $ideaId = isset($_GET['id']) ? (int)$_GET['id'] : 0; +$isDetailView = ($ideaId > 0); +$currentIdea = null; // Stockera les données si on est en vue détail + +// Préparation des variables par défaut pour éviter les erreurs "undefined variable" +$transport = []; $lodging = []; $acts = []; $budget = []; +$fixedTotal = 0; $ppTotal = 0; +$planned = []; $ideas = []; $archived = []; + +if ($isDetailView) { + // --- MODE DÉTAIL --- + + // 1. Récupérer l'idée principale + $rows = hol_q($pdo, "SELECT * FROM pf_holidays_ideas WHERE id = ?", [$ideaId]); + $currentIdea = $rows[0] ?? null; + + if ($currentIdea) { + // 2. Récupérer les données pour la CARTE (un seul point) + // Le JS filtrera lat/lng invalides + $mapIdeas = hol_q($pdo, " + SELECT id, title, country, region, city, + CAST(lat AS DECIMAL(9,6)) AS lat, + CAST(lng AS DECIMAL(9,6)) AS lng, + status, desired_start_date, desired_end_date + FROM pf_holidays_ideas + WHERE id = ? + ", [$ideaId]); + + // 3. Récupérer les sous-éléments (Transport, Logement, etc.) + $transport = hol_q($pdo, "SELECT * FROM pf_holidays_transport WHERE idea_id = ? ORDER BY created_at DESC", [$ideaId]); + $lodging = hol_q($pdo, "SELECT * FROM pf_holidays_lodging WHERE idea_id = ? ORDER BY created_at DESC", [$ideaId]); + $acts = hol_q($pdo, "SELECT * FROM pf_holidays_activities WHERE idea_id = ? ORDER BY created_at DESC", [$ideaId]); + $budget = hol_q($pdo, "SELECT category, label, amount, per_person FROM pf_holidays_budget_items WHERE idea_id = ? ORDER BY created_at DESC", [$ideaId]); + + // 4. Calculs totaux budget + $sumRow = hol_q($pdo, " + SELECT + SUM(CASE WHEN per_person=0 THEN amount ELSE 0 END) AS fixed_total, + SUM(CASE WHEN per_person=1 THEN amount ELSE 0 END) AS per_person_total + FROM pf_holidays_budget_items WHERE idea_id = ? + ", [$ideaId])[0] ?? ['fixed_total' => 0, 'per_person_total' => 0]; + + $fixedTotal = (float)($sumRow['fixed_total'] ?? 0); + $ppTotal = (float)($sumRow['per_person_total'] ?? 0); + } -/* Préparer les données pour la carte AVANT le rendu (liste ou détail) */ -if ($ideaId > 0) { - // Vue détail: renvoyer l'idée courante (le JS filtrera lat/lng invalides) - $mapIdeas = hol_q($pdo, " - SELECT id, title, country, region, city, - CAST(lat AS DECIMAL(9,6)) AS lat, - CAST(lng AS DECIMAL(9,6)) AS lng, - status, desired_start_date, desired_end_date - FROM pf_holidays_ideas - WHERE id = ? - ", [$ideaId]); } else { - // Vue liste: idées non archivées avec coordonnées - $mapIdeas = hol_q($pdo, " - SELECT id, title, country, region, city, - CAST(lat AS DECIMAL(9,6)) AS lat, - CAST(lng AS DECIMAL(9,6)) AS lng, - status, desired_start_date, desired_end_date - FROM pf_holidays_ideas - WHERE status IN ('draft','shortlist','favorite','planned') - AND lat IS NOT NULL - AND lng IS NOT NULL - "); + // --- MODE LISTE --- + + // 1. Données pour la CARTE (tous les points valides) + $mapIdeas = hol_q($pdo, " + SELECT id, title, country, region, city, + CAST(lat AS DECIMAL(9,6)) AS lat, + CAST(lng AS DECIMAL(9,6)) AS lng, + status, desired_start_date, desired_end_date + FROM pf_holidays_ideas + WHERE status IN ('draft','shortlist','favorite','planned') + AND lat IS NOT NULL + AND lng IS NOT NULL + "); + + // 2. Récupérer les listes + $planned = hol_q($pdo, " + SELECT * FROM pf_holidays_ideas + WHERE status = 'planned' + ORDER BY COALESCE(desired_start_date, created_at) DESC + "); + $ideas = hol_q($pdo, " + SELECT * FROM pf_holidays_ideas + WHERE status IN ('draft','shortlist','favorite') + ORDER BY FIELD(status,'favorite','shortlist','draft'), created_at DESC + "); + $archived = hol_q($pdo, " + SELECT * FROM pf_holidays_ideas + WHERE status = 'archived' + ORDER BY updated_at DESC + "); } +?> -if ($ideaId > 0) { - // Vue détail d'une idée - $rows = hol_q($pdo, "SELECT * FROM pf_holidays_ideas WHERE id = ?", [$ideaId]); - $idea = $rows[0] ?? null; - if (!$idea) { - echo '

Idée introuvable.

'; - return; - } - $transport = hol_q($pdo, "SELECT * FROM pf_holidays_transport WHERE idea_id = ? ORDER BY created_at DESC", [$ideaId]); - $lodging = hol_q($pdo, "SELECT * FROM pf_holidays_lodging WHERE idea_id = ? ORDER BY created_at DESC", [$ideaId]); - $acts = hol_q($pdo, "SELECT * FROM pf_holidays_activities WHERE idea_id = ? ORDER BY created_at DESC", [$ideaId]); - $budget = hol_q($pdo, "SELECT category, label, amount, per_person FROM pf_holidays_budget_items WHERE idea_id = ? ORDER BY created_at DESC", [$ideaId]); - $sumRow = hol_q($pdo, " - SELECT - SUM(CASE WHEN per_person=0 THEN amount ELSE 0 END) AS fixed_total, - SUM(CASE WHEN per_person=1 THEN amount ELSE 0 END) AS per_person_total - FROM pf_holidays_budget_items WHERE idea_id = ? - ", [$ideaId])[0] ?? ['fixed_total' => 0, 'per_person_total' => 0]; - $fixedTotal = (float)($sumRow['fixed_total'] ?? 0); - $ppTotal = (float)($sumRow['per_person_total'] ?? 0); - ?> - - - - + -
-

-
- ← Retour - - - + + + +
+

Idée introuvable

+ +
+

Cette idée de vacances n'existe pas ou a été supprimée.

+ +
+

+
+ ← Retour + + + +
+
+ +

+ + + • Dates: + + • Saison: + + + • Durée idéale: j + + • Statut: +

+ +
+
+

Transport

+
+ + + + + + + + + +
+
    + +
  • + + + 🔗 +
  • + +
+
+ +
+

Hébergement

+
+ + + + + + + + + + + +
+
    + +
  • + + + + 🔗 +
  • + +
+
+ +
+

Activités

+
+ + + + + + + + + + +
+
    + +
  • + + + 🔗 +
  • + +
+
+ +
+

Budget

+
+ + + + + + + +
+ +
+ Fixe: € + • Par personne: € +
+ +
    + +
  • [] — + +
  • + +
+
+
+ + + +
+

Idées de vacances

+
+ + +
-
-

- - - • Dates: - - • Saison: - - - • Durée idéale: j - - • Statut: -

- -
-

Transport

-
- - - - - - - - - -
-
    - -
  • - - - 🔗 -
  • - -
+

Vacances planifiées

+

Dates souhaitées, prêtes à être réservées.

+
+ +
+
+

+ planned +
+

+ + + • Dates: + + + • Durée idéale: j + +

+ +

+ +
+ Ouvrir + + +
+
+ +
-

Hébergement

-
- - - - - - - - - - - -
-
    - -
  • - - - - 🔗 -
  • - -
+

Idées

+

Brouillons, favoris, shortlist.

+
+ +
+
+

+ +
+

+ + + • Dates: + + • Saison: + + + • Durée idéale: j + +

+ +

+ +
+ Ouvrir + + +
+
+ +
-

Activités

-
- - - - - - - - - - -
-
    - -
  • - - - 🔗 -
  • - -
+

Archivées

+
+ +
+

+
+ + +
+
+ +
-
-

Budget

-
- - - - - - - -
+
-
+
+ + + +
- - +
+ + + + + +
- - - - - - -
-

Idées de vacances

-
- - -
-
- -
-

Vacances planifiées

-

Dates souhaitées, prêtes à être réservées.

-
- -
-
-

- planned -
-

- - - • Dates: - - - • Durée idéale: j - -

- -

- -
- Ouvrir - - -
-
- -
-
- -
-

Idées

-

Brouillons, favoris, shortlist.

-
- -
-
-

- -
-

- - - • Dates: - - • Saison: - - - • Durée idéale: j - -

- -

- -
- Ouvrir - - -
-
- -
-
- -
-

Archivées

-
- -
-

-
- - -
-
- -
-
- - - - - - - - - - - + \ No newline at end of file diff --git a/modules/holidays/save.php b/modules/holidays/save.php index e79461f..eef590e 100644 --- a/modules/holidays/save.php +++ b/modules/holidays/save.php @@ -1,194 +1,234 @@ NULL, sinon retourne la chaîne telle quelle + * Normalise une date (YYYY-MM-DD) : '' -> NULL */ function hol_norm_date($v): ?string { - if (!isset($v)) return null; - $s = trim((string)$v); - return $s === '' ? null : $s; + if (!isset($v)) return null; + $s = trim((string)$v); + return $s === '' ? null : $s; +} + +/** + * Redirection explicite vers la vue détail ou liste + */ +function hol_redirect(int $id = 0): void { + if ($id > 0) { + header("Location: /holidays.php?id=" . $id); + } else { + header("Location: /holidays.php"); + } + exit; } try { - switch ($action) { - case 'create_idea': { - $status = $_POST['status'] ?? 'draft'; - $start = hol_norm_date($_POST['desired_start_date'] ?? null); - $end = hol_norm_date($_POST['desired_end_date'] ?? null); - if (!empty($start) && $status === 'draft') { $status = 'planned'; } + switch ($action) { + // --- CRÉATION --- + case 'create_idea': { + $title = trim($_POST['title'] ?? ''); + if ($title === '') { + throw new Exception("Le titre est obligatoire."); + } - $latVal = hol_norm_decimal($_POST['lat'] ?? null); - $lngVal = hol_norm_decimal($_POST['lng'] ?? null); + $status = $_POST['status'] ?? 'draft'; + $start = hol_norm_date($_POST['desired_start_date'] ?? null); + $end = hol_norm_date($_POST['desired_end_date'] ?? null); + + // Règle métier : si on met une date, on passe probablement en 'planned' + if (!empty($start) && $status === 'draft') { $status = 'planned'; } - $stmt = $pdo->prepare(" - INSERT INTO pf_holidays_ideas - (title, country, region, city, lat, lng, desired_start_date, desired_end_date, season_hint, ideal_days, status, notes) - VALUES - (:title,:country,:region,:city,:lat,:lng,:start,:end,:season,:days,:status,:notes) - "); - $stmt->execute([ - ':title' => trim($_POST['title'] ?? ''), - ':country'=> trim($_POST['country'] ?? ''), - ':region' => trim($_POST['region'] ?? ''), - ':city' => trim($_POST['city'] ?? ''), - ':lat' => $latVal, - ':lng' => $lngVal, - ':start' => $start, - ':end' => $end, - ':season' => trim($_POST['season_hint'] ?? ''), - ':days' => ($_POST['ideal_days'] !== '' ? (int)$_POST['ideal_days'] : null), - ':status' => $status, - ':notes' => trim($_POST['notes'] ?? ''), - ]); - $newId = (int)$pdo->lastInsertId(); - header("Location: /holidays.php?id={$newId}"); - exit; + $stmt = $pdo->prepare(" + INSERT INTO pf_holidays_ideas + (title, country, region, city, lat, lng, desired_start_date, desired_end_date, season_hint, ideal_days, status, notes) + VALUES + (:title,:country,:region,:city,:lat,:lng,:start,:end,:season,:days,:status,:notes) + "); + $stmt->execute([ + ':title' => $title, + ':country' => trim($_POST['country'] ?? ''), + ':region' => trim($_POST['region'] ?? ''), + ':city' => trim($_POST['city'] ?? ''), + ':lat' => hol_norm_decimal($_POST['lat'] ?? null), + ':lng' => hol_norm_decimal($_POST['lng'] ?? null), + ':start' => $start, + ':end' => $end, + ':season' => trim($_POST['season_hint'] ?? ''), + ':days' => ($_POST['ideal_days'] !== '' ? (int)$_POST['ideal_days'] : null), + ':status' => $status, + ':notes' => trim($_POST['notes'] ?? ''), + ]); + + // Redirection vers la nouvelle idée + hol_redirect((int)$pdo->lastInsertId()); + break; + } + + // --- MISE À JOUR --- + case 'update_idea': { + $id = (int)($_POST['id'] ?? 0); + if ($id <= 0) throw new Exception("ID invalide."); + + $title = trim($_POST['title'] ?? ''); + if ($title === '') throw new Exception("Le titre est obligatoire."); + + $status = $_POST['status'] ?? 'draft'; + $start = hol_norm_date($_POST['desired_start_date'] ?? null); + $end = hol_norm_date($_POST['desired_end_date'] ?? null); + if (!empty($start) && $status === 'draft') { $status = 'planned'; } + + $stmt = $pdo->prepare(" + UPDATE pf_holidays_ideas + SET title=:title, country=:country, region=:region, city=:city, lat=:lat, lng=:lng, + desired_start_date=:start, desired_end_date=:end, + season_hint=:season, ideal_days=:days, status=:status, notes=:notes + WHERE id=:id + "); + $stmt->execute([ + ':id' => $id, + ':title' => $title, + ':country' => trim($_POST['country'] ?? ''), + ':region' => trim($_POST['region'] ?? ''), + ':city' => trim($_POST['city'] ?? ''), + ':lat' => hol_norm_decimal($_POST['lat'] ?? null), + ':lng' => hol_norm_decimal($_POST['lng'] ?? null), + ':start' => $start, + ':end' => $end, + ':season' => trim($_POST['season_hint'] ?? ''), + ':days' => ($_POST['ideal_days'] !== '' ? (int)$_POST['ideal_days'] : null), + ':status' => $status, + ':notes' => trim($_POST['notes'] ?? ''), + ]); + + hol_redirect($id); + break; + } + + // --- SUPPRESSION --- + case 'delete_idea': { + $id = (int)($_POST['id'] ?? 0); + if ($id > 0) { + // On supprime l'idée (les sous-tables devraient être supprimées via ON DELETE CASCADE côté SQL, + // sinon il faudrait les supprimer ici manuellement) + $stmt = $pdo->prepare("DELETE FROM pf_holidays_ideas WHERE id = :id"); + $stmt->execute([':id' => $id]); + } + hol_redirect(0); // Retour liste + break; + } + + // --- SOUS-ÉLÉMENTS (Transport, Logement, Activités, Budget) --- + + case 'add_transport': { + $id = (int)$_POST['idea_id']; + $stmt = $pdo->prepare(" + INSERT INTO pf_holidays_transport (idea_id, mode, duration_min, cost, co2_kg, link, notes) + VALUES (:id,:mode,:dur,:cost,:co2,:link,:notes) + "); + $stmt->execute([ + ':id' => $id, + ':mode' => strtoupper($_POST['mode'] ?? 'OTHER'), + ':dur' => ($_POST['duration_min'] !== '' ? (int)$_POST['duration_min'] : null), + ':cost' => hol_norm_decimal($_POST['cost'] ?? null), + ':co2' => hol_norm_decimal($_POST['co2_kg'] ?? null), + ':link' => trim($_POST['link'] ?? ''), + ':notes' => trim($_POST['notes'] ?? ''), + ]); + hol_redirect($id); + break; + } + + case 'add_lodging': { + $id = (int)$_POST['idea_id']; + $stmt = $pdo->prepare(" + INSERT INTO pf_holidays_lodging (idea_id, type, location_text, price_per_n, nights, free_cancel, family_friendly, link, notes) + VALUES (:id,:type,:loc,:ppn,:n,:fc,:ff,:link,:notes) + "); + $stmt->execute([ + ':id' => $id, + ':type' => strtoupper($_POST['type'] ?? 'OTHER'), + ':loc' => trim($_POST['location_text'] ?? ''), + ':ppn' => hol_norm_decimal($_POST['price_per_n'] ?? null), + ':n' => ($_POST['nights'] !== '' ? (int)$_POST['nights'] : null), + ':fc' => isset($_POST['free_cancel']) ? 1 : 0, + ':ff' => isset($_POST['family_friendly']) ? 1 : 0, + ':link' => trim($_POST['link'] ?? ''), + ':notes'=> trim($_POST['notes'] ?? ''), + ]); + hol_redirect($id); + break; + } + + case 'add_activity': { + $id = (int)$_POST['idea_id']; + $stmt = $pdo->prepare(" + INSERT INTO pf_holidays_activities (idea_id, name, kind, cost_est, need_booking, weather, link, notes) + VALUES (:id,:name,:kind,:cost,:need,:weather,:link,:notes) + "); + $stmt->execute([ + ':id' => $id, + ':name' => trim($_POST['name'] ?? ''), + ':kind' => trim($_POST['kind'] ?? ''), + ':cost' => hol_norm_decimal($_POST['cost_est'] ?? null), + ':need' => isset($_POST['need_booking']) ? 1 : 0, + ':weather'=> strtoupper($_POST['weather'] ?? 'ANY'), + ':link' => trim($_POST['link'] ?? ''), + ':notes' => trim($_POST['notes'] ?? ''), + ]); + hol_redirect($id); + break; + } + + case 'add_budget': { + $id = (int)$_POST['idea_id']; + $stmt = $pdo->prepare(" + INSERT INTO pf_holidays_budget_items (idea_id, category, label, amount, per_person) + VALUES (:id,:cat,:label,:amt,:pp) + "); + $stmt->execute([ + ':id' => $id, + ':cat' => strtoupper($_POST['category'] ?? 'OTHER'), + ':label' => trim($_POST['label'] ?? ''), + ':amt' => hol_norm_decimal($_POST['amount'] ?? null) ?? 0.0, + ':pp' => isset($_POST['per_person']) ? 1 : 0, + ]); + hol_redirect($id); + break; + } + + default: + http_response_code(400); + echo 'Unknown action'; + exit; } - case 'update_idea': { - $status = $_POST['status'] ?? 'draft'; - $start = hol_norm_date($_POST['desired_start_date'] ?? null); - $end = hol_norm_date($_POST['desired_end_date'] ?? null); - if (!empty($start) && $status === 'draft') { $status = 'planned'; } - - $latVal = hol_norm_decimal($_POST['lat'] ?? null); - $lngVal = hol_norm_decimal($_POST['lng'] ?? null); - - $stmt = $pdo->prepare(" - UPDATE pf_holidays_ideas - SET title=:title, country=:country, region=:region, city=:city, lat=:lat, lng=:lng, - desired_start_date=:start, desired_end_date=:end, - season_hint=:season, ideal_days=:days, status=:status, notes=:notes - WHERE id=:id - "); - $stmt->execute([ - ':id' => (int)$_POST['id'], - ':title' => trim($_POST['title'] ?? ''), - ':country'=> trim($_POST['country'] ?? ''), - ':region' => trim($_POST['region'] ?? ''), - ':city' => trim($_POST['city'] ?? ''), - ':lat' => $latVal, - ':lng' => $lngVal, - ':start' => $start, - ':end' => $end, - ':season' => trim($_POST['season_hint'] ?? ''), - ':days' => ($_POST['ideal_days'] !== '' ? (int)$_POST['ideal_days'] : null), - ':status' => $status, - ':notes' => trim($_POST['notes'] ?? ''), - ]); - hol_back(); - } - - case 'delete_idea': { - $stmt = $pdo->prepare("DELETE FROM pf_holidays_ideas WHERE id = :id"); - $stmt->execute([':id' => (int)$_POST['id']]); - header("Location: /holidays.php"); - exit; - } - - case 'add_transport': { - $stmt = $pdo->prepare(" - INSERT INTO pf_holidays_transport (idea_id, mode, duration_min, cost, co2_kg, link, notes) - VALUES (:id,:mode,:dur,:cost,:co2,:link,:notes) - "); - $stmt->execute([ - ':id' => (int)$_POST['idea_id'], - ':mode' => strtoupper($_POST['mode'] ?? 'OTHER'), - ':dur' => ($_POST['duration_min'] !== '' ? (int)$_POST['duration_min'] : null), - ':cost' => hol_norm_decimal($_POST['cost'] ?? null), - ':co2' => hol_norm_decimal($_POST['co2_kg'] ?? null), - ':link' => trim($_POST['link'] ?? ''), - ':notes'=> trim($_POST['notes'] ?? ''), - ]); - hol_back(); - } - - case 'add_lodging': { - $stmt = $pdo->prepare(" - INSERT INTO pf_holidays_lodging (idea_id, type, location_text, price_per_n, nights, free_cancel, family_friendly, link, notes) - VALUES (:id,:type,:loc,:ppn,:n,:fc,:ff,:link,:notes) - "); - $stmt->execute([ - ':id' => (int)$_POST['idea_id'], - ':type' => strtoupper($_POST['type'] ?? 'OTHER'), - ':loc' => trim($_POST['location_text'] ?? ''), - ':ppn' => hol_norm_decimal($_POST['price_per_n'] ?? null), - ':n' => ($_POST['nights'] !== '' ? (int)$_POST['nights'] : null), - ':fc' => isset($_POST['free_cancel']) ? 1 : 0, - ':ff' => isset($_POST['family_friendly']) ? 1 : 0, - ':link' => trim($_POST['link'] ?? ''), - ':notes'=> trim($_POST['notes'] ?? ''), - ]); - hol_back(); - } - - case 'add_activity': { - $stmt = $pdo->prepare(" - INSERT INTO pf_holidays_activities (idea_id, name, kind, cost_est, need_booking, weather, link, notes) - VALUES (:id,:name,:kind,:cost,:need,:weather,:link,:notes) - "); - $stmt->execute([ - ':id' => (int)$_POST['idea_id'], - ':name' => trim($_POST['name'] ?? ''), - ':kind' => trim($_POST['kind'] ?? ''), - ':cost' => hol_norm_decimal($_POST['cost_est'] ?? null), - ':need' => isset($_POST['need_booking']) ? 1 : 0, - ':weather'=> strtoupper($_POST['weather'] ?? 'ANY'), - ':link' => trim($_POST['link'] ?? ''), - ':notes' => trim($_POST['notes'] ?? ''), - ]); - hol_back(); - } - - case 'add_budget': { - $stmt = $pdo->prepare(" - INSERT INTO pf_holidays_budget_items (idea_id, category, label, amount, per_person) - VALUES (:id,:cat,:label,:amt,:pp) - "); - $stmt->execute([ - ':id' => (int)$_POST['idea_id'], - ':cat' => strtoupper($_POST['category'] ?? 'OTHER'), - ':label' => trim($_POST['label'] ?? ''), - ':amt' => hol_norm_decimal($_POST['amount'] ?? null) ?? 0.0, - ':pp' => isset($_POST['per_person']) ? 1 : 0, - ]); - hol_back(); - } - - default: - http_response_code(400); - echo 'Unknown action'; - exit; - } } catch (Throwable $e) { - http_response_code(500); - echo "Error: " . htmlspecialchars($e->getMessage()); -} + http_response_code(500); + // En production, éviter d'afficher l'erreur brute à l'utilisateur + // Mais utile pour le debug actuel + echo "Erreur lors de l'enregistrement : " . htmlspecialchars($e->getMessage()); + echo '
Retour'; +} \ No newline at end of file diff --git a/modules/holidays/view.php b/modules/holidays/view.php index 5b38031..024aacc 100644 --- a/modules/holidays/view.php +++ b/modules/holidays/view.php @@ -1,4 +1,6 @@ 'bad id']); - exit; + http_response_code(400); + echo json_encode(['error' => 'invalid_id']); + exit; } -$st = $pdo->prepare("SELECT * FROM pf_holidays_ideas WHERE id = ?"); -$st->execute([$id]); -$it = $st->fetch(PDO::FETCH_ASSOC); +try { + $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; -} + if (!$it) { + http_response_code(404); + echo json_encode(['error' => 'not_found']); + exit; + } -echo json_encode($it, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + // Amélioration : Typage explicite pour le JSON + // Cela évite que JS reçoive "4" (string) au lieu de 4 (int) pour les calculs + $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']; + + // On s'assure que les null restent null et pas des chaines vides si la DB est stricte + // (Optionnel mais propre) + + echo json_encode($it, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + +} catch (Throwable $e) { + http_response_code(500); + echo json_encode(['error' => 'server_error']); +} \ No newline at end of file