This commit is contained in:
2026-03-30 12:01:44 +02:00
parent 3f842e091a
commit cdeff93fbd
10 changed files with 894 additions and 318 deletions
+16 -4
View File
@@ -7,15 +7,27 @@ require __DIR__ . '/includes/db.php';
if (session_status() === PHP_SESSION_NONE) { session_start(); } if (session_status() === PHP_SESSION_NONE) { session_start(); }
$pageTitle = "PachaFamily - Idées de vacances"; // 1. On récupère la vue demandée (par défaut 'list')
$tab = $_GET['tab'] ?? 'list';
// 2. Configuration des variables pour le header
$pageTitle = ($tab === 'holiday_detail') ? "PachaFamily - Détail du voyage" : "PachaFamily - Mes Vacances";
$activePage = "holidays"; $activePage = "holidays";
$bodyClass = "pf-holidays"; $bodyClass = "pf-holidays";
// On charge le CSS spécifique au module
$pageCss = "/modules/holidays/holidays.css"; $pageCss = "/modules/holidays/holidays.css";
require __DIR__ . '/header.php'; require __DIR__ . '/header.php';
// Inclusion de la logique et de la vue unifiées // 3. ROUTEUR DU MODULE VACANCES
require __DIR__ . '/modules/holidays/index.php'; if ($tab === 'holiday_detail' && isset($_GET['id'])) {
// Si on demande le détail ET qu'un ID est fourni
require __DIR__ . '/modules/holidays/views/detail.php';
} else {
// Vue par défaut : la liste des cartes
require __DIR__ . '/modules/holidays/views/list.php';
}
// 4. Inclusion du JS global du module (s'applique aux deux vues)
echo '<script src="/modules/holidays/holidays.js"></script>';
require __DIR__ . '/footer.php'; require __DIR__ . '/footer.php';
+279 -38
View File
@@ -1,15 +1,21 @@
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
// Initialisation des écouteurs globaux si besoin // Fermer les modales si on clique en dehors du contenu
window.onclick = function (event) {
const modal = document.getElementById("holidayModal");
if (event.target == modal) {
closeHolidayModal();
}
};
}); });
// --- 1. GESTION DE LA MODALE D'ÉDITION --- // --- 1. GESTION DE LA MODALE D'ÉDITION RAPIDE ---
function openHolidayModal(mode) { function openHolidayModal(mode) {
const modal = document.getElementById("holidayModal"); const modal = document.getElementById("holidayModal");
const form = document.getElementById("holidayForm"); const form = document.getElementById("holidayForm");
const btnDelete = document.getElementById("btn_delete"); const btnDelete = document.getElementById("btn_delete");
// Reset complet // Reset complet pour éviter les résidus d'une carte précédente
form.reset(); form.reset();
document.getElementById("inp_id").value = ""; document.getElementById("inp_id").value = "";
document.getElementById("list_transport").innerHTML = ""; document.getElementById("list_transport").innerHTML = "";
@@ -17,51 +23,46 @@ function openHolidayModal(mode) {
document.getElementById("list_activity").innerHTML = ""; document.getElementById("list_activity").innerHTML = "";
if (mode === "add") { if (mode === "add") {
document.getElementById("modalTitle").innerText = "Nouveau Voyage"; document.getElementById("modalTitle").innerText = "Planifier le voyage";
btnDelete.style.display = "none"; btnDelete.style.display = "none";
} else { } else {
document.getElementById("modalTitle").innerText = "Modifier le voyage"; document.getElementById("modalTitle").innerText = "Modification rapide";
btnDelete.style.display = "block"; btnDelete.style.display = "block";
} }
modal.classList.add("open");
modal.style.display = "flex"; modal.style.display = "flex";
// --- AJOUT : FORCER LE SCROLL EN HAUT --- // Focus sur le champ titre pour une saisie rapide (petit délai pour l'animation d'ouverture)
const content = modal.querySelector(".pf-modal-content"); setTimeout(() => document.getElementById("inp_title").focus(), 100);
if (content) {
content.scrollTop = 0;
}
// Optionnel : Mettre le focus sur le premier champ (Titre)
// setTimeout(() => document.getElementById('inp_title').focus(), 50);
} }
function closeHolidayModal() { function closeHolidayModal() {
const modal = document.getElementById("holidayModal"); document.getElementById("holidayModal").style.display = "none";
modal.classList.remove("open");
modal.style.display = "none";
} }
// Fonction appelée au clic sur une carte (injectée par PHP) // Fonction appelée au clic sur le bouton ✏️ de la carte (injectée par PHP)
function editHoliday(data) { function editHoliday(data) {
openHolidayModal("edit");
const h = data.main; const h = data.main;
// On ouvre la modale en mode édition (ça reset les listes)
openHolidayModal("edit");
// Remplissage des champs principaux // Remplissage des champs principaux
document.getElementById("inp_id").value = h.id; document.getElementById("inp_id").value = h.id;
document.getElementById("inp_title").value = h.title; document.getElementById("inp_title").value = h.title;
document.getElementById("inp_status").value = h.status; document.getElementById("inp_status").value = h.status;
document.getElementById("inp_period").value = h.period_hint; document.getElementById("inp_period").value = h.period_hint || "";
document.getElementById("inp_start").value = h.start_date; document.getElementById("inp_start").value = h.start_date || "";
document.getElementById("inp_end").value = h.end_date; document.getElementById("inp_end").value = h.end_date || "";
// Pour éviter d'afficher "0" dans un champ vide, on vérifie si la valeur est > 0
document.getElementById("inp_food").value = document.getElementById("inp_food").value =
h.budget_food > 0 ? h.budget_food : ""; h.budget_food > 0 ? h.budget_food : "";
document.getElementById("inp_extra").value = document.getElementById("inp_extra").value =
h.budget_extra > 0 ? h.budget_extra : ""; h.budget_extra > 0 ? h.budget_extra : "";
document.getElementById("inp_notes").value = h.notes; document.getElementById("inp_notes").value = h.notes || "";
// Remplissage des listes dynamiques // Remplissage des listes dynamiques (Transport, Hébergement, Activité)
if (data.items && data.items.length > 0) { if (data.items && data.items.length > 0) {
data.items.forEach((item) => { data.items.forEach((item) => {
addItem(item.category, item.name, item.amount, item.is_paid); addItem(item.category, item.name, item.amount, item.is_paid);
@@ -69,36 +70,40 @@ function editHoliday(data) {
} }
} }
// --- 2. GESTION DES LISTES DYNAMIQUES (Transport, etc.) --- // --- 2. GESTION DES LISTES DYNAMIQUES DANS LA MODALE ---
function addItem(category, name = "", amount = "", isPaid = 0) { function addItem(category, name = "", amount = "", isPaid = 0) {
const container = document.getElementById("list_" + category); const container = document.getElementById("list_" + category);
const div = document.createElement("div"); const div = document.createElement("div");
// Utilisation des classes CSS définies précédemment // Style en ligne pour s'assurer que ça reste propre sans dépendre de classes externes complexes
div.className = "savings-line-item"; div.style.display = "flex";
div.style.gap = "8px";
div.style.alignItems = "center";
div.style.marginBottom = "10px";
// Checkbox logique pour "Payé" // Astuce pour lier la checkbox visuelle à l'input caché (valeur 0 ou 1 pour MySQL)
const checkedAttr = isPaid == 1 ? "checked" : ""; const checkedAttr = isPaid == 1 ? "checked" : "";
div.innerHTML = ` div.innerHTML = `
<input type="hidden" name="items[cat][]" value="${category}"> <input type="hidden" name="items[cat][]" value="${category}">
<input type="text" name="items[name][]" class="pf-input" <input type="text" name="items[name][]" class="pf-input"
placeholder="Nom (ex: Vol)" value="${name}" placeholder="Intitulé" value="${name}"
style="flex: 2; min-width: 0;"> style="flex: 2; padding: 8px; font-size:0.9rem;" required>
<input type="number" step="0.01" name="items[amount][]" class="pf-input" <input type="number" step="0.01" name="items[amount][]" class="pf-input"
placeholder="" value="${amount}" placeholder="Prix (€)" value="${amount}"
style="width: 80px; text-align: right;"> style="width: 80px; text-align: right; padding: 8px; font-size:0.9rem;">
<label title="Déjà payé ?" style="display: flex; align-items: center; cursor: pointer; padding: 0 5px;"> <label title="Déjà payé ?" style="display: flex; align-items: center; cursor: pointer; padding: 0 5px;">
<input type="checkbox" ${checkedAttr} onchange="this.nextElementSibling.value = this.checked ? 1 : 0"> <input type="checkbox" ${checkedAttr} onchange="this.nextElementSibling.value = this.checked ? 1 : 0" style="margin:0;">
<input type="hidden" name="items[paid][]" value="${isPaid}"> <input type="hidden" name="items[paid][]" value="${isPaid}">
<span style="font-size:0.8rem; margin-left:4px;">Payé</span> <span style="font-size:0.75rem; margin-left:4px; font-weight:bold; color:#64748b;">Payé</span>
</label> </label>
<button type="button" class="btn-remove" onclick="this.parentElement.remove()" title="Supprimer"> <button type="button" onclick="this.parentElement.remove()" title="Retirer cette ligne"
style="width: 28px; height: 28px; border: none; background: #fee2e2; color: #ef4444; border-radius: 6px; cursor: pointer; font-weight: bold; display:flex; align-items:center; justify-content:center;">
&times; &times;
</button> </button>
`; `;
@@ -107,11 +112,16 @@ function addItem(category, name = "", amount = "", isPaid = 0) {
} }
function deleteHoliday() { function deleteHoliday() {
if (!confirm("Supprimer définitivement ce voyage ?")) return; if (
!confirm(
"Voulez-vous vraiment supprimer définitivement ce voyage ? Cette action est irréversible.",
)
)
return;
const form = document.getElementById("holidayForm"); const form = document.getElementById("holidayForm");
// On ajoute un input caché pour signaler la suppression au PHP // On injecte un input caché pour signaler au PHP que c'est une demande de suppression
const input = document.createElement("input"); const input = document.createElement("input");
input.type = "hidden"; input.type = "hidden";
input.name = "action_delete"; input.name = "action_delete";
@@ -121,7 +131,7 @@ function deleteHoliday() {
form.submit(); form.submit();
} }
// --- 3. GESTION DE LA CARTE (Leaflet) --- // --- 3. GESTION DE LA CARTE (Leaflet - Optionnel selon si tu l'utilises ou non) ---
let map = null; let map = null;
@@ -172,3 +182,234 @@ function initMap() {
}); });
} }
} }
// ============================================================================
// 4. GESTION DE LA CARTE DÉTAILLÉE (ROADTRIP) ET GÉOCODAGE
// ============================================================================
let detailMap = null;
document.addEventListener("DOMContentLoaded", () => {
// Si on est sur la page détail et que la div "tripMap" existe, on initie la carte
if (document.getElementById("tripMap")) {
initDetailMap();
}
});
function initDetailMap() {
if (typeof L === "undefined" || typeof MAP_POINTS === "undefined") return;
detailMap = L.map("tripMap");
// Fond de carte propre (CartoDB Voyager)
L.tileLayer(
"https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png",
{
attribution: "© OpenStreetMap",
},
).addTo(detailMap);
if (MAP_POINTS.length === 0) {
detailMap.setView([46.6, 2.4], 5); // Centré sur la France par défaut si vide
return;
}
const latlngs = [];
const bounds = L.latLngBounds();
MAP_POINTS.forEach((pt, index) => {
const pos = [pt.lat, pt.lng];
latlngs.push(pos);
bounds.extend(pos);
// Couleur selon le paiement
const color = pt.paid == 1 ? "#10b981" : "#f59e0b";
// On place un petit cercle pour chaque point
const marker = L.circleMarker(pos, {
color: color,
radius: 7,
fillOpacity: 1,
fillColor: "white",
weight: 3,
}).addTo(detailMap);
// Numérotation et Popup
marker.bindPopup(`
<div style="text-align:center;">
<div style="font-size:0.7rem; color:#64748b; margin-bottom:2px;">Étape ${index + 1}</div>
<strong>${pt.title}</strong><br>
<span style="font-weight:bold; color:${color};">${parseFloat(pt.amount).toFixed(2)} €</span>
</div>
`);
});
// On dessine le trait en pointillés pour relier le roadtrip !
if (latlngs.length > 1) {
L.polyline(latlngs, {
color: "#3b82f6",
weight: 3,
dashArray: "8, 8",
opacity: 0.7,
}).addTo(detailMap);
}
// On zoome automatiquement pour voir tous les points
detailMap.fitBounds(bounds, { padding: [50, 50] });
}
// Fonction appelée quand on clique sur "👁️ Voir sur la carte" dans la liste
function panMapTo(lat, lng) {
if (detailMap) {
detailMap.setView([lat, lng], 14, { animate: true });
}
}
// --- LOGIQUE DE LA MODALE CHECKPOINT (Lignes multiples) ---
function openCheckpointModal(mode, data = null) {
const searchBlock = document.getElementById("cpSearchBlock");
const formBlock = document.getElementById("formCheckpoint");
const container = document.getElementById("cpExpensesContainer");
const btnDel = document.getElementById("btnDeleteCp");
container.innerHTML = ""; // Reset des lignes
if (mode === "add") {
document.getElementById("cpModalTitle").innerText =
"📍 Placer une nouvelle étape";
searchBlock.style.display = "block";
formBlock.style.display = "none";
btnDel.style.display = "none";
document.getElementById("searchPlaceInput").value = "";
document.getElementById("searchResults").innerHTML = "";
document.getElementById("cp_old_name").value = "";
document.getElementById("cp_name").value = "";
// Ajout d'une ligne vide par défaut
addCpExpenseLine();
} else if (mode === "edit" && data) {
document.getElementById("cpModalTitle").innerText = "✏️ Modifier l'étape";
searchBlock.style.display = "none"; // On cache la recherche en mode édition
formBlock.style.display = "block";
btnDel.style.display = "block";
document.getElementById("cp_lat").value = data.lat;
document.getElementById("cp_lng").value = data.lng;
document.getElementById("cp_old_name").value = data.location_name;
document.getElementById("cp_name").value = data.location_name;
// Remplissage des lignes existantes (en filtrant le point technique)
if (data.items && data.items.length > 0) {
let visibleCount = 0;
data.items.forEach((it) => {
if (it.name !== "PF_TECHNICAL_POINT") {
addCpExpenseLine(it.category, it.name, it.amount, it.is_paid);
visibleCount++;
}
});
// Si l'étape ne contenait QUE le point technique, on affiche une ligne vide pour inviter à la saisie
if (visibleCount === 0) {
addCpExpenseLine();
}
} else {
addCpExpenseLine(); // Sécurité
}
}
document.getElementById("checkpointModal").style.display = "flex";
}
function searchPlace() {
const q = document.getElementById("searchPlaceInput").value.trim();
if (q.length < 3) return;
const resultsDiv = document.getElementById("searchResults");
resultsDiv.innerHTML =
'<span style="color:#64748b; font-size:0.85rem;">Recherche en cours... ⏳</span>';
fetch(
"/modules/holidays/includes/api/geocode.php?limit=5&q=" +
encodeURIComponent(q),
)
.then((res) => res.json())
.then((data) => {
resultsDiv.innerHTML = "";
if (data.error || !data.results || data.results.length === 0) {
resultsDiv.innerHTML =
'<span style="color:#ef4444; font-size:0.85rem;">Aucun résultat trouvé.</span>';
return;
}
data.results.forEach((place) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "pf-btn btn-secondary";
btn.style.textAlign = "left";
btn.style.padding = "8px";
btn.style.height = "auto";
btn.innerText = "📍 " + place.display_name;
btn.onclick = () =>
selectPlace(place.lat, place.lng, place.display_name);
resultsDiv.appendChild(btn);
});
})
.catch((err) => {
resultsDiv.innerHTML =
'<span style="color:#ef4444; font-size:0.85rem;">Erreur réseau.</span>';
});
}
function selectPlace(lat, lng, fullName) {
document.getElementById("cp_lat").value = lat;
document.getElementById("cp_lng").value = lng;
// On nettoie le nom pour que ce soit joli (ex: "Paris, France" -> "Paris")
document.getElementById("cp_name").value = fullName.split(",")[0].trim();
document.getElementById("cpSearchBlock").style.display = "none";
document.getElementById("formCheckpoint").style.display = "block";
}
function addCpExpenseLine(
category = "accommodation",
name = "",
amount = "",
isPaid = 0,
) {
const container = document.getElementById("cpExpensesContainer");
const div = document.createElement("div");
div.style.display = "flex";
div.style.gap = "8px";
div.style.alignItems = "center";
const isChecked = isPaid == 1 ? "checked" : "";
div.innerHTML = `
<select name="items[cat][]" class="pf-input" style="width:50px; padding:8px 4px; font-size:1.2rem; cursor:pointer;" title="Catégorie">
<option value="accommodation" ${category === "accommodation" ? "selected" : ""}>🏨</option>
<option value="transport" ${category === "transport" ? "selected" : ""}>🚗</option>
<option value="activity" ${category === "activity" ? "selected" : ""}>🎫</option>
</select>
<input type="text" name="items[name][]" class="pf-input" placeholder="Libellé (Optionnel)" value="${name}" style="flex:2; padding:8px; font-size:0.9rem;">
<input type="number" step="0.01" name="items[amount][]" class="pf-input" placeholder="0.00" value="${amount}" style="width:80px; text-align:right; padding:8px; font-size:0.9rem;">
<label title="Payé ?" style="display:flex; align-items:center; cursor:pointer;">
<input type="checkbox" ${isChecked} onchange="this.nextElementSibling.value = this.checked ? 1 : 0" style="margin:0;">
<input type="hidden" name="items[paid][]" value="${isPaid}">
<span style="font-size:0.75rem; margin-left:2px; font-weight:bold; color:#64748b;">Payé</span>
</label>
<button type="button" onclick="this.parentElement.remove()" style="width:28px; height:28px; border:none; background:#fee2e2; color:#ef4444; border-radius:6px; cursor:pointer; font-weight:bold; display:flex; justify-content:center; align-items:center;">&times;</button>
`;
container.appendChild(div);
}
function deleteCheckpoint() {
if (!confirm("Supprimer cette étape et toutes les dépenses associées ?"))
return;
const form = document.getElementById("formCheckpoint");
const input = document.createElement("input");
input.type = "hidden";
input.name = "action_delete";
input.value = "1";
form.appendChild(input);
form.submit();
}
@@ -1,8 +1,9 @@
<?php <?php
// modules/holidays/geocode.php // modules/holidays/includes/api/geocode.php
require __DIR__ . '/../../includes/auth.php';
require_login('/login.php'); require dirname(__DIR__, 4) . '/includes/auth.php';
require __DIR__ . '/../../includes/db.php'; require dirname(__DIR__, 4) . '/includes/db.php';
require_login();
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
@@ -17,13 +18,13 @@ if ($q === '') {
// Borner la limite // Borner la limite
$limit = (int)($_GET['limit'] ?? 1); $limit = (int)($_GET['limit'] ?? 1);
if ($limit < 1) $limit = 1; if ($limit < 1) $limit = 1;
if ($limit > 10) $limit = 10; // Nominatim bloque souvent au-dessus de 10-50 if ($limit > 10) $limit = 10;
// 2. Normalisation pour le cache // 2. Normalisation pour le cache
$qNorm = mb_strtolower($q); $qNorm = mb_strtolower($q);
$qHash = hash('sha256', $qNorm); $qHash = hash('sha256', $qNorm);
// 3. Création automatique de la table cache si elle n'existe pas (Sécurité) // 3. Création automatique de la table cache
try { try {
$pdo->exec(" $pdo->exec("
CREATE TABLE IF NOT EXISTS pf_geocode_cache ( CREATE TABLE IF NOT EXISTS pf_geocode_cache (
@@ -35,13 +36,9 @@ try {
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
"); ");
} catch (Throwable $e) { } 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) // 4. Vérification du cache
// 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 { try {
$st = $pdo->prepare("SELECT lat, lng, display_name FROM pf_geocode_cache WHERE q_hash = ?"); $st = $pdo->prepare("SELECT lat, lng, display_name FROM pf_geocode_cache WHERE q_hash = ?");
$st->execute([$qHash]); $st->execute([$qHash]);
@@ -54,11 +51,9 @@ try {
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit; exit;
} }
} catch (Throwable $e) { } catch (Throwable $e) {}
// Erreur SQL silencieuse sur le cache
}
// 5. Appel Nominatim (Si pas en cache) // 5. Appel Nominatim
$endpoint = 'https://nominatim.openstreetmap.org/search'; $endpoint = 'https://nominatim.openstreetmap.org/search';
$params = http_build_query([ $params = http_build_query([
'format' => 'jsonv2', 'format' => 'jsonv2',
@@ -75,8 +70,6 @@ curl_setopt_array($ch, [
CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 10, CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => [ CURLOPT_HTTPHEADER => [
// Ton User-Agent est correct.
// Important : Nominatim demande une identification claire.
'User-Agent: PachaFamily-Holidays/1.0 (+contact: ferlan.alexandre@gmail.com)' 'User-Agent: PachaFamily-Holidays/1.0 (+contact: ferlan.alexandre@gmail.com)'
], ],
]); ]);
@@ -85,9 +78,8 @@ $http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch); $err = curl_error($ch);
curl_close($ch); curl_close($ch);
// Gestion erreur cURL / HTTP
if ($body === false || $http !== 200) { if ($body === false || $http !== 200) {
http_response_code(502); // Bad Gateway http_response_code(502);
echo json_encode([ echo json_encode([
'error' => 'geocode_failed', 'error' => 'geocode_failed',
'details' => $err ?: ('HTTP ' . $http) 'details' => $err ?: ('HTTP ' . $http)
@@ -97,17 +89,13 @@ if ($body === false || $http !== 200) {
$data = json_decode($body, true); $data = json_decode($body, true);
// Gestion erreur JSON ou vide
if (!is_array($data) || empty($data)) { if (!is_array($data) || empty($data)) {
// 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); http_response_code(404);
echo json_encode(['error' => 'not_found']); echo json_encode(['error' => 'not_found']);
exit; exit;
} }
// 6. Mise en cache du PREMIER résultat (Le "meilleur") // 6. Mise en cache
// On ne cache que le top result pour simplifier la structure de la DB.
if (isset($data[0])) { if (isset($data[0])) {
$r = $data[0]; $r = $data[0];
$lat = round((float)$r['lat'], 6); $lat = round((float)$r['lat'], 6);
@@ -124,9 +112,7 @@ if (isset($data[0])) {
} catch (Throwable $e) {} } catch (Throwable $e) {}
} }
// 7. Retour des résultats // 7. Retour
// Si limit=1, on renvoie format plat (pour compatibilité stricte)
// Si limit>1, on renvoie format liste
if ($limit === 1) { if ($limit === 1) {
$r = $data[0]; $r = $data[0];
echo json_encode([ echo json_encode([
@@ -1,9 +1,9 @@
<?php <?php
// modules/holidays/view.php // modules/holidays/includes/api/get_holiday_data.php
require __DIR__ . '/../../includes/auth.php'; require dirname(__DIR__, 4) . '/includes/auth.php';
require_login('/login.php'); require dirname(__DIR__, 4) . '/includes/db.php';
require __DIR__ . '/../../includes/db.php'; require_login();
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
@@ -16,6 +16,7 @@ if ($id <= 0) {
} }
try { try {
// Si tu utilises pf_holidays au lieu de pf_holidays_ideas, change le nom de la table ici !
$st = $pdo->prepare("SELECT * FROM pf_holidays_ideas WHERE id = ?"); $st = $pdo->prepare("SELECT * FROM pf_holidays_ideas WHERE id = ?");
$st->execute([$id]); $st->execute([$id]);
$it = $st->fetch(PDO::FETCH_ASSOC); $it = $st->fetch(PDO::FETCH_ASSOC);
@@ -26,17 +27,12 @@ try {
exit; exit;
} }
// 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']; $it['id'] = (int)$it['id'];
if (isset($it['lat'])) $it['lat'] = (float)$it['lat']; if (isset($it['lat'])) $it['lat'] = (float)$it['lat'];
if (isset($it['lng'])) $it['lng'] = (float)$it['lng']; if (isset($it['lng'])) $it['lng'] = (float)$it['lng'];
if (isset($it['ideal_days'])) $it['ideal_days'] = (int)$it['ideal_days']; 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); echo json_encode($it, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) { } catch (Throwable $e) {
@@ -0,0 +1,81 @@
<?php
// modules/holidays/includes/api/save_checkpoint.php
require dirname(__DIR__, 4) . '/includes/auth.php';
require dirname(__DIR__, 4) . '/includes/db.php';
require_login();
$holiday_id = (int)$_POST['holiday_id'];
// Suppression complète d'une étape
if (isset($_POST['action_delete']) && $_POST['action_delete'] === '1') {
$loc = $_POST['old_location_name'];
$pdo->prepare("DELETE FROM pf_holidays_items WHERE holiday_id = ? AND location_name = ?")->execute([$holiday_id, $loc]);
header("Location: /holidays.php?tab=holiday_detail&id=" . $holiday_id);
exit;
}
// Ajout / Modification d'une étape
$location_name = trim($_POST['location_name']);
$old_location = trim($_POST['old_location_name'] ?? '');
$lat = (float)$_POST['lat'];
$lng = (float)$_POST['lng'];
if ($holiday_id > 0 && !empty($location_name)) {
try {
$pdo->beginTransaction();
// 1. GESTION DES FAVORIS
if (isset($_POST['save_favorite']) && $_POST['save_favorite'] == '1') {
$stmtFav = $pdo->query("SELECT content FROM pf_notes WHERE note_type = 'holiday_favorites'");
$favs = json_decode($stmtFav->fetchColumn() ?: '[]', true);
$exists = false;
foreach ($favs as $f) { if ($f['name'] === $location_name) $exists = true; }
if (!$exists) {
$favs[] = ['name' => $location_name, 'lat' => $lat, 'lng' => $lng];
$pdo->prepare("INSERT INTO pf_notes (note_type, reference_id, content) VALUES ('holiday_favorites', 'GLOBAL', ?) ON DUPLICATE KEY UPDATE content = VALUES(content)")->execute([json_encode($favs)]);
}
}
// 2. GESTION DES DÉPENSES
if (!empty($old_location)) {
$pdo->prepare("DELETE FROM pf_holidays_items WHERE holiday_id = ? AND location_name = ?")->execute([$holiday_id, $old_location]);
}
$stmt = $pdo->prepare("INSERT INTO pf_holidays_items (holiday_id, category, name, amount, is_paid, location_name, lat, lng) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
$validItemsCount = 0;
if (isset($_POST['items']['name'])) {
$count = count($_POST['items']['name']);
for ($i = 0; $i < $count; $i++) {
$name = trim($_POST['items']['name'][$i]);
$amount_raw = $_POST['items']['amount'][$i];
// Si la ligne n'est pas totalement vide
if ($name !== '' || $amount_raw !== '') {
$cat = $_POST['items']['cat'][$i] ?? 'activity';
$amount = (float)$amount_raw;
if ($name === '') $name = 'Dépense liée';
$paid = isset($_POST['items']['paid'][$i]) ? 1 : 0;
$stmt->execute([$holiday_id, $cat, $name, $amount, $paid, $location_name, $lat, $lng]);
$validItemsCount++;
}
}
}
// 3. ÉTAPE SANS DÉPENSE (Point de passage)
// Si l'utilisateur n'a saisi aucune dépense, on crée une ligne technique invisible pour forcer l'affichage du point GPS
if ($validItemsCount === 0) {
$stmt->execute([$holiday_id, 'activity', 'PF_TECHNICAL_POINT', 0, 1, $location_name, $lat, $lng]);
}
$pdo->commit();
} catch (Exception $e) {
$pdo->rollBack();
die("Erreur de sauvegarde : " . $e->getMessage());
}
}
header("Location: /holidays.php?tab=holiday_detail&id=" . $holiday_id);
exit;
@@ -1,7 +1,10 @@
<?php <?php
require __DIR__ . '/../../includes/auth.php'; // modules/holidays/includes/api/save_holiday.php
require __DIR__ . '/../../includes/db.php';
require_login(); // On remonte de 4 niveaux pour atteindre la racine (api -> includes -> holidays -> modules -> racine)
require dirname(__DIR__, 4) . '/includes/auth.php';
require dirname(__DIR__, 4) . '/includes/db.php';
require_login(); // Si cette fonction nécessite une redirection, gère-la dans auth.php
if (isset($_POST['action_delete']) && $_POST['action_delete'] == '1') { if (isset($_POST['action_delete']) && $_POST['action_delete'] == '1') {
$stmt = $pdo->prepare("DELETE FROM pf_holidays WHERE id = ?"); $stmt = $pdo->prepare("DELETE FROM pf_holidays WHERE id = ?");
@@ -36,18 +39,19 @@ try {
$id = $pdo->lastInsertId(); $id = $pdo->lastInsertId();
} }
// GESTION DES ITEMS (On supprime tout et on recrée) // GESTION DES ITEMS GLOBAUX (On ne supprime QUE les items qui n'ont pas de lieu défini !)
$pdo->prepare("DELETE FROM pf_holidays_items WHERE holiday_id = ?")->execute([$id]); $pdo->prepare("DELETE FROM pf_holidays_items WHERE holiday_id = ? AND location_name IS NULL")->execute([$id]);
if (!empty($_POST['items']['name'])) { if (!empty($_POST['items']['name'])) {
$stmtItem = $pdo->prepare("INSERT INTO pf_holidays_items (holiday_id, category, name, amount, is_paid) VALUES (?, ?, ?, ?, ?)"); $stmtItem = $pdo->prepare("INSERT INTO pf_holidays_items (holiday_id, category, name, amount, is_paid) VALUES (?, ?, ?, ?, ?)");
$count = count($_POST['items']['name']); $count = count($_POST['items']['name']);
for ($i = 0; $i < $count; $i++) { for ($i = 0; $i < $count; $i++) {
$cat = $_POST['items']['cat'][$i]; // Utilisation de "?? ''" pour sécuriser si la donnée n'est pas envoyée
$name = trim($_POST['items']['name'][$i]); $cat = $_POST['items']['cat'][$i] ?? '';
$amount = floatval($_POST['items']['amount'][$i]); $name = trim($_POST['items']['name'][$i] ?? '');
$paid = $_POST['items']['paid'][$i]; $amount = floatval($_POST['items']['amount'][$i] ?? 0);
$paid = isset($_POST['items']['paid'][$i]) ? $_POST['items']['paid'][$i] : 0;
if (!empty($name)) { if (!empty($name)) {
$stmtItem->execute([$id, $cat, $name, $amount, $paid]); $stmtItem->execute([$id, $cat, $name, $amount, $paid]);
@@ -59,8 +63,9 @@ try {
} catch (Exception $e) { } catch (Exception $e) {
$pdo->rollBack(); $pdo->rollBack();
die("Erreur : " . $e->getMessage()); die("Erreur base de données : " . $e->getMessage());
} }
// Redirection vers la page principale
header("Location: /holidays.php"); header("Location: /holidays.php");
exit; exit;
-229
View File
@@ -1,229 +0,0 @@
<?php
// modules/holidays/index.php
// 1. Récupération des voyages + Calculs (Coût Total ET Montant déjà financé)
// 1. Récupération des voyages + Calculs (Coût Total ET Montant déjà financé)
$sql = "
SELECT h.*,
(
COALESCE(h.budget_food, 0) +
COALESCE(h.budget_extra, 0) +
COALESCE((SELECT SUM(amount) FROM pf_holidays_items WHERE holiday_id = h.id), 0)
) as total_cost,
(
COALESCE((SELECT SUM(ABS(amount)) FROM pf_expenses WHERE holiday_id = h.id), 0) +
COALESCE((SELECT SUM(amount) FROM pf_savings WHERE holiday_id = h.id), 0)
) as total_funded
FROM pf_holidays h
ORDER BY
-- COALESCE permet de rejeter les voyages sans date tout à la fin de la liste (ex: l'an 2999)
COALESCE(start_date, '2999-12-31') ASC,
FIELD(status, 'booked', 'planned', 'draft', 'passed', 'archived')
";
$holidays = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
// Tri par statut
$active = array_filter($holidays, fn($h) => in_array($h['status'], ['draft', 'planned', 'booked']));
$history = array_filter($holidays, fn($h) => in_array($h['status'], ['passed', 'archived']));
?>
<div class="pf-holidays">
<div class="pf-holidays__titlebar">
<h1>Mes Vacances ✈️</h1>
<div class="hol-title-actions">
<button class="hol-add-btn" onclick="openHolidayModal('add')">+ Créer un voyage</button>
</div>
</div>
<section class="pf-section">
<div class="hol-ideas-grid">
<?php if (empty($active)): ?>
<p style="color:var(--text-muted); font-style:italic;">Aucun voyage en cours. Planifions quelque chose !</p>
<?php endif; ?>
<?php foreach ($active as $h): ?>
<?php renderHolidayCard($h, $pdo); ?>
<?php endforeach; ?>
</div>
</section>
<?php if (!empty($history)): ?>
<section class="pf-section" style="margin-top: 40px; border-top: 1px solid #e2e8f0; padding-top: 20px;">
<h3 style="color:var(--text-muted);">Historique</h3>
<div class="hol-ideas-grid" style="opacity: 0.7;">
<?php foreach ($history as $h): ?>
<?php renderHolidayCard($h, $pdo); ?>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
</div>
<div id="holidayModal" class="pf-modal">
<div class="pf-modal-content" style="max-width: 800px; width: 95%;">
<h3 id="modalTitle" class="pf-modal-title">Planifier le voyage</h3>
<form action="/modules/holidays/save_new.php" method="POST" id="holidayForm">
<input type="hidden" name="id" id="inp_id">
<input type="hidden" name="action" value="save">
<div class="form-row">
<div style="flex: 2;">
<label class="pf-label">Nom du voyage</label>
<input type="text" name="title" id="inp_title" class="pf-input" placeholder="Ex: Octobre - Portugal" required>
</div>
<div style="flex: 1;">
<label class="pf-label">Statut</label>
<select name="status" id="inp_status" class="pf-input">
<option value="draft">Brouillon ✏️</option>
<option value="planned">Planifié 📅</option>
<option value="booked">Réservé ✅</option>
<option value="passed">Passé 👋</option>
<option value="archived">Archivé 🗄️</option>
</select>
</div>
</div>
<div class="form-row">
<div>
<label class="pf-label">Période (Texte libre)</label>
<input type="text" name="period_hint" id="inp_period" class="pf-input" placeholder="Ex: Octobre 2026">
</div>
<div style="display:flex; gap:10px;">
<div style="flex:1">
<label class="pf-label">Du</label>
<input type="date" name="start_date" id="inp_start" class="pf-input" lang="fr">
</div>
<div style="flex:1">
<label class="pf-label">Au</label>
<input type="date" name="end_date" id="inp_end" class="pf-input" lang="fr">
</div>
</div>
</div>
<hr style="border: 0; border-top: 1px solid #e2e8f0; margin: 20px 0;">
<div class="hol-columns-wrapper">
<div class="hol-col">
<div class="hol-col-header">
<h4 style="color:#2563eb;">🚗 Transport</h4>
<button type="button" class="btn-add-item" onclick="addItem('transport')" title="Ajouter un transport"></button>
</div>
<div id="list_transport" class="dynamic-list"></div>
</div>
<div class="hol-col">
<div class="hol-col-header">
<h4 style="color:#059669;">🏨 Hébergement</h4>
<button type="button" class="btn-add-item" onclick="addItem('accommodation')" title="Ajouter un hébergement"></button>
</div>
<div id="list_accommodation" class="dynamic-list"></div>
</div>
<div class="hol-col">
<div class="hol-col-header">
<h4 style="color:#d97706;">🎫 Activité</h4>
<button type="button" class="btn-add-item" onclick="addItem('activity')" title="Ajouter une activité"></button>
</div>
<div id="list_activity" class="dynamic-list"></div>
</div>
</div>
<hr style="border: 0; border-top: 1px solid #e2e8f0; margin: 20px 0;">
<div class="form-row">
<div>
<label class="pf-label">🍔 Budget Food & Bev (€)</label>
<input type="number" step="0.01" name="budget_food" id="inp_food" class="pf-input" placeholder="0.00">
</div>
<div>
<label class="pf-label">🎁 Budget Extras (€)</label>
<input type="number" step="0.01" name="budget_extra" id="inp_extra" class="pf-input" placeholder="0.00">
</div>
</div>
<div class="form-group">
<label class="pf-label">Notes</label>
<textarea name="notes" id="inp_notes" class="pf-input" rows="2" placeholder="Idées en vrac..."></textarea>
</div>
<div class="modal-footer">
<button type="button" onclick="deleteHoliday()" id="btn_delete" class="pf-btn btn-secondary" style="color:#ef4444; border-color:#fca5a5; margin-right:auto; display:none;">Supprimer</button>
<button type="button" onclick="document.getElementById('holidayModal').style.display='none'" class="pf-btn btn-secondary">Annuler</button>
<button type="submit" class="pf-btn">Enregistrer</button>
</div>
</form>
</div>
</div>
<?php
function renderHolidayCard($h, $pdo) {
$stmt = $pdo->prepare("SELECT * FROM pf_holidays_items WHERE holiday_id = ?");
$stmt->execute([$h['id']]);
$items = $stmt->fetchAll(PDO::FETCH_ASSOC);
$json = htmlspecialchars(json_encode(['main' => $h, 'items' => $items]), ENT_QUOTES, 'UTF-8');
$dateDisplay = htmlspecialchars($h['period_hint'] ?? '');
if (empty($dateDisplay) && $h['start_date']) {
$dateDisplay = date('d/m/Y', strtotime($h['start_date']));
if ($h['end_date']) $dateDisplay .= ' → ' . date('d/m/Y', strtotime($h['end_date']));
}
$statusClass = match($h['status']) {
'booked' => 'bg-green-100 text-green-800',
'planned' => 'bg-blue-100 text-blue-800',
'passed' => 'bg-gray-100 text-gray-600',
default => 'bg-yellow-50 text-yellow-800'
};
// --- NOUVEAU : Calcul de la progression ---
$cost = (float)$h['total_cost'];
$funded = (float)$h['total_funded'];
$leftToPay = max(0, $cost - $funded);
// Pourcentage pour la barre de progression (max 100%)
$percent = $cost > 0 ? min(100, round(($funded / $cost) * 100)) : 0;
// Couleur de la barre : Rouge si < 50%, Jaune si < 100%, Vert si tout est payé
$barColor = $percent === 100 ? '#10b981' : ($percent > 50 ? '#f59e0b' : '#ef4444');
echo "
<div class='hol-idea-card' onclick='editHoliday($json)'>
<div class='hol-idea-card__head'>
<h3>".htmlspecialchars($h['title'])."</h3>
<span style='font-size:0.75rem; padding:2px 8px; border-radius:12px; font-weight:bold;' class='$statusClass'>
".strtoupper($h['status'])."
</span>
</div>
<div class='hol-idea-meta'>
<span>🗓️ ".($dateDisplay ?: 'Dates à définir')."</span>
</div>
<div style='margin-top:auto; padding-top:10px; border-top:1px solid #f1f5f9;'>
<div style='display:flex; justify-content:space-between; align-items:center; margin-bottom:5px;'>
<span style='font-size:0.85rem; color:#64748b;'>Budget Total</span>
<span style='font-size:1rem; font-weight:bold; color:#1e293b;'>".number_format($cost, 0, ',', ' ')." €</span>
</div>
<div style='width:100%; height:6px; background:#e2e8f0; border-radius:3px; margin-bottom:8px; overflow:hidden;'>
<div style='width:{$percent}%; height:100%; background:{$barColor}; transition:width 0.3s ease;'></div>
</div>
<div style='display:flex; justify-content:space-between; align-items:center; font-size:0.8rem;'>
<span style='color:#10b981; font-weight:600;'>✓ Financé : ".number_format($funded, 0, ',', ' ')." €</span>
<span style='color:#ef4444; font-weight:600;'>Reste : ".number_format($leftToPay, 0, ',', ' ')." €</span>
</div>
</div>
</div>
";
}
?>
<script src="/modules/holidays/holidays.js"></script>
+242
View File
@@ -0,0 +1,242 @@
<?php
// modules/holidays/views/detail.php
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
if ($id === 0) { echo "<div class='pf-section'><p>Erreur.</p></div>"; exit; }
$stmt = $pdo->prepare("
SELECT h.*,
(COALESCE(h.budget_food, 0) + COALESCE(h.budget_extra, 0) + COALESCE((SELECT SUM(amount) FROM pf_holidays_items WHERE holiday_id = h.id), 0)) as total_cost,
(SELECT COALESCE(SUM(ABS(amount)), 0) FROM pf_expenses WHERE holiday_id = h.id) as total_paid,
(SELECT COALESCE(SUM(amount), 0) FROM pf_savings WHERE holiday_id = h.id) as total_saved
FROM pf_holidays h WHERE h.id = ?
");
$stmt->execute([$id]);
$holiday = $stmt->fetch(PDO::FETCH_ASSOC);
$stmtItems = $pdo->prepare("SELECT * FROM pf_holidays_items WHERE holiday_id = ? ORDER BY id ASC");
$stmtItems->execute([$id]);
$items = $stmtItems->fetchAll(PDO::FETCH_ASSOC);
// Récupération des favoris géographiques
$stmtFav = $pdo->query("SELECT content FROM pf_notes WHERE note_type = 'holiday_favorites'");
$favorites = json_decode($stmtFav->fetchColumn() ?: '[]', true);
// GROUPEMENT PAR LIEU (Pour la carte et l'affichage)
$steps = [];
$generalItems = [];
foreach ($items as $it) {
if (!empty($it['location_name'])) {
$loc = $it['location_name'];
if (!isset($steps[$loc])) {
$steps[$loc] = [
'location_name' => $loc,
'lat' => (float)$it['lat'],
'lng' => (float)$it['lng'],
'total_amount' => 0,
'items' => []
];
}
$steps[$loc]['items'][] = $it;
$steps[$loc]['total_amount'] += (float)$it['amount'];
} else {
$generalItems[] = $it;
}
}
$mapPoints = array_values($steps);
// Calculs d'affichage (Dates & Finances)
$dateDisplay = htmlspecialchars($holiday['period_hint'] ?? '');
if (empty($dateDisplay) && $holiday['start_date']) {
$dateDisplay = date('d/m/Y', strtotime($holiday['start_date']));
if ($holiday['end_date']) $dateDisplay .= ' → ' . date('d/m/Y', strtotime($holiday['end_date']));
}
$cost = (float)$holiday['total_cost'];
$paid = (float)$holiday['total_paid'];
$saved = (float)$holiday['total_saved'];
$leftToPay = max(0, $cost - $paid);
$pctPaid = $cost > 0 ? min(100, ($paid / $cost) * 100) : 0;
$pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
?>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<div class="pf-holidays-detail">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
<div style="display:flex; align-items:center; gap:15px;">
<a href="?tab=list" class="pf-btn btn-secondary" style="padding:6px 12px; height:auto; width:auto; text-decoration:none;">◀ Retour</a>
<h1 style="margin:0; font-size:1.5rem;"><?= htmlspecialchars($holiday['title']) ?></h1>
<span style="font-size:0.75rem; padding:4px 10px; border-radius:12px; font-weight:bold; background:#e0f2fe; color:#0369a1;">
<?= strtoupper($holiday['status']) ?>
</span>
</div>
<button onclick='editHoliday(<?= htmlspecialchars(json_encode(['main' => $holiday, 'items' => $generalItems]), ENT_QUOTES, 'UTF-8') ?>)' class="pf-btn btn-secondary" style="width:auto;">⚙️ Modifier les bases</button>
</div>
<div style="background:white; padding:20px; border-radius:16px; box-shadow:var(--shadow-sm); border:1px solid #e2e8f0; margin-bottom:24px;">
<div style="display:flex; justify-content:space-between; margin-bottom:15px; flex-wrap:wrap; gap:15px;">
<div>
<div style="font-size:0.85rem; color:#64748b;">Période</div>
<div style="font-weight:600; color:#1e293b;"><?= $dateDisplay ?: 'À définir' ?></div>
</div>
<div>
<div style="font-size:0.85rem; color:#64748b;">Budget Food & Extras</div>
<div style="font-weight:600; color:#1e293b;">🍔 <?= number_format($holiday['budget_food'], 0) ?> € | 🎁 <?= number_format($holiday['budget_extra'], 0) ?> €</div>
</div>
<div style="text-align:right;">
<div style="font-size:0.85rem; color:#64748b;">Coût Total Estimé</div>
<div style="font-size:1.4rem; font-weight:bold; color:#0f172a;"><?= number_format($cost, 0, ',', ' ') ?> €</div>
</div>
</div>
<div style="width:100%; height:12px; background:#e2e8f0; border-radius:6px; margin-bottom:10px; display:flex; overflow:hidden;">
<div style="width:<?= $pctPaid ?>%; background:#10b981;" title="Payé"></div>
<div style="width:<?= $pctSaved ?>%; background:#3b82f6;" title="Financé (Provision)"></div>
</div>
<div style="display:flex; justify-content:space-between; font-size:0.85rem;">
<span style="color:#10b981; font-weight:600;">✓ Payé : <?= number_format($paid, 0, ',', ' ') ?> €</span>
<span style="color:#3b82f6; font-weight:600;">💼 Provisionné : <?= number_format($saved, 0, ',', ' ') ?> €</span>
<span style="color:#ef4444; font-weight:700;">⏳ Reste à payer : <?= number_format($leftToPay, 0, ',', ' ') ?> €</span>
</div>
</div>
<div style="display:grid; grid-template-columns: 2fr 1fr; gap:20px; align-items: stretch;">
<div style="background:white; border-radius:16px; box-shadow:var(--shadow-sm); border:1px solid #e2e8f0; overflow:hidden; display:flex; flex-direction:column; height: 600px;">
<div style="padding:15px 20px; border-bottom:1px solid #e2e8f0; display:flex; justify-content:space-between; align-items:center;">
<h3 style="margin:0; font-size:1.1rem;">🗺️ Itinéraire & Checkpoints</h3>
<button class="pf-btn" onclick="openCheckpointModal('add')" style="padding:6px 12px; height:auto; width:auto; font-size:0.85rem;">📍 Placer une étape</button>
</div>
<div id="tripMap" style="flex:1; width:100%; background:#f1f5f9;"></div>
</div>
<div style="background:white; border-radius:16px; box-shadow:var(--shadow-sm); border:1px solid #e2e8f0; display:flex; flex-direction:column; height: 600px;">
<div style="padding:15px 20px; border-bottom:1px solid #e2e8f0; background:#f8fafc; border-radius:16px 16px 0 0;">
<h3 style="margin:0; font-size:1.1rem;">📝 Détail des étapes</h3>
</div>
<div style="padding:15px; overflow-y:auto; flex:1;">
<?php if (empty($steps)): ?>
<p style="color:#94a3b8; font-style:italic; text-align:center; margin-top:40px;">Aucune étape planifiée.</p>
<?php else: ?>
<?php foreach ($steps as $step): ?>
<div style="border:1px solid #e2e8f0; border-radius:8px; margin-bottom:15px; overflow:hidden;">
<div style="background:#f8fafc; padding:10px 15px; display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid #e2e8f0;">
<div>
<strong style="color:#0f172a; font-size:1rem; cursor:pointer;" onclick="panMapTo(<?= $step['lat'] ?>, <?= $step['lng'] ?>)">📍 <?= htmlspecialchars($step['location_name']) ?></strong>
<div style="font-size:0.8rem; color:#64748b;">Total Étape : <?= number_format($step['total_amount'], 2) ?> €</div>
</div>
<button onclick='openCheckpointModal("edit", <?= htmlspecialchars(json_encode($step), ENT_QUOTES, "UTF-8") ?>)' class="btn-icon-small" title="Modifier cette étape" style="background:white; border:1px solid #cbd5e1; border-radius:4px; padding:4px 8px; cursor:pointer;">✏️</button>
</div>
<div style="padding:10px 15px; background:white;">
<?php
$visibleItemsCount = 0;
foreach ($step['items'] as $it):
if ($it['name'] === 'PF_TECHNICAL_POINT') continue; // On cache la ligne technique !
$visibleItemsCount++;
$icon = match($it['category']) { 'transport' => '🚗', 'accommodation' => '🏨', 'activity' => '🎫', default => '🏷️' };
?>
<div style="display:flex; justify-content:space-between; font-size:0.85rem; margin-bottom:5px; border-bottom:1px dashed #f1f5f9; padding-bottom:5px;">
<span style="color:#475569;"><?= $icon ?> <?= htmlspecialchars($it['name']) ?></span>
<span>
<strong style="color:#1e293b;"><?= number_format($it['amount'], 2) ?> €</strong>
<span style="margin-left:5px; color:<?= $it['is_paid'] ? '#10b981' : '#f59e0b' ?>;" title="<?= $it['is_paid'] ? 'Payé' : 'À payer' ?>"><?= $it['is_paid'] ? '✓' : '⏳' ?></span>
</span>
</div>
<?php endforeach; ?>
<?php if ($visibleItemsCount === 0): ?>
<div style="font-size:0.8rem; color:#94a3b8; font-style:italic;">Point de passage (Aucune dépense)</div>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<?php if (!empty($holiday['notes'])): ?>
<div style="padding:15px; border-top:1px solid #e2e8f0; background:#fffbeb;">
<h4 style="margin:0 0 5px 0; font-size:0.85rem; color:#d97706;">Notes du voyage :</h4>
<p style="margin:0; font-size:0.85rem; color:#92400e; white-space:pre-wrap;"><?= htmlspecialchars($holiday['notes']) ?></p>
</div>
<?php endif; ?>
</div>
</div>
</div>
<div id="checkpointModal" class="pf-modal">
<div class="pf-modal-content" style="max-width: 600px; width:95%;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
<h3 id="cpModalTitle" style="margin:0;">📍 Placer une étape</h3>
<button type="button" onclick="document.getElementById('checkpointModal').style.display='none'" style="border:none; background:none; font-size:1.8rem; cursor:pointer;">&times;</button>
</div>
<div id="cpSearchBlock" style="margin-bottom:20px;">
<?php if (!empty($favorites)): ?>
<div style="margin-bottom:15px; display:flex; gap:8px; flex-wrap:wrap;">
<?php foreach($favorites as $fav): ?>
<button type="button" class="pf-btn btn-secondary" style="padding:4px 10px; font-size:0.8rem; height:auto; width:auto; border-radius:20px; background:#f0f9ff; color:#0369a1; border-color:#bae6fd;" onclick="selectPlace(<?= $fav['lat'] ?>, <?= $fav['lng'] ?>, '<?= htmlspecialchars(addslashes($fav['name'])) ?>')">
⭐ <?= htmlspecialchars($fav['name']) ?>
</button>
<?php endforeach; ?>
</div>
<?php endif; ?>
<label class="pf-label">Rechercher un lieu géographique</label>
<div style="display:flex; gap:10px;">
<input type="text" id="searchPlaceInput" class="pf-input" placeholder="Ex: Paris, Ibis Barcelone..." onkeypress="if(event.key === 'Enter') { searchPlace(); return false; }">
<button type="button" class="pf-btn btn-secondary" onclick="searchPlace()" style="width:auto;">🔍</button>
</div>
<div id="searchResults" style="margin-top:10px; max-height:200px; overflow-y:auto; display:flex; flex-direction:column; gap:5px;"></div>
</div>
<form action="/modules/holidays/includes/api/save_checkpoint.php" method="POST" id="formCheckpoint" style="display:none; border-top:1px solid #e2e8f0; padding-top:20px;">
<input type="hidden" name="holiday_id" value="<?= $id ?>">
<input type="hidden" name="old_location_name" id="cp_old_name">
<input type="hidden" name="lat" id="cp_lat">
<input type="hidden" name="lng" id="cp_lng">
<div class="form-group" style="margin-bottom:15px;">
<label class="pf-label">Nom de l'étape (Ce qui s'affichera sur la carte)</label>
<input type="text" name="location_name" id="cp_name" class="pf-input" style="font-weight:bold; color:#2563eb;" required>
</div>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
<label class="pf-label" style="margin:0;">Dépenses prévues à cette étape</label>
<button type="button" class="pf-btn btn-secondary" onclick="addCpExpenseLine()" style="padding:4px 8px; font-size:0.8rem; height:auto; width:auto;">+ Ajouter une dépense</button>
</div>
<div id="cpExpensesContainer" style="margin-bottom:15px; display:flex; flex-direction:column; gap:10px; max-height:300px; overflow-y:auto;">
</div>
<div style="margin-bottom: 20px; padding-left: 5px;">
<label style="display:flex; align-items:center; cursor:pointer; font-size:0.85rem; color:#475569;">
<input type="checkbox" name="save_favorite" value="1" style="margin-right:8px; cursor:pointer; width:16px; height:16px;">
⭐ Sauvegarder cette adresse dans mes favoris rapides
</label>
</div>
<div style="display:flex; justify-content:space-between; align-items:center; border-top:1px solid #e2e8f0; padding-top:15px;">
<div>
<button type="button" onclick="deleteCheckpoint()" id="btnDeleteCp" class="pf-btn btn-secondary" style="color:#ef4444; border-color:#fca5a5; display:none; width:auto; margin:0;">🗑️ Supprimer l'étape</button>
</div>
<div style="display:flex; gap:10px;">
<button type="button" onclick="document.getElementById('checkpointModal').style.display='none'" class="pf-btn btn-secondary" style="width:auto; margin:0;">Annuler</button>
<button type="submit" class="pf-btn" style="width:auto; margin:0;">Enregistrer l'étape</button>
</div>
</div>
</form>
</div>
</div>
<?php include __DIR__ . '/modal.php'; ?>
<script>
const MAP_POINTS = <?= json_encode($mapPoints) ?>;
</script>
+146
View File
@@ -0,0 +1,146 @@
<?php
// modules/holidays/views/list.php
// 1. Récupération des voyages + Calculs (Coût Total, Montant Payé, Montant Épargné/Financé)
$sql = "
SELECT h.*,
(
COALESCE(h.budget_food, 0) +
COALESCE(h.budget_extra, 0) +
COALESCE((SELECT SUM(amount) FROM pf_holidays_items WHERE holiday_id = h.id), 0)
) as total_cost,
(SELECT COALESCE(SUM(ABS(amount)), 0) FROM pf_expenses WHERE holiday_id = h.id) as total_paid,
(SELECT COALESCE(SUM(amount), 0) FROM pf_savings WHERE holiday_id = h.id) as total_saved
FROM pf_holidays h
ORDER BY
-- COALESCE permet de rejeter les voyages sans date tout à la fin de la liste (ex: l'an 2999)
COALESCE(start_date, '2999-12-31') ASC,
FIELD(status, 'booked', 'planned', 'draft', 'passed', 'archived')
";
$holidays = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
// Tri par statut
$active = array_filter($holidays, fn($h) => in_array($h['status'], ['draft', 'planned', 'booked']));
$history = array_filter($holidays, fn($h) => in_array($h['status'], ['passed', 'archived']));
?>
<div class="pf-holidays">
<div class="pf-holidays__titlebar">
<h1>Mes Vacances ✈️</h1>
<div class="hol-title-actions">
<button class="hol-add-btn" onclick="openHolidayModal('add')">+ Créer un voyage</button>
</div>
</div>
<section class="pf-section">
<div class="hol-ideas-grid">
<?php if (empty($active)): ?>
<p style="color:var(--text-muted); font-style:italic;">Aucun voyage en cours. Planifions quelque chose !</p>
<?php endif; ?>
<?php foreach ($active as $h): ?>
<?php renderHolidayCard($h, $pdo); ?>
<?php endforeach; ?>
</div>
</section>
<?php if (!empty($history)): ?>
<section class="pf-section" style="margin-top: 40px; border-top: 1px solid #e2e8f0; padding-top: 20px;">
<h3 style="color:var(--text-muted);">Historique</h3>
<div class="hol-ideas-grid" style="opacity: 0.7;">
<?php foreach ($history as $h): ?>
<?php renderHolidayCard($h, $pdo); ?>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
</div>
<?php include __DIR__ . '/modal.php'; ?>
<?php
function renderHolidayCard($h, $pdo) {
$stmt = $pdo->prepare("SELECT * FROM pf_holidays_items WHERE holiday_id = ?");
$stmt->execute([$h['id']]);
$items = $stmt->fetchAll(PDO::FETCH_ASSOC);
$json = htmlspecialchars(json_encode(['main' => $h, 'items' => $items]), ENT_QUOTES, 'UTF-8');
$dateDisplay = htmlspecialchars($h['period_hint'] ?? '');
if (empty($dateDisplay) && $h['start_date']) {
$dateDisplay = date('d/m/Y', strtotime($h['start_date']));
if ($h['end_date']) $dateDisplay .= ' → ' . date('d/m/Y', strtotime($h['end_date']));
}
$statusClass = match($h['status']) {
'booked' => 'bg-green-100 text-green-800',
'planned' => 'bg-blue-100 text-blue-800',
'passed' => 'bg-gray-100 text-gray-600',
default => 'bg-yellow-50 text-yellow-800'
};
// --- Calcul des métriques financières ---
$cost = (float)$h['total_cost'];
$paid = (float)$h['total_paid'];
$saved = (float)$h['total_saved'];
$leftToPay = max(0, $cost - $paid);
// Calculs pour la barre de progression bicolore
$pctPaid = $cost > 0 ? min(100, ($paid / $cost) * 100) : 0;
$pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
echo "
<div class='hol-idea-card' style='display: flex; flex-direction: column;'>
<div class='hol-idea-card__head' style='display:flex; justify-content:space-between; align-items:flex-start; margin-bottom: 10px;'>
<div style='flex:1;'>
<h3 style='margin:0; font-size:1.15rem; color:#0f172a;'>
<a href='?tab=holiday_detail&id={$h['id']}' style='text-decoration:none; color:inherit;' title='Ouvrir la page du voyage'>
".htmlspecialchars($h['title'])."
</a>
</h3>
<span style='font-size:0.7rem; padding:3px 8px; border-radius:12px; font-weight:bold; display:inline-block; margin-top:6px;' class='$statusClass'>
".strtoupper($h['status'])."
</span>
</div>
<div style='display:flex; gap:5px;'>
<button onclick='editHoliday($json)' class='pf-btn btn-secondary' style='padding:6px; height:auto; width:auto; line-height:1;' title='Modification rapide'>✏️</button>
<a href='?tab=holiday_detail&id={$h['id']}' class='pf-btn' style='padding:6px; height:auto; width:auto; line-height:1; text-decoration:none;' title='Gérer le voyage'>👁️</a>
</div>
</div>
<div class='hol-idea-meta' style='margin-bottom:15px;'>
<span>🗓️ ".($dateDisplay ?: 'Dates à définir')."</span>
</div>
<div style='margin-top:auto; padding-top:15px; border-top:1px solid #f1f5f9;'>
<div style='display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;'>
<span style='font-size:0.85rem; color:#64748b;'>Budget Total</span>
<span style='font-size:1.1rem; font-weight:bold; color:#1e293b;'>".number_format($cost, 0, ',', ' ')." €</span>
</div>
<div style='width:100%; height:8px; background:#e2e8f0; border-radius:4px; margin-bottom:12px; display:flex; overflow:hidden;'>
<div style='width:{$pctPaid}%; background:#10b981; transition:width 0.3s ease;' title='Payé'></div>
<div style='width:{$pctSaved}%; background:#3b82f6; transition:width 0.3s ease;' title='Financé (Provision)'></div>
</div>
<div style='display:grid; grid-template-columns: 1fr 1fr; gap:8px; font-size:0.8rem;'>
<div style='color:#10b981; font-weight:600;' title='Montant déjà dépensé'>✓ Payé : ".number_format($paid, 0, ',', ' ')." €</div>
<div style='color:#3b82f6; font-weight:600; text-align:right;' title='Montant épargné non dépensé'>💼 Financé : ".number_format($saved, 0, ',', ' ')." €</div>
<div style='color:#ef4444; font-weight:700; font-size:0.85rem; grid-column: span 2; padding-top: 4px; border-top: 1px dashed #fca5a5;'>
⏳ Reste à payer : ".number_format($leftToPay, 0, ',', ' ')." €
</div>
</div>
</div>
</div>
";
}
?>
+96
View File
@@ -0,0 +1,96 @@
<div id="holidayModal" class="pf-modal">
<div class="pf-modal-content" style="max-width: 800px; width: 95%;">
<h3 id="modalTitle" class="pf-modal-title">Planifier le voyage</h3>
<form action="/modules/holidays/includes/api/save_holiday.php" method="POST" id="holidayForm">
<input type="hidden" name="id" id="inp_id">
<input type="hidden" name="action" value="save">
<div class="form-row">
<div style="flex: 2;">
<label class="pf-label">Nom du voyage</label>
<input type="text" name="title" id="inp_title" class="pf-input" placeholder="Ex: Octobre - Portugal" required>
</div>
<div style="flex: 1;">
<label class="pf-label">Statut</label>
<select name="status" id="inp_status" class="pf-input">
<option value="draft">Brouillon ✏️</option>
<option value="planned">Planifié 📅</option>
<option value="booked">Réservé </option>
<option value="passed">Passé 👋</option>
<option value="archived">Archivé 🗄️</option>
</select>
</div>
</div>
<div class="form-row">
<div>
<label class="pf-label">Période (Texte libre)</label>
<input type="text" name="period_hint" id="inp_period" class="pf-input" placeholder="Ex: Octobre 2026">
</div>
<div style="display:flex; gap:10px;">
<div style="flex:1">
<label class="pf-label">Du</label>
<input type="date" name="start_date" id="inp_start" class="pf-input" lang="fr">
</div>
<div style="flex:1">
<label class="pf-label">Au</label>
<input type="date" name="end_date" id="inp_end" class="pf-input" lang="fr">
</div>
</div>
</div>
<hr style="border: 0; border-top: 1px solid #e2e8f0; margin: 20px 0;">
<div class="hol-columns-wrapper">
<div class="hol-col">
<div class="hol-col-header">
<h4 style="color:#2563eb;">🚗 Transport</h4>
<button type="button" class="btn-add-item" onclick="addItem('transport')" title="Ajouter un transport"></button>
</div>
<div id="list_transport" class="dynamic-list"></div>
</div>
<div class="hol-col">
<div class="hol-col-header">
<h4 style="color:#059669;">🏨 Hébergement</h4>
<button type="button" class="btn-add-item" onclick="addItem('accommodation')" title="Ajouter un hébergement"></button>
</div>
<div id="list_accommodation" class="dynamic-list"></div>
</div>
<div class="hol-col">
<div class="hol-col-header">
<h4 style="color:#d97706;">🎫 Activité</h4>
<button type="button" class="btn-add-item" onclick="addItem('activity')" title="Ajouter une activité"></button>
</div>
<div id="list_activity" class="dynamic-list"></div>
</div>
</div>
<hr style="border: 0; border-top: 1px solid #e2e8f0; margin: 20px 0;">
<div class="form-row">
<div>
<label class="pf-label">🍔 Budget Food & Bev ()</label>
<input type="number" step="0.01" name="budget_food" id="inp_food" class="pf-input" placeholder="0.00">
</div>
<div>
<label class="pf-label">🎁 Budget Extras ()</label>
<input type="number" step="0.01" name="budget_extra" id="inp_extra" class="pf-input" placeholder="0.00">
</div>
</div>
<div class="form-group">
<label class="pf-label">Notes</label>
<textarea name="notes" id="inp_notes" class="pf-input" rows="2" placeholder="Idées en vrac..."></textarea>
</div>
<div class="modal-footer">
<button type="button" onclick="deleteHoliday()" id="btn_delete" class="pf-btn btn-secondary" style="color:#ef4444; border-color:#fca5a5; margin-right:auto; display:none;">Supprimer</button>
<button type="button" onclick="closeHolidayModal()" class="pf-btn btn-secondary">Annuler</button>
<button type="submit" class="pf-btn">Enregistrer</button>
</div>
</form>
</div>
</div>