10 Commits
Author SHA1 Message Date
percoandClaude Sonnet 4.6 6aab112ecb feat(voyage): affichage détaillé des gares de péage par segment
Deploy HouseHub / deploy (push) Successful in 1s
- Chaque segment affiche maintenant la gare d'entrée et de sortie
- Affichage de l'opérateur (APRR, COFIROUTE, SANEF...)
- Style amélioré : distance + gares sur la même ligne
- Ajout du champ 'op' dans la réponse API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:23:34 +02:00
percoandClaude Sonnet 4.6 6e409fb3a1 feat(voyage): calculateur de coût de trajet avec péages et carburant
Deploy HouseHub / deploy (push) Successful in 1s
- GPS database (toll_gps.json): 414 péages géocodés via Nominatim (88.5% coverage, 468 plazas)
- API PHP (voyage/api.php): estimation péages + carburant via OSRM routing
  - Matching opérateur/entrée/sortie par proximité GPS (80km radius)
  - Fallback haversine si OSRM indisponible
- UI holidays/detail.php: panneau "Coût du trajet" (≥2 étapes)
  - Inputs conso L/100km et prix carburant configurables
  - Affichage distance, péages, carburant, total aller
  - Détail par segment en accordéon
- CSS: classes hol-cost-stat pour les stats chiffrées

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:17:21 +02:00
percoandClaude Sonnet 4.6 535f1b7dcf feat(voyage): base de données péages 2026 (24 680 paires, 5 opérateurs)
Deploy HouseHub / deploy (push) Successful in 2s
Données extraites depuis les PDFs officiels 2026 :
- APRR : 20 964 paires (réseau A6, A31, A36, A40...)
- Cofiroute : 1 296 paires (A10, A11, A28...)
- SANEF : 1 620 paires (A1, A2, A26...)
- AREA : 674 paires (A41, A43...)
- SAPN : 126 paires (A13, A29...)

