diff --git a/docker/schema_family.sql b/docker/schema_family.sql index ef57872..a38758a 100644 --- a/docker/schema_family.sql +++ b/docker/schema_family.sql @@ -355,6 +355,17 @@ CREATE TABLE IF NOT EXISTS pf_holidays_ideas ( notes TEXT DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS `pf_holidays_attachments` ( + `id` INT NOT NULL AUTO_INCREMENT, + `holiday_id` INT NOT NULL, + `item_id` INT DEFAULT NULL, + `file_name` VARCHAR(255) NOT NULL, + `file_path` VARCHAR(255) NOT NULL, + `uploaded_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + CONSTRAINT `fk_holiday_attachment` FOREIGN KEY (`holiday_id`) REFERENCES `pf_holidays`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS pf_geocode_cache ( q_hash CHAR(64) PRIMARY KEY, q VARCHAR(255), diff --git a/migrate-voyages.php b/migrate-voyages.php deleted file mode 100644 index 90b7fd6..0000000 --- a/migrate-voyages.php +++ /dev/null @@ -1,38 +0,0 @@ -🗺️ Migration : Ajout du Véhicule aux Voyages

🎉 Migration terminée !

"; -} catch (Exception $e) { - die("Erreur fatale : " . $e->getMessage()); -} -?> \ No newline at end of file diff --git a/migrate_attachments.php b/migrate_attachments.php new file mode 100644 index 0000000..b0f00c0 --- /dev/null +++ b/migrate_attachments.php @@ -0,0 +1,49 @@ +🚀 Lancement de la migration multi-bases...\n\n"; + +try { + // 2. Récupérer toutes les bases de données qui commencent par "househub_f" + $stmt = $pdo->query("SHOW DATABASES LIKE 'househub_f%'"); + $databases = $stmt->fetchAll(PDO::FETCH_COLUMN); + + if (empty($databases)) { + die("❌ Aucune base de données familiale trouvée (Format househub_f1, househub_f2...).\n"); + } + + $successCount = 0; + + foreach ($databases as $dbName) { + // Sécurité : on vérifie le format exact (househub_f suivi de chiffres) + if (preg_match('/^househub_f\d+$/', $dbName)) { + echo "⚙️ Mise à jour de la base : $dbName... "; + + // On bascule la connexion sur la base de la famille + $pdo->exec("USE `$dbName`"); + + // 3. Création de la table + $sql = "CREATE TABLE IF NOT EXISTS pf_holidays_attachments ( + id INT AUTO_INCREMENT PRIMARY KEY, + holiday_id INT NOT NULL, + item_id INT DEFAULT NULL, + file_name VARCHAR(255) NOT NULL, + file_path VARCHAR(255) NOT NULL, + uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_holiday_attachment FOREIGN KEY (holiday_id) REFERENCES pf_holidays(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;"; + + $pdo->exec($sql); + echo "✅ OK\n"; + $successCount++; + } + } + + echo "\n🎉 Migration terminée avec succès sur $successCount base(s) familiale(s) !"; + +} catch (PDOException $e) { + die("\n❌ Erreur SQL pendant la migration : " . $e->getMessage() . ""); +} \ No newline at end of file diff --git a/migrate_leaves_meta.php b/migrate_leaves_meta.php deleted file mode 100644 index 1eb69f8..0000000 --- a/migrate_leaves_meta.php +++ /dev/null @@ -1,90 +0,0 @@ -🚀 Début de la restauration des compteurs de congés"; - -try { - $stmt = $meta_pdo->query("SELECT db_name, name FROM families WHERE is_active = 1"); - $families = $stmt->fetchAll(PDO::FETCH_ASSOC); - - foreach ($families as $family) { - $dbName = $family['db_name']; - echo "

Famille : {$family['name']} ($dbName)

"; - - try { - $pdo = new PDO( - "mysql:host=$db_host;dbname=$dbName;charset=utf8mb4", - $db_user, $db_pass, - [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] - ); - - // 1. Création de la table de configuration (si elle n'existe pas) - $pdo->exec(" - CREATE TABLE IF NOT EXISTS pf_person_leave_meta ( - id INT AUTO_INCREMENT PRIMARY KEY, - person_id INT NOT NULL, - leave_type VARCHAR(50) NOT NULL, - anniversary_date DATE NOT NULL, - UNIQUE KEY uq_person_leave (person_id, leave_type), - FOREIGN KEY (person_id) REFERENCES pf_people(id) ON DELETE CASCADE - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - "); - - // 2. On scanne l'historique des soldes et snapshots pour trouver TOUS les types existants - $stmtLeaves = $pdo->query(" - SELECT DISTINCT person_id, leave_type - FROM ( - SELECT person_id, leave_type FROM pf_leave_balances - UNION - SELECT person_id, leave_type FROM pf_leave_snapshots - ) as combined - WHERE leave_type IS NOT NULL AND leave_type != '' - "); - - $existingLeaves = $stmtLeaves->fetchAll(PDO::FETCH_ASSOC); - $inserted = 0; - - $stmtInsert = $pdo->prepare(" - INSERT IGNORE INTO pf_person_leave_meta (person_id, leave_type, anniversary_date) - VALUES (?, ?, ?) - "); - - // 3. On injecte les données dans la configuration avec les dates par défaut de ton ancien JS - foreach ($existingLeaves as $leave) { - $pid = $leave['person_id']; - $type = strtoupper(trim($leave['leave_type'])); - - // Dates d'anniversaire par défaut (année 2000 arbitraire, seul le mois/jour compte) - $anniversary = '2000-01-01'; - if ($type === 'CP') $anniversary = '2000-06-01'; // 1er Juin - if ($type === 'JRA') $anniversary = '2000-01-01'; // 1er Janvier - if ($type === 'JA') $anniversary = '2000-04-29'; // 29 Avril (issu de ton ancien JS) - - $stmtInsert->execute([$pid, $type, $anniversary]); - if ($stmtInsert->rowCount() > 0) { - $inserted++; - } - } - - echo "✅ $inserted compteurs historiques (CP, JRA, JA...) détectés et configurés avec succès pour la modale.
"; - - } catch (PDOException $e) { - echo "❌ Erreur sur la base $dbName : " . $e->getMessage() . "
"; - } - } - - echo "

🎉 Restauration terminée avec succès !

"; - -} catch (Exception $e) { - die("❌ Erreur fatale Meta DB : " . $e->getMessage()); -} -?> \ No newline at end of file diff --git a/modules/holidays/holidays.js b/modules/holidays/holidays.js index ba7c3f1..db2e687 100644 --- a/modules/holidays/holidays.js +++ b/modules/holidays/holidays.js @@ -1161,3 +1161,219 @@ function launchGpsApp(app) { closeGpsModal(); } } + +// ============================================================================ +// GESTION DU PORTE-DOCUMENTS (UPLOAD) +// ============================================================================ +window.currentDocsStepId = null; + +// 🔥 On attache le verrou à window pour éviter les erreurs de redéclaration +window.isUploadingDocs = window.isUploadingDocs || false; + +function openDocsModal(sortOrder) { + window.currentDocsStepId = sortOrder; + document.getElementById("docsModal").style.display = "flex"; + document.body.classList.add("no-scroll"); + document.getElementById("uploadStatus").innerHTML = ""; + + const listContainer = document.getElementById("docsListContainer"); + listContainer.innerHTML = + '

⏳ Chargement des documents...

'; + + const holidayId = document.querySelector('input[name="holiday_id"]').value; + + // 🔥 On va chercher les documents existants ! + fetch( + `/modules/holidays/includes/api/get_attachments.php?holiday_id=${holidayId}&item_id=${sortOrder}`, + ) + .then((response) => response.json()) + .then((data) => { + listContainer.innerHTML = ""; // On vide le message de chargement + + if (data.success && data.files.length > 0) { + data.files.forEach((f) => { + // On rend le nom du fichier cliquable pour ouvrir le document dans un nouvel onglet + const docHtml = ` +
+
+ 📄 + ${f.file_name} +
+ +
+ `; + listContainer.insertAdjacentHTML("beforeend", docHtml); + }); + } else { + listContainer.innerHTML = + '

Aucun document pour cette étape.

'; + } + }) + .catch(() => { + listContainer.innerHTML = + '

Erreur lors du chargement.

'; + }); +} + +function closeDocsModal() { + document.getElementById("docsModal").style.display = "none"; + document.body.classList.remove("no-scroll"); +} + +function handleFileUpload(input) { + // 1. LE VERROU : Si un envoi est déjà en cours, on bloque tout ! + if (window.isUploadingDocs) return; + + if (!input.files || input.files.length === 0) return; + + // On ferme le verrou + window.isUploadingDocs = true; + + const file = input.files[0]; + const holidayId = document.querySelector('input[name="holiday_id"]').value; + const statusDiv = document.getElementById("uploadStatus"); + const listContainer = document.getElementById("docsListContainer"); + + if (file.size > 5 * 1024 * 1024) { + statusDiv.innerHTML = + "Fichier trop lourd (Max 5Mo)."; + input.value = ""; + window.isUploadingDocs = false; // On rouvre le verrou + return; + } + + statusDiv.innerHTML = + "⏳ Envoi en cours..."; + + const fd = new FormData(); + fd.append("holiday_id", holidayId); + fd.append("item_id", window.currentDocsStepId); + fd.append("file", file); + + fetch("/modules/holidays/includes/api/upload_attachment.php", { + method: "POST", + body: fd, + }) + .then((response) => response.json()) + .then((data) => { + if (data.success) { + statusDiv.innerHTML = `✅ Sauvegardé !`; + + const emptyMsg = listContainer.querySelector("p"); + if (emptyMsg) emptyMsg.remove(); + + const docHtml = ` +
+
+ 📄 + ${data.file_name} +
+ +
+ `; + listContainer.insertAdjacentHTML("beforeend", docHtml); + } else { + statusDiv.innerHTML = `❌ Erreur: ${data.error}`; + } + }) + .catch((err) => { + statusDiv.innerHTML = + "❌ Erreur réseau."; + }) + .finally(() => { + input.value = ""; + // 🔥 2. On rouvre le verrou SEULEMENT quand tout est terminé + setTimeout(() => { + window.isUploadingDocs = false; + }, 500); + }); +} + +// Fonction pour supprimer un document +function deleteAttachment(fileId, btnElement) { + if (!confirm("Voulez-vous vraiment supprimer ce document définitivement ?")) + return; + + const holidayId = document.querySelector('input[name="holiday_id"]').value; + const row = btnElement.closest('div[style*="border: 1px solid"]'); // Cible la ligne d'affichage + row.style.opacity = "0.4"; // Effet visuel d'attente + + const fd = new FormData(); + fd.append("file_id", fileId); + fd.append("holiday_id", holidayId); + + fetch("/modules/holidays/includes/api/delete_attachment.php", { + method: "POST", + body: fd, + }) + .then((response) => response.json()) + .then((data) => { + if (data.success) { + row.remove(); // On efface la ligne + + // Si c'était le dernier fichier, on remet le texte "Aucun document" + const listContainer = document.getElementById("docsListContainer"); + if (listContainer.children.length === 0) { + listContainer.innerHTML = + '

Aucun document pour cette étape.

'; + } + } else { + alert("Erreur : " + data.error); + row.style.opacity = "1"; + } + }) + .catch(() => { + alert("Erreur réseau lors de la suppression."); + row.style.opacity = "1"; + }); +} + +// ============================================================================ +// GÉNÉRATION DU CARNET DE VOYAGE (PDF Côté Client) - VERSION TEXTE BRUT +// ============================================================================ +window.generateTravelBook = function () { + const element = document.getElementById("travelBookTemplate"); + const btn = document.querySelector('button[onclick="generateTravelBook()"]'); + + if (!element) { + alert("Erreur : Le modèle de carnet de voyage est introuvable."); + return; + } + + const originalText = btn.innerHTML; + btn.innerHTML = "⏳ Génération..."; + btn.disabled = true; + + // Options ajustées avec un fond blanc forcé + const opt = { + margin: 10, // 10mm de marge + filename: "Carnet_de_Route.pdf", + image: { type: "jpeg", quality: 0.98 }, + html2canvas: { scale: 2, useCORS: true, backgroundColor: "#ffffff" }, + jsPDF: { unit: "mm", format: "a4", orientation: "portrait" }, + }; + + // 🔥 L'ASTUCE MAGIQUE : + // On extrait le HTML en texte brut et on l'encapsule dans un bloc 100% blanc. + // Plus aucun conflit possible avec l'affichage de ta page web ! + const htmlString = ` +
+ ${element.outerHTML} +
+ `; + + // Génération directe depuis la chaîne de texte + html2pdf() + .set(opt) + .from(htmlString) + .save() + .then(() => { + btn.innerHTML = originalText; + btn.disabled = false; + }) + .catch((err) => { + console.error("Erreur html2pdf:", err); + btn.innerHTML = originalText; + btn.disabled = false; + }); +}; diff --git a/modules/holidays/includes/api/delete_attachment.php b/modules/holidays/includes/api/delete_attachment.php new file mode 100644 index 0000000..7e06f24 --- /dev/null +++ b/modules/holidays/includes/api/delete_attachment.php @@ -0,0 +1,42 @@ + false, 'error' => 'Méthode non autorisée']); + exit; +} + +$file_id = isset($_POST['file_id']) ? (int)$_POST['file_id'] : 0; +$holiday_id = isset($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : 0; + +if ($file_id === 0 || $holiday_id === 0) { + echo json_encode(['success' => false, 'error' => 'Paramètres manquants']); + exit; +} + +// 1. On récupère le chemin exact du fichier pour le supprimer du NAS +$stmt = $pdo->prepare("SELECT file_path FROM pf_holidays_attachments WHERE id = ? AND holiday_id = ?"); +$stmt->execute([$file_id, $holiday_id]); +$file = $stmt->fetch(PDO::FETCH_ASSOC); + +if ($file) { + $absolutePath = dirname(__DIR__, 4) . '/' . $file['file_path']; + + // 2. On supprime physiquement le fichier s'il existe + if (file_exists($absolutePath)) { + unlink($absolutePath); + } + + // 3. On nettoie la base de données + $del = $pdo->prepare("DELETE FROM pf_holidays_attachments WHERE id = ?"); + $del->execute([$file_id]); + + echo json_encode(['success' => true]); +} else { + echo json_encode(['success' => false, 'error' => 'Fichier introuvable ou non autorisé']); +} \ No newline at end of file diff --git a/modules/holidays/includes/api/get_attachments.php b/modules/holidays/includes/api/get_attachments.php new file mode 100644 index 0000000..d68e22e --- /dev/null +++ b/modules/holidays/includes/api/get_attachments.php @@ -0,0 +1,22 @@ + false, 'error' => 'ID manquant']); + exit; +} + +// On récupère les documents de cette étape spécifique +$stmt = $pdo->prepare("SELECT id, file_name, file_path FROM pf_holidays_attachments WHERE holiday_id = ? AND item_id = ? ORDER BY uploaded_at DESC"); +$stmt->execute([$holiday_id, $item_id]); +$files = $stmt->fetchAll(PDO::FETCH_ASSOC); + +echo json_encode(['success' => true, 'files' => $files]); \ No newline at end of file diff --git a/modules/holidays/includes/api/upload_attachment.php b/modules/holidays/includes/api/upload_attachment.php new file mode 100644 index 0000000..55c0e35 --- /dev/null +++ b/modules/holidays/includes/api/upload_attachment.php @@ -0,0 +1,46 @@ + false, 'error' => 'Méthode non autorisée']); + exit; +} + +$holiday_id = isset($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : 0; +$item_id = (isset($_POST['item_id']) && (int)$_POST['item_id'] > 0) ? (int)$_POST['item_id'] : null; + +if ($holiday_id === 0 || empty($_FILES['file'])) { + echo json_encode(['success' => false, 'error' => 'Données manquantes ou fichier invalide']); + exit; +} + +$file = $_FILES['file']; + +// On range les fichiers dans un sous-dossier par voyage pour rester propre sur le NAS +$uploadDir = dirname(__DIR__, 4) . '/uploads/holidays/' . $holiday_id . '/'; + +if (!is_dir($uploadDir)) { + mkdir($uploadDir, 0777, true); +} + +// Sécurisation du nom de fichier (retrait des accents et caractères spéciaux) +$fileName = basename($file['name']); +$safeFileName = preg_replace("/[^a-zA-Z0-9.-]/", "_", $fileName); +$uniqueFileName = time() . '_' . $safeFileName; +$destination = $uploadDir . $uniqueFileName; + +if (move_uploaded_file($file['tmp_name'], $destination)) { + // Enregistrement en base de données + $stmt = $pdo->prepare("INSERT INTO pf_holidays_attachments (holiday_id, item_id, file_name, file_path) VALUES (?, ?, ?, ?)"); + $stmt->execute([$holiday_id, $item_id, $fileName, 'uploads/holidays/' . $holiday_id . '/' . $uniqueFileName]); + + $attachmentId = $pdo->lastInsertId(); + echo json_encode(['success' => true, 'id' => $attachmentId, 'file_name' => $fileName]); +} else { + echo json_encode(['success' => false, 'error' => 'Erreur lors de l\'écriture du fichier sur le NAS']); +} \ No newline at end of file diff --git a/modules/holidays/views/detail.php b/modules/holidays/views/detail.php index a1c3ea7..ac35986 100644 --- a/modules/holidays/views/detail.php +++ b/modules/holidays/views/detail.php @@ -90,6 +90,7 @@ $pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
+
@@ -98,14 +99,20 @@ $pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
-
+
+ + +
+
@@ -303,6 +310,7 @@ $pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0; style="width:26px!important; height:26px!important; font-size:0.8rem; min-width:26px!important; min-height:26px!important;"> 🧭 + @@ -354,6 +362,8 @@ $pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
+ +
@@ -498,6 +508,30 @@ $pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
+
+
+
+

📎 Porte-documents

+ +
+ +
+

Ajoutez vos billets, réservations ou PDFs pour cette étape.

+ + + + +
+
+ +
+

Aucun document pour cette étape.

+
+
+
+ + + + + \ No newline at end of file diff --git a/modules/holidays/views/pdf_template.php b/modules/holidays/views/pdf_template.php new file mode 100644 index 0000000..23c301b --- /dev/null +++ b/modules/holidays/views/pdf_template.php @@ -0,0 +1,140 @@ + + +
+
+ +
+
🗺️
+

+

+ + Du au + + Dates à définir + +

+
+ +
+

💰 Budget Prévisionnel :

+ + + + + + + + + +
Frais Généraux (Vols, Locations globales...)
Étapes (Hébergements, Activités...)
+
+ + 0 || $holiday['budget_extra'] > 0): ?> +
+

🌍 Réservations & Frais Généraux

+
    + 0): ?> +
  • 🍔 Budget Nourriture :
  • + + 0): ?> +
  • 🎁 Extras & Souvenirs :
  • + + '🚗', 'accommodation' => '🏨', 'activity' => '🎫', default => '🏷️' }; + ?> +
  • + + +
  • + +
+
+ + +
+

📍 Itinéraire Détaillé

+ + $step): ?> + + 0): ?> +
+
+
🚗 En route vers l'étape suivante...
+
+
+ + +
+ +
+
+

+ +
+ 📅 Du au +
+ +
+
+ + € + +
+
+ + + + +
+

📋 Planning & Activités :

+ + '🚗', 'accommodation' => '🏨', 'activity' => '🎫', default => '🏷️' }; + // Sécurité sur la date d'activité (item_date ou date) + $cpDate = !empty($cp['item_date']) ? $cp['item_date'] : (!empty($cp['date']) ? $cp['date'] : null); + ?> + + + + + + +
+ + + + + € +
+
+ + +
+ + +
+

📝 Notes & Informations utiles

+ +

Espace réservé pour vos numéros d'urgence, codes de cadenas, adresses locales...

+ + +
+ + +
+ Carnet de route généré automatiquement par HouseHub. Bon voyage ! ✈️ +
+ +
+
\ No newline at end of file