Script de parsing : /tmp/build_tolls.py (à conserver pour mises à jour)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 15:54:26 +02:00
Cedric 99ed0e31de rollback
Deploy HouseHub / deploy (push) Successful in 2s
2026-05-20 12:05:58 +02:00
Cedric a1e7bc62fd test
Deploy HouseHub / deploy (push) Successful in 1s
2026-05-20 12:05:10 +02:00
percoandClaude Sonnet 4.6 3675f0c01a ci: ajout branches staging-perco et staging-fefe dans le workflow deploy
Deploy HouseHub / deploy (push) Successful in 1s
- Push sur staging-perco → deploy /opt/container/househub-perco
- Push sur staging-fefe  → deploy /opt/container/househub-fefe
- Push sur main          → deploy /opt/container/househub (production)
- Utiliser `git deploy <env>` pour envoyer sur le bon environnement

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 11:52:12 +02:00
perco 836533fcf6 Merge pull request 'refacto : var currency and zone applied' (#2) from param_foyer into main
Deploy HouseHub / deploy (push) Successful in 1s
Reviewed-on: #2
2026-05-20 11:26:08 +02:00
FeFeClochette ed2d5a7c9e delete extract source
Deploy HouseHub / deploy (push) Successful in 1s
2026-05-20 11:16:49 +02:00
percoandClaude Sonnet 4.6 6bbee271e3 fix(liste): couleur appliquée sur tout le tab (pas juste un point)
Deploy HouseHub / deploy (push) Successful in 2s
- Tab actif coloré : background + border = couleur choisie, texte blanc
- Tab inactif coloré : border + texte = couleur choisie
- Suppression du .liste-tab-dot inutilisé

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:08:42 +02:00
percoandClaude Sonnet 4.6 7b294f300a feat(liste): modal création/édition avec couleur et type de liste
Deploy HouseHub / deploy (push) Successful in 2s
- Remplacement du formulaire inline par un modal complet
- Color picker : 11 couleurs + option "sans couleur"
- Type de liste : Courses, To-do, Voyage, Travail, Maison, Santé, Loisirs, Autre
- Tab affiche un dot coloré + emoji du type
- Bouton "Supprimer" dans le modal d'édition (remplace le × dans le tab)
- DB : columns color + list_type sur pf_lists

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:05:59 +02:00
12 changed files with 2744 additions and 193 deletions
+19 -4
View File
@@ -2,7 +2,7 @@ name: Deploy HouseHub
on: on:
push: push:
branches: [main] branches: [main, staging-perco, staging-fefe]
jobs: jobs:
deploy: deploy:
@@ -15,6 +15,21 @@ jobs:
username: perco username: perco
key: ${{ secrets.SSH_PRIVATE_KEY }} key: ${{ secrets.SSH_PRIVATE_KEY }}
script: | script: |
cd /opt/container/househub BRANCH="${{ github.ref_name }}"
git pull case "$BRANCH" in
echo "✓ HouseHub mis à jour" main)
cd /opt/container/househub
git pull
echo "✓ Production mise à jour"
;;
staging-perco)
cd /opt/container/househub-perco
git pull origin staging-perco
echo "✓ Staging Perco mis à jour"
;;
staging-fefe)
cd /opt/container/househub-fefe
git pull origin staging-fefe
echo "✓ Staging Fefe mis à jour"
;;
esac
+5 -3
View File
@@ -374,9 +374,11 @@ CREATE TABLE IF NOT EXISTS pf_todos (
-- ─── Module Liste (multi-listes) ────────────────────────────────────────────── -- ─── Module Liste (multi-listes) ──────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS pf_lists ( CREATE TABLE IF NOT EXISTS pf_lists (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL DEFAULT 'Ma liste', name VARCHAR(255) NOT NULL DEFAULT 'Ma liste',
position INT NOT NULL DEFAULT 0, color VARCHAR(20) DEFAULT NULL,
list_type VARCHAR(50) DEFAULT NULL,
position INT NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-105
View File
@@ -1,105 +0,0 @@
<?php
/**
* HouseHub OS - Source Extractor 🦙🚀
* Génère un fichier Markdown contenant tout le code source du projet.
* Idéal pour alimenter une GEM ou un LLM.
*/
// 1. 🚨 SÉCURITÉ : Ne jamais laisser ce fichier accessible publiquement sans protection !
// Appelle le script via : https://househub.nas.../extract_source.php?key=TON_MOT_DE_PASSE
$secretKey = 'mR83s7MmXbP$jer$9C4HnGkry6xhGL6';
if (!isset($_GET['key']) || $_GET['key'] !== $secretKey) {
http_response_code(403);
die("⛔ Accès refusé. Veuillez fournir la clé de sécurité.");
}
// Forcer l'affichage en texte brut dans le navigateur pour un copier-coller facile
header('Content-Type: text/plain; charset=utf-8');
$root = __DIR__;
// 2. CONFIGURATION DU FILTRAGE
// Dossiers à exclure (ne mets pas de slash au début)
$excludeDirs = [
'.git',
'.gitea',
'.devtools',
'vendor',
'node_modules',
'assets/img',
'modules/holidays/assets/img',
'modules/family-calendar/assets/img',
'modules/gift-list/assets/img'
];
// Fichiers spécifiques à exclure (Sécurité & Bruit)
$excludeFiles = [
'.env',
'.env.example',
'extract_source.php', // On s'exclut soi-même
'pachafamily_source.md',
'docker-compose.override.yml'
];
// Extensions autorisées (on ignore les images, pdf, zip, etc.)
$allowedExtensions = ['php', 'js', 'css', 'sql', 'json', 'yml', 'yaml', 'md', 'sh'];
// 3. INITIALISATION DU PARCOURS
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
$report = "# 🦙 Source Code HouseHub OS\n\n";
$report .= "> *Généré le " . date('Y-m-d H:i:s') . "*\n\n";
$report .= "---\n\n";
// 4. BOUCLE D'EXTRACTION
foreach ($iterator as $file) {
if ($file->isDir()) continue;
$filePath = $file->getPathname();
$relativePath = str_replace($root . DIRECTORY_SEPARATOR, '', $filePath);
$relativePath = str_replace('\\', '/', $relativePath); // Uniformisation Windows/Linux
// Extraction de l'extension
$ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
// A. Filtrage par dossier
$skip = false;
foreach ($excludeDirs as $dir) {
// Si le chemin commence par ce dossier, ou contient /ce_dossier/
if (strpos($relativePath, $dir . '/') === 0 || strpos($relativePath, '/' . $dir . '/') !== false) {
$skip = true;
break;
}
}
if ($skip) continue;
// B. Filtrage par nom de fichier
if (in_array(basename($filePath), $excludeFiles)) continue;
// C. Filtrage par extension
if (!in_array($ext, $allowedExtensions)) continue;
// 5. LECTURE ET FORMATAGE
$content = file_get_contents($filePath);
if ($content === false) {
$content = "// Erreur lors de la lecture de ce fichier.";
}
// Ajustement du tag de langage pour le Markdown
$lang = $ext;
if ($ext === 'js') $lang = 'javascript';
if ($ext === 'yml') $lang = 'yaml';
$report .= "### 📄 Fichier : `$relativePath`\n";
$report .= "```$lang\n";
$report .= trim($content) . "\n";
$report .= "```\n\n---\n\n";
}
// 6. SORTIE
echo $report;
-8
View File
@@ -18,14 +18,6 @@ require __DIR__ . '/header.php';
<button class="liste-tab-add" id="btn-add-list" title="<?= htmlspecialchars(tr('liste_new_list')) ?>">+</button> <button class="liste-tab-add" id="btn-add-list" title="<?= htmlspecialchars(tr('liste_new_list')) ?>">+</button>
</div> </div>
<!-- Formulaire de création de liste inline -->
<div class="liste-new-form hidden" id="liste-new-form">
<input type="text" id="new-list-name" maxlength="100"
placeholder="<?= htmlspecialchars(tr('liste_list_name_placeholder')) ?>" autocomplete="off">
<button id="btn-confirm-new-list"><?= htmlspecialchars(tr('liste_create')) ?></button>
<button id="btn-cancel-new-list"><?= htmlspecialchars(tr('cancel')) ?></button>
</div>
<!-- Corps de la liste active --> <!-- Corps de la liste active -->
<div class="liste-body" id="liste-body"> <div class="liste-body" id="liste-body">
+7
View File
@@ -1398,3 +1398,10 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover {
#tripMap { #tripMap {
touch-action: none; touch-action: none;
} }
/* --- Toll cost estimator panel --- */
.hol-cost-stat { background: var(--bg-page, #f8fafc); border: 1px solid var(--border-light, #e2e8f0); border-radius: 10px; padding: .75rem 1rem; text-align: center; }
.hol-cost-stat-val { font-size: 1.2rem; font-weight: 700; color: var(--text-main, #0f172a); }
.hol-cost-stat-label { font-size: .72rem; color: var(--text-muted, #64748b); margin-top: 2px; text-transform: uppercase; letter-spacing: .04em; }
.hol-cost-total { border-color: var(--primary, #4361ee); }
.hol-cost-total .hol-cost-stat-val { color: var(--primary, #4361ee); }
+73
View File
@@ -261,6 +261,22 @@ $pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
</div> </div>
</div> </div>
<?php if (count($mapPoints) >= 2): ?>
<div class="hol-summary-card" id="hol-cost-panel" style="margin-top:24px;">
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; margin-bottom:16px;">
<h3 style="margin:0; display:flex; align-items:center; gap:8px;">🚗 Coût du trajet</h3>
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
<label style="font-size:.82rem; color:var(--text-muted);">Conso (L/100km)</label>
<input type="number" id="fuel-l100" value="7" min="3" max="30" step="0.5" style="width:65px; padding:.3rem .5rem; border:1px solid var(--border-light); border-radius:8px; font-size:.85rem;">
<label style="font-size:.82rem; color:var(--text-muted);">Prix carburant (€/L)</label>
<input type="number" id="fuel-price" value="1.85" min="1" max="4" step="0.01" style="width:65px; padding:.3rem .5rem; border:1px solid var(--border-light); border-radius:8px; font-size:.85rem;">
<button onclick="estimateTripCost()" class="pf-btn pf-btn-small">Calculer</button>
</div>
</div>
<div id="hol-cost-result" style="color:var(--text-muted); font-size:.9rem;">Cliquez sur Calculer pour estimer le coût du trajet.</div>
</div>
<?php endif; ?>
<?php if (!empty($holiday['notes'])): ?> <?php if (!empty($holiday['notes'])): ?>
<div class="hol-summary-card" style="margin-top: 24px; padding: 25px; border-left: 5px solid #f59e0b;"> <div class="hol-summary-card" style="margin-top: 24px; padding: 25px; border-left: 5px solid #f59e0b;">
<h3 style="margin: 0 0 15px 0; font-size: 1.2rem; color: #0f172a; display: flex; align-items: center; gap: 8px;"> <h3 style="margin: 0 0 15px 0; font-size: 1.2rem; color: #0f172a; display: flex; align-items: center; gap: 8px;">
@@ -415,6 +431,63 @@ window.closePlanningModal = window.closePlanningModal || function() {
if(modal) modal.style.display = 'none'; if(modal) modal.style.display = 'none';
document.body.classList.remove('no-scroll'); document.body.classList.remove('no-scroll');
}; };
async function estimateTripCost() {
const l100 = parseFloat(document.getElementById('fuel-l100').value) || 7;
const price = parseFloat(document.getElementById('fuel-price').value) || 1.85;
const result = document.getElementById('hol-cost-result');
result.innerHTML = '⏳ Calcul en cours…';
const stops = (window.MAP_POINTS || []).map(p => ({lat: p.lat, lng: p.lng, name: p.location_name}));
if (stops.length < 2) { result.innerHTML = 'Pas assez d\'étapes pour calculer.'; return; }
try {
const resp = await fetch('/modules/voyage/api.php?action=estimate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({stops, fuel_l100: l100, fuel_price: price})
});
const data = await resp.json();
if (!data.ok) { result.innerHTML = 'Erreur : ' + (data.error || 'inconnue'); return; }
let html = '<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:12px; margin-bottom:16px;">';
html += `<div class="hol-cost-stat"><div class="hol-cost-stat-val">${Math.round(data.total_km)} km</div><div class="hol-cost-stat-label">Distance totale</div></div>`;
html += `<div class="hol-cost-stat"><div class="hol-cost-stat-val">${data.total_toll.toFixed(2)} €</div><div class="hol-cost-stat-label">Péages estimés</div></div>`;
html += `<div class="hol-cost-stat"><div class="hol-cost-stat-val">${data.fuel_cost.toFixed(2)} €</div><div class="hol-cost-stat-label">Carburant</div></div>`;
html += `<div class="hol-cost-stat hol-cost-total"><div class="hol-cost-stat-val">${data.grand_total.toFixed(2)} €</div><div class="hol-cost-stat-label">Total aller</div></div>`;
html += '</div>';
if (data.segments && data.segments.length) {
html += '<div style="border-top:1px solid var(--border-light); padding-top:12px; margin-top:4px;">';
html += '<div style="font-size:.75rem; font-weight:700; text-transform:uppercase; letter-spacing:.04em; color:var(--text-muted); margin-bottom:10px;">Détail du trajet</div>';
data.segments.forEach(s => {
html += `<div style="padding:8px 0; border-bottom:1px solid var(--border-light);">`;
html += `<div style="display:flex; justify-content:space-between; align-items:baseline; margin-bottom:4px;">
<span style="font-weight:600; font-size:.88rem;">📍 ${esc(s.from)} → ${esc(s.to)}</span>
<span style="font-size:.88rem; color:var(--primary);">péage : <strong>${s.toll.toFixed(2)} €</strong></span>
</div>`;
html += `<div style="font-size:.78rem; color:var(--text-muted);">🛣️ ${Math.round(s.distance_km)} km`;
if (s.entry_plaza && s.exit_plaza) {
html += ` · ${esc(s.entry_plaza)} → ${esc(s.exit_plaza)}`;
if (s.op) html += ` <span style="background:var(--bg-page); border:1px solid var(--border-light); border-radius:4px; padding:0 4px; font-size:.7rem;">${esc(s.op)}</span>`;
} else if (s.note) {
html += ` · <em>${esc(s.note)}</em>`;
}
html += '</div></div>';
});
html += '</div>';
}
function esc(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
result.innerHTML = html;
} catch(e) {
result.innerHTML = 'Erreur de connexion.';
console.error(e);
}
}
</script> </script>
<script src="/modules/holidays/holidays.js"></script> <script src="/modules/holidays/holidays.js"></script>
+19 -7
View File
@@ -15,6 +15,7 @@ require_once dirname(__DIR__, 2) . '/includes/db.php';
// ── Auto-create tables ──────────────────────────────────────────────────────── // ── Auto-create tables ────────────────────────────────────────────────────────
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_lists ( $pdo->exec("CREATE TABLE IF NOT EXISTS pf_lists (
id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL DEFAULT 'Ma liste', id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL DEFAULT 'Ma liste',
color VARCHAR(20) DEFAULT NULL, list_type VARCHAR(50) DEFAULT NULL,
position INT NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP position INT NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
@@ -48,6 +49,8 @@ $pdo->exec("CREATE TABLE IF NOT EXISTS pf_item_category_rules (
try { $pdo->exec("ALTER TABLE pf_grocery_items ADD COLUMN list_id INT NOT NULL DEFAULT 1 AFTER id"); } catch (\Exception $e) {} try { $pdo->exec("ALTER TABLE pf_grocery_items ADD COLUMN list_id INT NOT NULL DEFAULT 1 AFTER id"); } catch (\Exception $e) {}
try { $pdo->exec("ALTER TABLE pf_grocery_items ADD COLUMN category_id INT DEFAULT NULL AFTER list_id"); } catch (\Exception $e) {} try { $pdo->exec("ALTER TABLE pf_grocery_items ADD COLUMN category_id INT DEFAULT NULL AFTER list_id"); } catch (\Exception $e) {}
try { $pdo->exec("ALTER TABLE pf_grocery_items ADD KEY idx_items_list (list_id)"); } catch (\Exception $e) {} try { $pdo->exec("ALTER TABLE pf_grocery_items ADD KEY idx_items_list (list_id)"); } catch (\Exception $e) {}
try { $pdo->exec("ALTER TABLE pf_lists ADD COLUMN color VARCHAR(20) DEFAULT NULL AFTER name"); } catch (\Exception $e) {}
try { $pdo->exec("ALTER TABLE pf_lists ADD COLUMN list_type VARCHAR(50) DEFAULT NULL AFTER color"); } catch (\Exception $e) {}
// ── Seed categories if empty ────────────────────────────────────────────────── // ── Seed categories if empty ──────────────────────────────────────────────────
if ((int)$pdo->query("SELECT COUNT(*) FROM pf_list_categories")->fetchColumn() === 0) { if ((int)$pdo->query("SELECT COUNT(*) FROM pf_list_categories")->fetchColumn() === 0) {
@@ -313,23 +316,32 @@ if ($action === 'set_category' && $method === 'POST') {
if ($action === 'lists') { if ($action === 'lists') {
if ($method === 'GET') { if ($method === 'GET') {
$firstId = liste_ensure_default($pdo); $firstId = liste_ensure_default($pdo);
$rows = $pdo->query("SELECT id, name, position FROM pf_lists ORDER BY position, id")->fetchAll(PDO::FETCH_ASSOC); $rows = $pdo->query("SELECT id, name, color, list_type, position FROM pf_lists ORDER BY position, id")->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['lists' => $rows, 'default_id' => $firstId]); echo json_encode(['lists' => $rows, 'default_id' => $firstId]);
exit; exit;
} }
if ($method === 'POST') { if ($method === 'POST') {
$name = mb_substr(trim($body['name'] ?? ''), 0, 100); $name = mb_substr(trim($body['name'] ?? ''), 0, 100);
$color = mb_substr(trim($body['color'] ?? ''), 0, 20) ?: null;
$list_type = mb_substr(trim($body['list_type'] ?? ''), 0, 50) ?: null;
if (!$name) { http_response_code(400); echo json_encode(['error' => 'name required']); exit; } if (!$name) { http_response_code(400); echo json_encode(['error' => 'name required']); exit; }
$pos = (int)$pdo->query("SELECT COALESCE(MAX(position),0)+1 FROM pf_lists")->fetchColumn(); $pos = (int)$pdo->query("SELECT COALESCE(MAX(position),0)+1 FROM pf_lists")->fetchColumn();
$pdo->prepare("INSERT INTO pf_lists (name, position) VALUES (?,?)")->execute([$name, $pos]); $pdo->prepare("INSERT INTO pf_lists (name, color, list_type, position) VALUES (?,?,?,?)")->execute([$name, $color, $list_type, $pos]);
echo json_encode(['id' => (int)$pdo->lastInsertId(), 'name' => $name, 'position' => $pos]); echo json_encode(['id' => (int)$pdo->lastInsertId(), 'name' => $name, 'color' => $color, 'list_type' => $list_type, 'position' => $pos]);
exit; exit;
} }
if ($method === 'PUT') { if ($method === 'PUT') {
$id = (int)($_GET['id'] ?? 0); $id = (int)($_GET['id'] ?? 0);
$name = mb_substr(trim($body['name'] ?? ''), 0, 100); $name = mb_substr(trim($body['name'] ?? ''), 0, 100);
$color = array_key_exists('color', $body) ? (mb_substr(trim($body['color'] ?? ''), 0, 20) ?: null) : false;
$list_type = array_key_exists('list_type', $body) ? (mb_substr(trim($body['list_type'] ?? ''), 0, 50) ?: null) : false;
if (!$id || !$name) { http_response_code(400); echo json_encode(['error' => 'invalid']); exit; } if (!$id || !$name) { http_response_code(400); echo json_encode(['error' => 'invalid']); exit; }
$pdo->prepare("UPDATE pf_lists SET name=? WHERE id=?")->execute([$name, $id]); $sets = ['name=?'];
$vals = [$name];
if ($color !== false) { $sets[] = 'color=?'; $vals[] = $color; }
if ($list_type !== false) { $sets[] = 'list_type=?'; $vals[] = $list_type; }
$vals[] = $id;
$pdo->prepare("UPDATE pf_lists SET " . implode(', ', $sets) . " WHERE id=?")->execute($vals);
echo json_encode(['ok' => true]); exit; echo json_encode(['ok' => true]); exit;
} }
if ($method === 'DELETE') { if ($method === 'DELETE') {
+136
View File
@@ -310,6 +310,135 @@
} }
.liste-empty-icon { font-size: 3rem; margin-bottom: .75rem; } .liste-empty-icon { font-size: 3rem; margin-bottom: .75rem; }
/* ── List modal ────────────────────────────────────────────────────────────── */
.liste-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 1rem;
}
.liste-modal {
background: var(--card-bg, #fff);
border: 1px solid var(--border, #dee2e6);
border-radius: 14px;
width: 100%;
max-width: 420px;
box-shadow: 0 20px 60px rgba(0,0,0,.2);
}
.liste-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border, #dee2e6);
}
.liste-modal-header h3 { margin: 0; font-size: .95rem; font-weight: 700; color: var(--text, #212529); }
.liste-modal-close {
background: none;
border: none;
font-size: 1.3rem;
cursor: pointer;
color: var(--muted, #6c757d);
line-height: 1;
padding: 0 .25rem;
transition: color .15s;
}
.liste-modal-close:hover { color: var(--text, #212529); }
.liste-modal-body {
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: .85rem;
}
.liste-modal-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: .5rem;
padding: .85rem 1.25rem;
border-top: 1px solid var(--border, #dee2e6);
}
.liste-modal-footer .btn-tool-danger { margin-right: auto; }
.liste-form-group { display: flex; flex-direction: column; gap: .3rem; }
.liste-form-label {
font-size: .73rem;
font-weight: 700;
color: var(--muted, #6c757d);
text-transform: uppercase;
letter-spacing: .04em;
}
.liste-modal-name, .liste-modal-type {
padding: .5rem .7rem;
border: 1px solid var(--border, #dee2e6);
border-radius: 8px;
background: var(--input-bg, #fff);
color: var(--text, #212529);
font-size: .875rem;
font-family: inherit;
width: 100%;
box-sizing: border-box;
transition: border-color .15s;
}
.liste-modal-name:focus, .liste-modal-type:focus {
outline: none;
border-color: var(--primary, #4361ee);
box-shadow: 0 0 0 3px rgba(67,97,238,.15);
}
.btn-tool-primary {
background: var(--primary, #4361ee);
color: #fff;
border-color: var(--primary, #4361ee);
}
.btn-tool-primary:hover { filter: brightness(1.1); background: var(--primary, #4361ee); }
/* ── Color picker ──────────────────────────────────────────────────────────── */
.liste-color-picker {
display: flex;
flex-wrap: wrap;
gap: .45rem;
}
.liste-color-swatch {
width: 26px;
height: 26px;
border-radius: 50%;
border: 3px solid transparent;
background: var(--swatch, transparent);
cursor: pointer;
transition: transform .1s;
outline: 2px solid transparent;
outline-offset: 2px;
box-sizing: border-box;
}
.liste-color-swatch:hover { transform: scale(1.2); }
.liste-color-swatch.active {
outline-color: var(--swatch, var(--primary, #4361ee));
border-color: var(--card-bg, #fff);
}
.liste-color-swatch-none {
background: transparent !important;
border: 2px dashed var(--border, #dee2e6) !important;
}
.liste-color-swatch-none.active { outline-color: var(--primary, #4361ee); }
/* ── Tab type emoji ────────────────────────────────────────────────────────── */
.liste-tab-type { font-size: .85rem; line-height: 1; }
/* ── Category section headers ──────────────────────────────────────────────── */ /* ── Category section headers ──────────────────────────────────────────────── */
.liste-cat-section-header { .liste-cat-section-header {
@@ -429,6 +558,13 @@
[data-theme='dark'] .btn-tool:hover { background: #2a2a3e; } [data-theme='dark'] .btn-tool:hover { background: #2a2a3e; }
[data-theme='dark'] .liste-new-form input { background: #1e1e2e; color: #cdd6f4; border-color: #4361ee; } [data-theme='dark'] .liste-new-form input { background: #1e1e2e; color: #cdd6f4; border-color: #4361ee; }
[data-theme='dark'] .liste-new-form #btn-cancel-new-list { background: #2a2a3e; color: #cdd6f4; } [data-theme='dark'] .liste-new-form #btn-cancel-new-list { background: #2a2a3e; color: #cdd6f4; }
[data-theme='dark'] .liste-modal { background: #1e1e2e; border-color: #3a3a4e; }
[data-theme='dark'] .liste-modal-header,
[data-theme='dark'] .liste-modal-footer { border-color: #3a3a4e; }
[data-theme='dark'] .liste-modal-header h3 { color: #cdd6f4; }
[data-theme='dark'] .liste-modal-name,
[data-theme='dark'] .liste-modal-type { background: #2a2a3e; border-color: #3a3a4e; color: #cdd6f4; }
[data-theme='dark'] .liste-color-swatch.active { border-color: #1e1e2e; }
[data-theme='dark'] .liste-cat-badge { border-color: #3a3a4e; } [data-theme='dark'] .liste-cat-badge { border-color: #3a3a4e; }
[data-theme='dark'] .liste-cat-badge:hover { background: #313155; border-color: #4361ee; } [data-theme='dark'] .liste-cat-badge:hover { background: #313155; border-color: #4361ee; }
[data-theme='dark'] .liste-cat-section-count { background: #2a2a3e; color: #8892b0; } [data-theme='dark'] .liste-cat-section-count { background: #2a2a3e; color: #8892b0; }
+169 -66
View File
@@ -11,6 +11,35 @@ const state = {
catMap: {}, catMap: {},
}; };
// ── Constants ──────────────────────────────────────────────────────────────────
const LIST_COLORS = [
{ id: 'none', hex: null },
{ id: 'blue', hex: '#3b82f6' },
{ id: 'red', hex: '#ef4444' },
{ id: 'amber', hex: '#f59e0b' },
{ id: 'purple', hex: '#8b5cf6' },
{ id: 'pink', hex: '#ec4899' },
{ id: 'emerald', hex: '#10b981' },
{ id: 'orange', hex: '#f97316' },
{ id: 'cyan', hex: '#06b6d4' },
{ id: 'indigo', hex: '#6366f1' },
{ id: 'teal', hex: '#14b8a6' },
{ id: 'gray', hex: '#6b7280' },
];
const LIST_TYPES = [
{ value: '', label: '— Aucun —', emoji: '' },
{ value: 'courses', label: 'Courses', emoji: '🛒' },
{ value: 'todo', label: 'To-do', emoji: '✅' },
{ value: 'voyage', label: 'Voyage', emoji: '✈️' },
{ value: 'travail', label: 'Travail', emoji: '💼' },
{ value: 'maison', label: 'Maison', emoji: '🏠' },
{ value: 'sante', label: 'Santé', emoji: '💊' },
{ value: 'loisirs', label: 'Loisirs', emoji: '🎮' },
{ value: 'autre', label: 'Autre', emoji: '📋' },
];
// ── Utilities ────────────────────────────────────────────────────────────────── // ── Utilities ──────────────────────────────────────────────────────────────────
function esc(s) { function esc(s) {
@@ -42,6 +71,116 @@ async function api(action, method = 'GET', body = null, params = {}) {
return r.json(); return r.json();
} }
// ── List modal ─────────────────────────────────────────────────────────────────
let activeModal = null;
function openListModal(listId = null) {
if (activeModal) { activeModal.remove(); activeModal = null; }
const list = listId ? state.lists.find(l => l.id === listId) : null;
const isNew = !listId;
const backdrop = document.createElement('div');
backdrop.className = 'liste-modal-backdrop';
backdrop.innerHTML = `
<div class="liste-modal" role="dialog" aria-modal="true">
<div class="liste-modal-header">
<h3>${isNew ? 'Nouvelle liste' : 'Modifier la liste'}</h3>
<button class="liste-modal-close" aria-label="Fermer">×</button>
</div>
<div class="liste-modal-body">
<div class="liste-form-group">
<label class="liste-form-label">Nom</label>
<input class="liste-modal-name" type="text" maxlength="100"
value="${esc(list?.name ?? '')}" placeholder="Ma liste…" autocomplete="off">
</div>
<div class="liste-form-group">
<label class="liste-form-label">Couleur</label>
<div class="liste-color-picker">
${LIST_COLORS.map(c => {
const isActive = (c.hex === null && !list?.color) || (list?.color === c.hex);
const cls = 'liste-color-swatch' + (c.hex === null ? ' liste-color-swatch-none' : '') + (isActive ? ' active' : '');
const style = c.hex ? `style="--swatch:${c.hex}"` : '';
return `<button class="${cls}" data-color="${c.hex ?? ''}" ${style} title="${c.id}"></button>`;
}).join('')}
</div>
</div>
<div class="liste-form-group">
<label class="liste-form-label">Type de liste</label>
<select class="liste-modal-type">
${LIST_TYPES.map(t =>
`<option value="${esc(t.value)}" ${list?.list_type === t.value ? 'selected' : ''}>${t.emoji ? t.emoji + ' ' : ''}${esc(t.label)}</option>`
).join('')}
</select>
</div>
</div>
<div class="liste-modal-footer">
${!isNew && state.lists.length > 1
? `<button class="btn-tool btn-tool-danger liste-modal-delete">Supprimer</button>`
: ''}
<button class="btn-tool liste-modal-cancel">Annuler</button>
<button class="btn-tool btn-tool-primary liste-modal-save">Enregistrer</button>
</div>
</div>`;
document.body.appendChild(backdrop);
activeModal = backdrop;
const nameInput = backdrop.querySelector('.liste-modal-name');
nameInput.focus();
nameInput.setSelectionRange(nameInput.value.length, nameInput.value.length);
// Color swatches
backdrop.querySelectorAll('.liste-color-swatch').forEach(btn => {
btn.addEventListener('click', () => {
backdrop.querySelectorAll('.liste-color-swatch').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
const close = () => { backdrop.remove(); activeModal = null; };
backdrop.querySelector('.liste-modal-close').addEventListener('click', close);
backdrop.querySelector('.liste-modal-cancel').addEventListener('click', close);
backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); });
backdrop.querySelector('.liste-modal-delete')?.addEventListener('click', () => { close(); deleteList(listId); });
backdrop.querySelector('.liste-modal-save').addEventListener('click', () => saveListModal(backdrop, listId));
nameInput.addEventListener('keydown', e => {
if (e.key === 'Enter') saveListModal(backdrop, listId);
if (e.key === 'Escape') close();
});
}
async function saveListModal(backdrop, listId) {
const name = backdrop.querySelector('.liste-modal-name').value.trim();
if (!name) { backdrop.querySelector('.liste-modal-name').focus(); return; }
const activeSwatch = backdrop.querySelector('.liste-color-swatch.active');
const color = activeSwatch?.dataset.color || null;
const list_type = backdrop.querySelector('.liste-modal-type').value || null;
if (listId) {
const r = await api('lists', 'PUT', { name, color, list_type }, { id: listId });
if (r.ok) {
const list = state.lists.find(l => l.id === listId);
if (list) { list.name = name; list.color = color; list.list_type = list_type; }
backdrop.remove(); activeModal = null;
renderTabs();
toast('Liste mise à jour');
}
} else {
const r = await api('lists', 'POST', { name, color, list_type });
if (r.id) {
state.lists.push({ id: r.id, name, color: r.color, list_type: r.list_type, position: r.position });
state.currentListId = r.id;
backdrop.remove(); activeModal = null;
await loadItems();
renderTabs();
toast('Liste créée : ' + name);
}
}
}
// ── Categories ───────────────────────────────────────────────────────────────── // ── Categories ─────────────────────────────────────────────────────────────────
async function loadCategories() { async function loadCategories() {
@@ -104,49 +243,54 @@ function closeCategoryPicker() {
// ── Tabs ─────────────────────────────────────────────────────────────────────── // ── Tabs ───────────────────────────────────────────────────────────────────────
function typeEmoji(list_type) {
return LIST_TYPES.find(t => t.value === list_type)?.emoji || '';
}
function renderTabs() { function renderTabs() {
const container = document.getElementById('liste-tabs'); const container = document.getElementById('liste-tabs');
if (!container) return; if (!container) return;
container.innerHTML = ''; container.innerHTML = '';
state.lists.forEach(list => { state.lists.forEach(list => {
const isActive = list.id === state.currentListId;
const tab = document.createElement('div'); const tab = document.createElement('div');
tab.className = 'liste-tab' + (list.id === state.currentListId ? ' active' : ''); tab.className = 'liste-tab' + (isActive ? ' active' : '');
tab.dataset.id = list.id; tab.dataset.id = list.id;
if (list.id === state.currentListId) { // Apply custom color to entire tab
if (list.color) {
if (isActive) {
tab.style.cssText = `background:${list.color};border-color:${list.color};color:#fff`;
} else {
tab.style.cssText = `border-color:${list.color};color:${list.color}`;
}
}
const emoji = typeEmoji(list.list_type);
const prefix = emoji ? `<span class="liste-tab-type">${emoji}</span>` : '';
if (isActive) {
tab.innerHTML = ` tab.innerHTML = `
${prefix}
<span class="liste-tab-name">${esc(list.name)}</span> <span class="liste-tab-name">${esc(list.name)}</span>
<button class="liste-tab-btn liste-tab-rename" title="${esc(T.rename_list)}" data-id="${list.id}">✏</button> <button class="liste-tab-btn liste-tab-edit" title="Modifier" data-id="${list.id}">✏</button>
${state.lists.length > 1 ? `<button class="liste-tab-btn liste-tab-delete" title="Supprimer" data-id="${list.id}">×</button>` : ''}
`; `;
} else { } else {
tab.innerHTML = `<span class="liste-tab-name">${esc(list.name)}</span>`; tab.innerHTML = `${prefix}<span class="liste-tab-name">${esc(list.name)}</span>`;
tab.addEventListener('click', () => switchList(list.id)); tab.addEventListener('click', () => switchList(list.id));
} }
container.appendChild(tab); container.appendChild(tab);
}); });
container.querySelectorAll('.liste-tab-rename').forEach(btn => { container.querySelectorAll('.liste-tab-edit').forEach(btn => {
btn.addEventListener('click', e => { e.stopPropagation(); startRename(parseInt(btn.dataset.id)); }); btn.addEventListener('click', e => { e.stopPropagation(); openListModal(parseInt(btn.dataset.id)); });
});
container.querySelectorAll('.liste-tab-delete').forEach(btn => {
btn.addEventListener('click', e => { e.stopPropagation(); deleteList(parseInt(btn.dataset.id)); });
}); });
} }
function startRename(listId) { // ── List management ─────────────────────────────────────────────────────────────
const list = state.lists.find(l => l.id === listId);
if (!list) return; document.getElementById('btn-add-list')?.addEventListener('click', () => openListModal(null));
const name = prompt(T.new_name || 'Nouveau nom :', list.name);
if (name === null || name.trim() === '') return;
api('lists', 'PUT', { name: name.trim() }, { id: listId }).then(r => {
if (r.ok) {
list.name = name.trim();
renderTabs();
toast(T.list_renamed || 'Liste renommée');
}
});
}
async function deleteList(listId) { async function deleteList(listId) {
if (!confirm(T.confirm_delete_list || 'Supprimer cette liste et tous ses articles ?')) return; if (!confirm(T.confirm_delete_list || 'Supprimer cette liste et tous ses articles ?')) return;
@@ -161,41 +305,6 @@ async function deleteList(listId) {
toast(T.list_deleted || 'Liste supprimée'); toast(T.list_deleted || 'Liste supprimée');
} }
// ── List management ─────────────────────────────────────────────────────────────
document.getElementById('btn-add-list')?.addEventListener('click', () => {
document.getElementById('liste-new-form')?.classList.remove('hidden');
document.getElementById('btn-add-list')?.classList.add('hidden');
document.getElementById('new-list-name')?.focus();
});
document.getElementById('btn-cancel-new-list')?.addEventListener('click', cancelNewList);
document.getElementById('btn-confirm-new-list')?.addEventListener('click', confirmNewList);
document.getElementById('new-list-name')?.addEventListener('keydown', e => {
if (e.key === 'Enter') confirmNewList();
if (e.key === 'Escape') cancelNewList();
});
function cancelNewList() {
document.getElementById('liste-new-form')?.classList.add('hidden');
document.getElementById('btn-add-list')?.classList.remove('hidden');
if (document.getElementById('new-list-name')) document.getElementById('new-list-name').value = '';
}
async function confirmNewList() {
const input = document.getElementById('new-list-name');
const name = input?.value.trim();
if (!name) return;
const r = await api('lists', 'POST', { name });
if (r.id) {
state.lists.push({ id: r.id, name: r.name, position: r.position });
state.currentListId = r.id;
cancelNewList();
await loadItems();
renderTabs();
toast((T.list_created || 'Liste créée') + ' : ' + r.name);
}
}
// ── Switch list ───────────────────────────────────────────────────────────────── // ── Switch list ─────────────────────────────────────────────────────────────────
async function switchList(listId) { async function switchList(listId) {
@@ -207,10 +316,7 @@ async function switchList(listId) {
// ── Items ─────────────────────────────────────────────────────────────────────── // ── Items ───────────────────────────────────────────────────────────────────────
async function loadItems() { async function loadItems() {
if (!state.currentListId) { if (!state.currentListId) { renderItems(); return; }
renderItems();
return;
}
const r = await api('items', 'GET', null, { list_id: state.currentListId }); const r = await api('items', 'GET', null, { list_id: state.currentListId });
state.items = r.items || []; state.items = r.items || [];
renderItems(); renderItems();
@@ -236,15 +342,12 @@ function renderItems() {
let html = ''; let html = '';
if (hasCategories && state.categories.length) { if (hasCategories && state.categories.length) {
// Group pending items by category
const groups = new Map(); const groups = new Map();
pending.forEach(item => { pending.forEach(item => {
const key = item.category_id ?? 0; const key = item.category_id ?? 0;
if (!groups.has(key)) groups.set(key, []); if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(item); groups.get(key).push(item);
}); });
// Render in category order, uncategorized last
const catIds = state.categories.map(c => parseInt(c.id)); const catIds = state.categories.map(c => parseInt(c.id));
const orderedKeys = catIds.filter(id => groups.has(id)); const orderedKeys = catIds.filter(id => groups.has(id));
if (groups.has(0)) orderedKeys.push(0); if (groups.has(0)) orderedKeys.push(0);
@@ -284,7 +387,7 @@ function rowHtml(item) {
const checked = item.in_cart ? 'checked' : ''; const checked = item.in_cart ? 'checked' : '';
const cls = item.in_cart ? 'liste-row in-cart' : 'liste-row'; const cls = item.in_cart ? 'liste-row in-cart' : 'liste-row';
const cat = item.category_id ? state.catMap[item.category_id] : null; const cat = item.category_id ? state.catMap[item.category_id] : null;
const badgeCls = 'liste-cat-badge' + (cat ? '' : ' liste-cat-badge-empty'); const badgeCls = 'liste-cat-badge' + (cat ? '' : ' liste-cat-badge-empty');
const badgeIcon = cat ? esc(cat.icon) : '🏷️'; const badgeIcon = cat ? esc(cat.icon) : '🏷️';
const badgeTip = cat ? esc(cat.name) : 'Non classé'; const badgeTip = cat ? esc(cat.name) : 'Non classé';
return `<div class="${cls}" data-id="${item.id}"> return `<div class="${cls}" data-id="${item.id}">
+243
View File
@@ -0,0 +1,243 @@
<?php
// modules/voyage/api.php
// Toll cost estimator API for HouseHub
require_once dirname(__DIR__, 2) . '/includes/auth.php';
require_login();
header('Content-Type: application/json; charset=utf-8');
$action = $_GET['action'] ?? '';
if ($action !== 'estimate') {
echo json_encode(['ok' => false, 'error' => 'Action inconnue']);
exit;
}
// --- Read POST body ---
$body = file_get_contents('php://input');
$params = json_decode($body, true);
if (!$params || empty($params['stops']) || count($params['stops']) < 2) {
echo json_encode(['ok' => false, 'error' => 'Paramètres invalides (stops requis, min 2)']);
exit;
}
$stops = $params['stops'];
$fuelL100 = (float)($params['fuel_l100'] ?? 7);
$fuelPrice= (float)($params['fuel_price'] ?? 1.85);
// --- Load databases ---
$tollsPath = __DIR__ . '/data/tolls.json';
$gpsPath = __DIR__ . '/data/toll_gps.json';
if (!file_exists($tollsPath)) {
echo json_encode(['ok' => false, 'error' => 'Base de péages introuvable']);
exit;
}
$tollsRaw = json_decode(file_get_contents($tollsPath), true);
$tollData = $tollsRaw['data'] ?? [];
$gpsData = [];
if (file_exists($gpsPath)) {
$gpsData = json_decode(file_get_contents($gpsPath), true) ?? [];
}
// --- Build lookup index: [op][entry][exit] => c1_price ---
$tollIndex = [];
foreach ($tollData as $row) {
$op = $row['op'];
$e = $row['e'];
$x = $row['x'];
$c1 = (float)$row['c1'];
$tollIndex[$op][$e][$x] = $c1;
}
// --- Helper: great-circle distance (km) ---
function haversine(float $lat1, float $lng1, float $lat2, float $lng2): float {
$R = 6371.0;
$dLat = deg2rad($lat2 - $lat1);
$dLng = deg2rad($lng2 - $lng1);
$a = sin($dLat/2)**2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng/2)**2;
return $R * 2 * atan2(sqrt($a), sqrt(1 - $a));
}
// --- Find plazas near a coordinate (within $maxKm) ---
// Returns array of [plaza_name => distance_km]
function plazasNear(array $gpsData, float $lat, float $lng, float $maxKm): array {
$result = [];
foreach ($gpsData as $name => $info) {
$d = haversine($lat, $lng, $info['lat'], $info['lng']);
if ($d <= $maxKm) {
$result[$name] = $d;
}
}
asort($result);
return $result;
}
// --- Look up toll price for (op, entry, exit) with fallback ---
function lookupToll(array $tollIndex, string $op, string $entry, string $exit): ?float {
// Direct lookup
if (isset($tollIndex[$op][$entry][$exit])) {
return $tollIndex[$op][$entry][$exit];
}
// Reversed
if (isset($tollIndex[$op][$exit][$entry])) {
return $tollIndex[$op][$exit][$entry];
}
return null;
}
// --- OSRM route distance ---
function getRouteDistanceKm(float $lat1, float $lng1, float $lat2, float $lng2): ?float {
$url = sprintf(
'https://router.project-osrm.org/route/v1/driving/%f,%f;%f,%f?overview=false',
$lng1, $lat1, $lng2, $lat2
);
$ctx = stream_context_create([
'http' => [
'timeout' => 10,
'header' => "User-Agent: HouseHub/1.0\r\n"
]
]);
$resp = @file_get_contents($url, false, $ctx);
if ($resp === false) return null;
$data = json_decode($resp, true);
if (!empty($data['routes'][0]['distance'])) {
return (float)$data['routes'][0]['distance'] / 1000.0;
}
return null;
}
// --- Main: process each consecutive stop pair ---
$segments = [];
$totalKm = 0.0;
$totalToll = 0.0;
for ($i = 0; $i < count($stops) - 1; $i++) {
$orig = $stops[$i];
$dest = $stops[$i + 1];
$oLat = (float)$orig['lat'];
$oLng = (float)$orig['lng'];
$dLat = (float)$dest['lat'];
$dLng = (float)$dest['lng'];
$fromName = $orig['name'] ?? "Étape " . ($i + 1);
$toName = $dest['name'] ?? "Étape " . ($i + 2);
// Distance via OSRM
$distKm = getRouteDistanceKm($oLat, $oLng, $dLat, $dLng);
if ($distKm === null) {
// Fallback: straight-line distance * 1.25
$distKm = haversine($oLat, $oLng, $dLat, $dLng) * 1.25;
}
$totalKm += $distKm;
// --- Find toll ---
$segToll = 0.0;
$entryPlaza = null;
$exitPlaza = null;
$tollNote = null;
if (!empty($gpsData)) {
// Find plazas near origin (80km) and destination (80km)
$nearOrig = plazasNear($gpsData, $oLat, $oLng, 80.0);
$nearDest = plazasNear($gpsData, $dLat, $dLng, 80.0);
$bestToll = null;
$bestDistSum = PHP_FLOAT_MAX;
$bestEntry = null;
$bestExit = null;
$bestOp = null;
// Try each operator
foreach ($tollIndex as $op => $opEntries) {
// Find candidate entry plazas (near origin, present in this operator)
$candidateEntries = [];
foreach ($nearOrig as $pName => $dist) {
if (isset($opEntries[$pName]) || isset(array_flip(array_keys($opEntries))[$pName])) {
// Plaza exists as entry in this operator
if (isset($opEntries[$pName])) {
$candidateEntries[$pName] = $dist;
}
}
// Also check if it's an exit plaza (reversed lookup later)
// Include any plaza from this operator that is within range
}
// Build set of all plazas for this operator
$opPlazas = [];
foreach ($opEntries as $entry => $exits) {
$opPlazas[$entry] = true;
foreach ($exits as $exit => $price) {
$opPlazas[$exit] = true;
}
}
// Filter nearOrig and nearDest to only plazas in this operator
$opNearOrig = array_intersect_key($nearOrig, $opPlazas);
$opNearDest = array_intersect_key($nearDest, $opPlazas);
if (empty($opNearOrig) || empty($opNearDest)) continue;
// Try best 5 entry and best 5 exit candidates
$topEntries = array_slice($opNearOrig, 0, 5, true);
$topExits = array_slice($opNearDest, 0, 5, true);
foreach ($topEntries as $ePlaza => $eDist) {
foreach ($topExits as $xPlaza => $xDist) {
if ($ePlaza === $xPlaza) continue;
$price = lookupToll($tollIndex, $op, $ePlaza, $xPlaza);
if ($price !== null) {
$distSum = $eDist + $xDist;
if ($distSum < $bestDistSum) {
$bestDistSum = $distSum;
$bestToll = $price;
$bestEntry = $ePlaza;
$bestExit = $xPlaza;
$bestOp = $op;
}
}
}
}
}
if ($bestToll !== null) {
$segToll = $bestToll;
$entryPlaza = $bestEntry;
$exitPlaza = $bestExit;
} else {
$tollNote = 'péages non estimés pour ce trajet';
}
} else {
$tollNote = 'base GPS des péages non disponible';
}
$totalToll += $segToll;
$seg = [
'from' => $fromName,
'to' => $toName,
'distance_km' => round($distKm, 1),
'toll' => round($segToll, 2),
];
if ($entryPlaza) $seg['entry_plaza'] = $entryPlaza;
if ($exitPlaza) $seg['exit_plaza'] = $exitPlaza;
if ($bestOp) $seg['op'] = $bestOp;
if ($tollNote) $seg['note'] = $tollNote;
$segments[] = $seg;
}
$fuelCost = round(($totalKm / 100.0) * $fuelL100 * $fuelPrice, 2);
$grandTotal = round($totalToll + $fuelCost, 2);
echo json_encode([
'ok' => true,
'segments' => $segments,
'total_km' => round($totalKm, 1),
'total_toll' => round($totalToll, 2),
'fuel_cost' => $fuelCost,
'grand_total' => $grandTotal,
], JSON_UNESCAPED_UNICODE);
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long