EasyBudget
Gérez votre budget simplement
💡 Assistant EasyBudget
Clique sur ↻ Rafraîchir pour lancer l'analyse.
📵 Mode hors-ligne — Les modifications ne seront pas sauvegardées
EasyBudget
Votre budget, simplement
`; const blob = new Blob([html], { type: 'text/html' }); const url = URL.createObjectURL(blob); const win = window.open(url, '_blank'); if (win) { win.onload = () => { win.print(); }; notify('PDF ouvert — utilise Ctrl+P pour imprimer/sauvegarder !'); } else { notify('Active les popups pour générer le PDF'); } } async function exportCSV() { notify('Génération du CSV...'); const d = await gatherExportData(); let csv = `Budget ${d.moisNom} ${d.a}\n\n`; csv += `Gains;${d.gains}\nDépenses;${d.depenses}\nÉpargne;${d.epargne}\n\n`; csv += `DÉPENSES FIXES\nCatégorie;Description;Montant\n`; d.fixes.forEach(r => { csv += `${r.categorie||''};${r.description||''};${r.montant}\n`; }); csv += `\nDÉPENSES VARIABLES\nCatégorie;Description;Estimé;Réel\n`; d.vars.forEach(r => { csv += `${r.categorie||''};${r.description||''};${r.montant};${r.montant_reel??''}\n`; }); csv += `\nABONNEMENTS\nService;Montant\n`; d.abos.forEach(r => { csv += `${r.service||''};${r.montant}\n`; }); const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `budget_${d.moisNom}_${d.a}.csv`; a.click(); URL.revokeObjectURL(url); notify('CSV téléchargé !'); } // ===== SIMULATEUR ===== function switchSim(panel, btn) { document.querySelectorAll('.sim-tab').forEach(b => b.classList.remove('active')); document.querySelectorAll('.sim-panel').forEach(p => p.classList.remove('active')); btn.classList.add('active'); document.getElementById('sim-' + panel).classList.add('active'); if (panel === 'objectif') calcObjectif(); if (panel === 'projection') calcProjection(); if (panel === 'credit') calcCredit(); } function fmtSim(n) { return Math.round(n).toLocaleString('fr-FR') + ' €'; } function calcObjectif() { const cible = parseInt(document.getElementById('obj-cible').value); const epargne = parseInt(document.getElementById('obj-epargne').value); const deja = parseInt(document.getElementById('obj-deja').value); document.getElementById('obj-cible-val').textContent = fmtSim(cible); document.getElementById('obj-epargne-val').textContent = fmtSim(epargne); document.getElementById('obj-deja-val').textContent = fmtSim(deja); const restant = Math.max(0, cible - deja); const mois = epargne > 0 ? Math.ceil(restant / epargne) : 0; const ans = Math.floor(mois / 12); const moisReste = mois % 12; let dureeStr = ''; if (mois === 0) dureeStr = 'Objectif déjà atteint ! 🎉'; else if (ans === 0) dureeStr = `${moisReste} mois`; else if (moisReste === 0) dureeStr = `${ans} an${ans > 1 ? 's' : ''}`; else dureeStr = `${ans} an${ans > 1 ? 's' : ''} et ${moisReste} mois`; const dateAtteinte = new Date(); dateAtteinte.setMonth(dateAtteinte.getMonth() + mois); const moisNoms = ['jan.','fév.','mars','avr.','mai','juin','juil.','août','sep.','oct.','nov.','déc.']; const dateStr = moisNoms[dateAtteinte.getMonth()] + ' ' + dateAtteinte.getFullYear(); document.getElementById('sim-objectif-result').innerHTML = `
${dureeStr}
${mois > 0 ? 'Pour atteindre ' + fmtSim(cible) + ' — objectif en ' + dateStr : 'Félicitations !'}
Restant à épargner
${fmtSim(restant)}
Épargne / mois
${fmtSim(epargne)}
Déjà économisé
${fmtSim(deja)}
`; } function calcProjection() { const epargne = parseInt(document.getElementById('proj-epargne').value); const duree = parseInt(document.getElementById('proj-duree').value); const taux = parseFloat(document.getElementById('proj-taux').value); document.getElementById('proj-epargne-val').textContent = fmtSim(epargne); document.getElementById('proj-duree-val').textContent = duree + ' an' + (duree > 1 ? 's' : ''); document.getElementById('proj-taux-val').textContent = taux + ' %'; const n = duree * 12; const r = taux / 100 / 12; let total; if (r === 0) { total = epargne * n; } else { total = epargne * ((Math.pow(1 + r, n) - 1) / r); } const totalVerse = epargne * n; const interets = total - totalVerse; document.getElementById('sim-projection-result').innerHTML = `
${fmtSim(total)}
Capital accumulé en ${duree} an${duree > 1 ? 's' : ''} à ${taux}% d'intérêt annuel
Total versé
${fmtSim(totalVerse)}
Intérêts gagnés
${fmtSim(interets)}
Capital final
${fmtSim(total)}
`; } function calcCredit() { const montant = parseInt(document.getElementById('cred-montant').value); const taux = parseFloat(document.getElementById('cred-taux').value); const duree = parseInt(document.getElementById('cred-duree').value); document.getElementById('cred-montant-val').textContent = fmtSim(montant); document.getElementById('cred-taux-val').textContent = taux + ' %'; document.getElementById('cred-duree-val').textContent = duree + ' an' + (duree > 1 ? 's' : ''); const n = duree * 12; const r = taux / 100 / 12; let mensualite; if (r === 0) { mensualite = montant / n; } else { mensualite = montant * (r * Math.pow(1 + r, n)) / (Math.pow(1 + r, n) - 1); } const totalRembourse = mensualite * n; const coutCredit = totalRembourse - montant; document.getElementById('sim-credit-result').innerHTML = `
${fmtSim(mensualite)} / mois
Mensualité pour un crédit de ${fmtSim(montant)} sur ${duree} an${duree > 1 ? 's' : ''}
Total remboursé
${fmtSim(totalRembourse)}
Coût du crédit
${fmtSim(coutCredit)}
Mensualité
${fmtSim(mensualite)}
`; } // Init simulateur au chargement de la page function initSimulateur() { calcObjectif(); calcProjection(); calcCredit(); calcBrutNet(); } // COULEUR TEXTE let currentTextColor = '#1a1a1a'; function setTextColor(c, el) { currentTextColor = c; // N'appliquer que si on est en mode personnalisé if (currentPreset === 'custom') { document.documentElement.style.setProperty('--text', c); document.documentElement.style.setProperty('--text2', c + 'bb'); document.documentElement.style.setProperty('--text3', c + '77'); } document.querySelectorAll('.bg-opt[id^="text-opt"]').forEach(b => b.classList.remove('active')); if (el) el.classList.add('active'); } // THÈMES SAUVEGARDÉS function saveCustomTheme() { const name = prompt('Nom de ce thème :'); if (!name) return; const themes = JSON.parse(localStorage.getItem('eb_custom_themes') || '[]'); themes.push({ name, accent: currentAccent, bg: currentBg, text: currentTextColor, font: currentFont, theme: currentTheme }); localStorage.setItem('eb_custom_themes', JSON.stringify(themes)); loadSavedThemes(); notify('Thème "' + name + '" sauvegardé !'); } function loadSavedThemes() { const themes = JSON.parse(localStorage.getItem('eb_custom_themes') || '[]'); const el = document.getElementById('saved-themes-list'); if (!el) return; if (!themes.length) { el.innerHTML = '
Aucun thème sauvegardé
'; return; } el.innerHTML = themes.map((t, i) => `
${t.name}
`).join(''); } function applyCustomTheme(i) { const themes = JSON.parse(localStorage.getItem('eb_custom_themes') || '[]'); const t = themes[i]; if (!t) return; applyAccent(t.accent); applyBg(t.bg); if (t.text) setTextColor(t.text, null); if (t.font) applyFont(t.font); if (t.theme) applyTheme(t.theme); notify('Thème "' + t.name + '" appliqué !'); } function deleteCustomTheme(i) { const themes = JSON.parse(localStorage.getItem('eb_custom_themes') || '[]'); themes.splice(i, 1); localStorage.setItem('eb_custom_themes', JSON.stringify(themes)); loadSavedThemes(); } // ===== MODE HORS-LIGNE ===== function updateOnlineStatus() { const banner = document.getElementById('offline-banner'); if (!navigator.onLine) { banner.classList.add('show'); } else { banner.classList.remove('show'); } } window.addEventListener('online', updateOnlineStatus); window.addEventListener('offline', updateOnlineStatus); updateOnlineStatus(); // ===== GRAPHIQUE ÉVOLUTION CUMULATIVE ===== let chartEvolution = null; let chartTendances = null; let tendancePeriode = 12; function setTendancePeriode(n) { tendancePeriode = n; const b6 = document.getElementById('btn-tendance-6'); const b12 = document.getElementById('btn-tendance-12'); if(b6) b6.className = 'btn btn-sm' + (n===6?' btn-primary':''); if(b12) b12.className = 'btn btn-sm' + (n===12?' btn-primary':''); loadTendancesChart(); } async function loadTendancesChart() { if(!U) return; const now = new Date(); const [ { data: allRecaps }, { data: allFixes }, { data: allVars } ] = await Promise.all([ sb.from('recap_mensuel').select('mois,annee,gains,depenses').eq('user_id', U.id).order('annee').order('mois'), sb.from('depenses_fixes').select('montant').eq('user_id', U.id), sb.from('depenses_variables').select('montant,annee,mois').eq('user_id', U.id) ]); if (!allRecaps || !allRecaps.length) return; const totalFixes = (allFixes||[]).reduce((s,r) => s + parseFloat(r.montant||0), 0); const moisLabels = []; const moisKeys = []; for (let i = tendancePeriode - 1; i >= 0; i--) { const d = new Date(now.getFullYear(), now.getMonth() - i, 1); moisKeys.push({ annee: d.getFullYear(), mois: d.getMonth() + 1 }); const nom = ['Jan','Fév','Mar','Avr','Mai','Jun','Jul','Aoû','Sep','Oct','Nov','Déc'][d.getMonth()]; moisLabels.push(nom + ' ' + d.getFullYear().toString().slice(2)); } const fixesData = []; const varsData = []; const epargneData = []; moisKeys.forEach(function(mk) { const annee = mk.annee, mois = mk.mois; const recap = (allRecaps||[]).find(function(r){ return r.annee===annee && r.mois===mois; }); const varsMonth = (allVars||[]) .filter(function(r){ return r.annee===annee && r.mois===mois; }) .reduce(function(s,r){ return s + parseFloat(r.montant||0); }, 0); if (recap) { fixesData.push(Math.round(totalFixes)); varsData.push(Math.round(varsMonth)); epargneData.push(Math.round(parseFloat(recap.gains||0) - totalFixes - varsMonth)); } else { fixesData.push(null); varsData.push(null); epargneData.push(null); } }); const canvas = document.getElementById('chart-tendances'); if (!canvas) return; const ctx = canvas.getContext('2d'); if (chartTendances) { chartTendances.destroy(); chartTendances = null; } chartTendances = new Chart(ctx, { type: 'line', data: { labels: moisLabels, datasets: [ { label: 'Fixes', data: fixesData, borderColor: '#e74c3c', backgroundColor: 'rgba(231,76,60,0.08)', fill: true, tension: 0.3, pointRadius: 4, borderWidth: 2, spanGaps: false }, { label: 'Variables', data: varsData, borderColor: '#f39c12', backgroundColor: 'rgba(243,156,18,0.08)', fill: true, tension: 0.3, pointRadius: 4, borderWidth: 2, spanGaps: false }, { label: 'Epargne nette', data: epargneData, borderColor: '#2d6a4f', backgroundColor: 'rgba(45,106,79,0.08)', fill: true, tension: 0.3, pointRadius: 4, borderWidth: 2, spanGaps: false } ] }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, plugins: { legend: { display: false }, tooltip: { callbacks: { label: function(ctx){ return ' ' + ctx.dataset.label + ' : ' + (ctx.raw !== null ? fmt(ctx.raw) : '—'); } } } }, scales: { x: { ticks: { color: 'var(--text3)', font: { size: 11 }, maxRotation: 45 }, grid: { color: 'var(--border)' } }, y: { ticks: { color: 'var(--text3)', font: { size: 11 }, callback: function(v){ return fmt(v); } }, grid: { color: 'var(--border)' } } } } }); const validEpargne = epargneData.filter(function(v){ return v !== null; }); if (validEpargne.length >= 2) { const diff = validEpargne[validEpargne.length-1] - validEpargne[0]; const moy = Math.round(validEpargne.reduce(function(a,b){return a+b;},0) / validEpargne.length); const trend = diff > 50 ? '📈 En hausse' : diff < -50 ? '📉 En baisse' : '➡️ Stable'; const insightEl = document.getElementById('tendances-insight'); if (insightEl) { insightEl.style.display = 'block'; insightEl.innerHTML = trend + ' — Épargne moyenne : ' + fmt(moy) + '/mois. ' + (diff > 50 ? 'Bonne dynamique !' : diff < -50 ? 'Attention, tendance à surveiller.' : 'Tendance régulière.'); } } } async function loadEvolutionChart() { const sel = document.getElementById('evo-annee'); const annee = sel ? parseInt(sel.value) : new Date().getFullYear(); // Peupler le select avec les années disponibles const { data: allRecaps } = await sb.from('recap_mensuel') .select('mois,annee,gains,depenses').eq('user_id', U.id).order('annee').order('mois'); if (sel && allRecaps) { const years = [...new Set(allRecaps.map(r => r.annee))].sort(); const currentVal = sel.value; sel.innerHTML = years.map(y => ``).join(''); if (!years.includes(annee) && years.length) sel.value = years[years.length - 1]; } const moisNoms = ['Jan','Fév','Mar','Avr','Mai','Jun','Jul','Aoû','Sep','Oct','Nov','Déc']; const labels = moisNoms; // Données de l'année sélectionnée const recapsAnnee = (allRecaps||[]).filter(r => r.annee === annee); // Épargne cumulée : part de 0, s'accumule mois par mois let cumul = 0; const cumulData = []; const pointColors = []; for (let m = 1; m <= 12; m++) { const r = recapsAnnee.find(r => r.mois === m); if (r && r.gains) { cumul += parseFloat(r.gains) - parseFloat(r.depenses || 0); cumulData.push(Math.round(cumul)); pointColors.push(cumul >= 0 ? '#2d6a4f' : '#e74c3c'); } else { cumulData.push(null); // mois sans données pointColors.push('#f39c12'); } } const ctx = document.getElementById('chart-evolution')?.getContext('2d'); if (!ctx) return; if (chartEvolution) { chartEvolution.destroy(); chartEvolution = null; } chartEvolution = new Chart(ctx, { type: 'line', data: { labels, datasets: [{ label: 'Épargne cumulée', data: cumulData, borderColor: '#2d6a4f', backgroundColor: (ctx) => { const chart = ctx.chart; const { ctx: c, chartArea } = chart; if (!chartArea) return 'rgba(45,106,79,0.1)'; const gradient = c.createLinearGradient(0, chartArea.top, 0, chartArea.bottom); gradient.addColorStop(0, 'rgba(45,106,79,0.25)'); gradient.addColorStop(1, 'rgba(45,106,79,0)'); return gradient; }, borderWidth: 2.5, pointRadius: cumulData.map(v => v !== null ? 5 : 0), pointBackgroundColor: pointColors, pointBorderColor: '#fff', pointBorderWidth: 2, fill: true, tension: 0.35, spanGaps: false }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { callbacks: { label: ctx => ctx.raw !== null ? (ctx.raw >= 0 ? '+' : '') + ctx.raw.toLocaleString('fr-FR') + ' €' : 'Pas de données' } } }, scales: { x: { grid: { display: false }, ticks: { color: () => getComputedStyle(document.documentElement).getPropertyValue('--text3').trim() || '#888', font: { size: 11 } } }, y: { grid: { color: 'rgba(0,0,0,0.05)' }, ticks: { color: () => getComputedStyle(document.documentElement).getPropertyValue('--text3').trim() || '#888', font: { size: 11 }, callback: v => v + ' €' }, beginAtZero: true } } } }); } // ===== BADGES ===== const BADGES_DEF = [ { id: 'premier_pas', emoji: '👶', name: 'Premier pas', desc: 'Ajouter ta première dépense fixe', check: (d) => d.fixes > 0 }, { id: 'epargnant', emoji: '💰', name: 'Épargnant', desc: 'Épargner au moins 1 mois positif', check: (d) => d.moisPositifs >= 1 }, { id: 'regulier', emoji: '📅', name: 'Régulier', desc: 'Renseigner 3 mois consécutifs', check: (d) => d.moisTotal >= 3 }, { id: 'pro', emoji: '🏆', name: 'Pro', desc: 'Renseigner 6 mois consécutifs', check: (d) => d.moisTotal >= 6 }, { id: 'investisseur', emoji: '📈', name: 'Investisseur', desc: 'Ajouter un investissement', check: (d) => d.investissements > 0 }, { id: 'objectif', emoji: '🎯', name: 'Objectif atteint', desc: 'Atteindre 100% d\'un objectif épargne', check: (d) => d.objectifAtteint }, { id: 'abonne', emoji: '📋', name: 'Gestionnaire', desc: 'Avoir 3 abonnements suivis', check: (d) => d.abonnements >= 3 }, { id: 'mille', emoji: '💎', name: 'Millionnaire', desc: 'Épargner 1 000€ en un mois', check: (d) => d.maxEpargne >= 1000 }, { id: 'taux30', emoji: '🚀', name: 'Taux 30%', desc: 'Taux d\'épargne de 30% ou plus', check: (d) => d.maxTaux >= 30 }, ]; async function loadBadges() { const [ { data: fixes }, { data: recaps }, { data: inv }, { data: abos }, { data: epargnes } ] = await Promise.all([ sb.from('depenses_fixes').select('id').eq('user_id', U.id), sb.from('recap_mensuel').select('*').eq('user_id', U.id).order('annee').order('mois'), sb.from('investissements').select('id').eq('user_id', U.id), sb.from('abonnements').select('id').eq('user_id', U.id), sb.from('objectifs_epargne').select('*').eq('user_id', U.id) ]); const moisPositifs = (recaps||[]).filter(r => parseFloat(r.gains||0) > parseFloat(r.depenses||0)).length; const maxEpargne = Math.max(...(recaps||[]).map(r => parseFloat(r.gains||0) - parseFloat(r.depenses||0)), 0); const maxTaux = Math.max(...(recaps||[]).map(r => r.gains > 0 ? Math.round(((r.gains - (r.depenses||0)) / r.gains) * 100) : 0), 0); const objectifAtteint = (epargnes||[]).some(e => e.objectif > 0 && (e.epargne_actuelle||0) >= e.objectif); const data = { fixes: (fixes||[]).length, moisTotal: (recaps||[]).length, moisPositifs, investissements: (inv||[]).length, abonnements: (abos||[]).length, objectifAtteint, maxEpargne, maxTaux }; const earned = JSON.parse(localStorage.getItem('eb_badges_earned') || '[]'); const newlyEarned = []; const grid = document.getElementById('badges-grid'); if (!grid) return; grid.innerHTML = BADGES_DEF.map(b => { const isEarned = b.check(data); if (isEarned && !earned.includes(b.id)) newlyEarned.push(b); return `
${b.emoji}
${b.name}
${b.desc}
${isEarned ? '
✓ Obtenu
' : ''}
`; }).join(''); // Notifier les nouveaux badges if (newlyEarned.length > 0) { const newIds = [...earned, ...newlyEarned.map(b => b.id)]; localStorage.setItem('eb_badges_earned', JSON.stringify(newIds)); newlyEarned.forEach(b => { setTimeout(() => notify('🏆 Nouveau badge : ' + b.name + ' ' + b.emoji), 500); }); } } // ===== AVIS & SUGGESTIONS ===== let currentRating = 0; const ADMIN_EMAIL = 'erwannchauvet@gmail.com'; function setRating(val) { currentRating = val; document.querySelectorAll('.star').forEach((s, i) => { s.textContent = i < val ? '⭐' : '☆'; }); } async function loadAvisPage() { // Afficher le panel admin si c'est moi const isAdmin = U.email === ADMIN_EMAIL; const adminCard = document.getElementById('avis-admin-card'); const usersCard = document.getElementById('admin-users-card'); if (adminCard) adminCard.style.display = isAdmin ? 'block' : 'none'; if (usersCard) usersCard.style.display = isAdmin ? 'block' : 'none'; if (isAdmin) { loadAdminAvis(); loadAdminUsers(); } } async function submitAvis() { if (!currentRating) { notify('Choisis une note avant d\'envoyer !'); return; } const texte = document.getElementById('avis-texte').value.trim(); const { error } = await sb.from('avis').insert({ user_id: U.id, note: currentRating, suggestion: texte || null }); if (error) { notify('Erreur: ' + error.message); return; } document.getElementById('avis-confirm').style.display = 'block'; document.getElementById('avis-texte').value = ''; setRating(0); notify('Merci pour ton avis ! ⭐'); } async function loadAdminAvis() { const { data: avis } = await sb.from('avis').select('*').order('created_at', {ascending: false}); if (!avis || !avis.length) { document.getElementById('admin-avis-list').innerHTML = '
Aucun avis pour l\'instant.
'; return; } const moy = (avis.reduce((s, a) => s + a.note, 0) / avis.length).toFixed(1); const dist = [1,2,3,4,5].map(n => avis.filter(a => a.note === n).length); document.getElementById('admin-stats').innerHTML = `
Note moyenne
${moy} ⭐
Total avis
${avis.length}
5 étoiles
${dist[4]}
`; document.getElementById('admin-avis-list').innerHTML = avis.map(a => { const date = new Date(a.created_at).toLocaleDateString('fr-FR'); const stars = '⭐'.repeat(a.note) + '☆'.repeat(5 - a.note); return `
${stars} ${date}
${a.suggestion ? `
${a.suggestion}
` : '
Pas de commentaire
'}
`; }).join(''); } // ===== BRUT → NET ===== let currentStatut = 'non_cadre'; let showBNDetail = false; // Taux de cotisations salariales (approximatifs 2024) const COTISATIONS = { non_cadre: [ { nom: 'Assurance maladie', taux: 0.0 }, { nom: 'Assurance vieillesse plafonnée', taux: 6.9 }, { nom: 'Assurance vieillesse déplafonnée', taux: 0.4 }, { nom: 'Retraite complémentaire (T1)', taux: 3.15 }, { nom: 'CEG (T1)', taux: 0.86 }, { nom: 'Assurance chômage', taux: 0.0 }, { nom: 'CSG déductible', taux: 6.8 }, { nom: 'CSG/CRDS non déductible', taux: 2.9 }, ], cadre: [ { nom: 'Assurance maladie', taux: 0.0 }, { nom: 'Assurance vieillesse plafonnée', taux: 6.9 }, { nom: 'Assurance vieillesse déplafonnée', taux: 0.4 }, { nom: 'Retraite complémentaire (T1)', taux: 3.15 }, { nom: 'Retraite complémentaire (T2)', taux: 8.64 }, { nom: 'CEG', taux: 0.86 }, { nom: 'CET', taux: 0.14 }, { nom: 'CSG déductible', taux: 6.8 }, { nom: 'CSG/CRDS non déductible', taux: 2.9 }, ], fp: [ { nom: 'Cotisation pension civile', taux: 11.1 }, { nom: 'CSG déductible', taux: 6.8 }, { nom: 'CSG/CRDS non déductible', taux: 2.9 }, { nom: 'RAFP (retraite additionnelle)', taux: 5.0 }, { nom: 'Contribution solidarité', taux: 1.0 }, ], liberal: [ { nom: 'Maladie-maternité', taux: 6.5 }, { nom: 'Retraite de base', taux: 17.75 }, { nom: 'Retraite complémentaire', taux: 7.0 }, { nom: 'Invalidité-décès', taux: 1.3 }, { nom: 'CSG déductible', taux: 6.8 }, { nom: 'CSG/CRDS non déductible', taux: 2.9 }, ] }; function setStatut(statut, btn) { currentStatut = statut; document.querySelectorAll('.statut-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); calcBrutNet(); } function toggleBNDetail() { showBNDetail = !showBNDetail; document.getElementById('bn-detail').style.display = showBNDetail ? 'block' : 'none'; } function calcBrutNet() { const brut = parseInt(document.getElementById('bn-brut').value); const pas = parseFloat(document.getElementById('bn-pas').value); document.getElementById('bn-brut-val').textContent = brut.toLocaleString('fr-FR') + ' €'; document.getElementById('bn-pas-val').textContent = pas + ' %'; const cotis = COTISATIONS[currentStatut] || COTISATIONS.non_cadre; const totalTaux = cotis.reduce((s, c) => s + c.taux, 0); const totalCotis = brut * totalTaux / 100; const netAvantImpot = brut - totalCotis; const impot = netAvantImpot * pas / 100; const netApresImpot = netAvantImpot - impot; // Mettre à jour le slider avec le brut calculé document.getElementById('bn-brut').value = brut; document.getElementById('bn-brut-val').textContent = brut.toLocaleString('fr-FR') + ' €'; // Synchroniser tous les champs depuis le brut mensuel syncBNFromBrutMensuel(brut, netAvantImpot, netApresImpot); // Détail cotisations document.getElementById('bn-detail-lines').innerHTML = cotis.map(c => { const montant = brut * c.taux / 100; return `
${c.nom} ${c.taux}% = ${Math.round(montant)} €
`; }).join('') + `
Total cotisations ${Math.round(totalCotis)} € (${totalTaux.toFixed(1)}%)
`; } // SYNC CHAMPS BRUT/NET const HEURES_MOIS = 151.67; function getBNTaux() { const brut = parseInt(document.getElementById('bn-brut').value) || 2500; const cotis = COTISATIONS[currentStatut] || COTISATIONS.non_cadre; const totalTaux = cotis.reduce((s, c) => s + c.taux, 0); const pas = parseFloat(document.getElementById('bn-pas').value) || 0; const netRatio = 1 - totalTaux / 100; // ratio net/brut const pasRatio = 1 - pas / 100; return { netRatio, pasRatio, totalTaux, pas }; } function syncBNFromBrutMensuel(brutM, netM, netApresImpot) { const r2 = (n) => Math.round(n * 100) / 100; const setVal = (id, v) => { const el = document.getElementById(id); if (el && document.activeElement !== el) el.value = v > 0 ? v : ''; }; setVal('bn-brut-horaire', r2(brutM / HEURES_MOIS)); setVal('bn-net-horaire', r2(netM / HEURES_MOIS)); setVal('bn-brut-mensuel', Math.round(brutM)); setVal('bn-net-mensuel', Math.round(netM)); setVal('bn-brut-annuel', Math.round(brutM * 12)); setVal('bn-net-annuel', Math.round(netM * 12)); document.getElementById('bn-net-apres-impot').textContent = Math.round(netApresImpot).toLocaleString('fr-FR') + ' €'; } function syncBN(source) { const { netRatio, pasRatio } = getBNTaux(); let brutM = 0; const v = parseFloat(document.getElementById('bn-' + source.replace('_', '-')).value) || 0; if (source === 'brut_horaire') brutM = v * HEURES_MOIS; else if (source === 'net_horaire') brutM = netRatio > 0 ? (v * HEURES_MOIS) / netRatio : 0; else if (source === 'brut_mensuel') brutM = v; else if (source === 'net_mensuel') brutM = netRatio > 0 ? v / netRatio : 0; else if (source === 'brut_annuel') brutM = v / 12; else if (source === 'net_annuel') brutM = netRatio > 0 ? (v / 12) / netRatio : 0; if (!brutM) return; const netM = brutM * netRatio; const netApresImpot = netM * pasRatio; // Mettre à jour le slider document.getElementById('bn-brut').value = Math.round(brutM); document.getElementById('bn-brut-val').textContent = Math.round(brutM).toLocaleString('fr-FR') + ' €'; syncBNFromBrutMensuel(brutM, netM, netApresImpot); updateBNDetail(brutM); } function updateBNDetail(brutM) { const cotis = COTISATIONS[currentStatut] || COTISATIONS.non_cadre; const totalTaux = cotis.reduce((s, c) => s + c.taux, 0); const totalCotis = brutM * totalTaux / 100; document.getElementById('bn-detail-lines').innerHTML = cotis.map(c => { const montant = brutM * c.taux / 100; return `
${c.nom} ${c.taux}% = ${Math.round(montant)} €
`; }).join('') + `
Total cotisations ${Math.round(totalCotis)} € (${totalTaux.toFixed(1)}%)
`; } // ===== CALCULATEUR DE TRAJET ===== const ORS_KEY = 'eyJvcmciOiI1YjNjZTM1OTc4NTExMTAwMDFjZjYyNDgiLCJpZCI6IjQ4MTFiODc3NWUyMDQxNTNiM2M5MWYxNjM2NmI3NjAxIiwiaCI6Im11cm11cjY0In0='; let routeType = 'autoroute'; function setRouteType(type) { routeType = type; document.getElementById('trajet-btn-auto').className = type === 'autoroute' ? 'btn btn-primary' : 'btn'; document.getElementById('trajet-btn-nat').className = type === 'nationale' ? 'btn btn-primary' : 'btn'; } function updateTrajetSliders() { const conso = parseFloat(document.getElementById('trajet-conso').value); const prix = parseFloat(document.getElementById('trajet-prix').value); const pass = parseInt(document.getElementById('trajet-pass').value); document.getElementById('trajet-conso-val').textContent = conso + ' L/100km'; document.getElementById('trajet-prix-val').textContent = prix.toFixed(2).replace('.', ',') + ' €/L'; document.getElementById('trajet-pass-val').textContent = pass + ' passager' + (pass > 1 ? 's' : ''); } async function geocode(adresse) { const url = `https://api.openrouteservice.org/geocode/search?api_key=${ORS_KEY}&text=${encodeURIComponent(adresse)}&size=1&lang=fr`; const resp = await fetch(url); const data = await resp.json(); if (!data.features || !data.features.length) throw new Error('Adresse non trouvée : ' + adresse); return data.features[0].geometry.coordinates; // [lng, lat] } async function calcTrajet() { const depart = document.getElementById('trajet-depart').value.trim(); const arrivee = document.getElementById('trajet-arrivee').value.trim(); const conso = parseFloat(document.getElementById('trajet-conso').value); const prix = parseFloat(document.getElementById('trajet-prix').value); const pass = parseInt(document.getElementById('trajet-pass').value); if (!depart || !arrivee) { notify('Renseigne le départ et l\'arrivée !'); return; } const resultEl = document.getElementById('sim-trajet-result'); resultEl.style.display = 'block'; resultEl.innerHTML = '
Calcul en cours...
'; try { // Géocoder les deux adresses const [coordDepart, coordArrivee] = await Promise.all([geocode(depart), geocode(arrivee)]); // Calculer l'itinéraire const resp = await fetch('https://api.openrouteservice.org/v2/directions/driving-car', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': ORS_KEY }, body: JSON.stringify({ coordinates: [coordDepart, coordArrivee], options: routeType === 'nationale' ? { avoid_features: ['tollways', 'highways'] } : {} }) }); const data = await resp.json(); if (!data.routes || !data.routes.length) throw new Error('Itinéraire introuvable'); const route = data.routes[0].summary; const distanceKm = (route.distance / 1000).toFixed(1); const dureeMin = Math.round(route.duration / 60); const dureeH = Math.floor(dureeMin / 60); const dureeM = dureeMin % 60; const dureeStr = dureeH > 0 ? `${dureeH}h${dureeM.toString().padStart(2,'0')}` : `${dureeMin} min`; const coutTotal = (distanceKm * conso / 100 * prix); const coutParPassager = coutTotal / pass; resultEl.innerHTML = `
${coutTotal.toFixed(2).replace('.', ',')} €
${depart} → ${arrivee} · ${distanceKm} km · ${dureeStr}
Distance
${distanceKm} km
Durée estimée
${dureeStr}
Coût total
${coutTotal.toFixed(2).replace('.', ',')} €
Carburant
${(distanceKm * conso / 100).toFixed(1)} L
Par passager
${coutParPassager.toFixed(2).replace('.', ',')} €
Aller-retour
${(coutTotal * 2).toFixed(2).replace('.', ',')} €
`; } catch(e) { resultEl.innerHTML = `
🔴 ${e.message}
`; } } // AUTOCOMPLETE ADRESSES let autocompleteTimers = {}; async function autocompleteAdresse(inputId) { const input = document.getElementById(inputId); const list = document.getElementById(inputId + '-list'); const q = input.value.trim(); if (q.length < 3) { list.style.display = 'none'; return; } // Positionner sous l'input const rect = input.getBoundingClientRect(); list.style.top = (rect.bottom + window.scrollY) + 'px'; list.style.left = rect.left + 'px'; list.style.width = rect.width + 'px'; clearTimeout(autocompleteTimers[inputId]); autocompleteTimers[inputId] = setTimeout(async () => { try { const resp = await fetch( `https://api.openrouteservice.org/geocode/autocomplete?api_key=${ORS_KEY}&text=${encodeURIComponent(q)}&lang=fr&size=5&layers=locality,address&boundary.country=FR` ); const data = await resp.json(); const features = data.features || []; if (!features.length) { list.style.display = 'none'; return; } list.innerHTML = features.map(f => { const label = f.properties.label || f.properties.name; return `
${label}
`; }).join(''); list.style.display = 'block'; } catch(e) { console.error('ORS autocomplete error:', e); list.innerHTML = `
Erreur: ${e.message}
`; list.style.display = 'block'; } }, 300); } function selectAdresse(inputId, label) { document.getElementById(inputId).value = label; document.getElementById(inputId + '-list').style.display = 'none'; } function hideAutocomplete(inputId) { setTimeout(() => { const list = document.getElementById(inputId + '-list'); if (list) list.style.display = 'none'; }, 200); } // SPLASH SCREEN window.addEventListener('load', () => { setTimeout(() => { const splash = document.getElementById('splash'); if (splash) { splash.classList.add('hide'); setTimeout(() => splash.remove(), 500); } }, 1800); }); // COPIER MOIS PRÉCÉDENT async function copierMoisPrecedent() { // Calculer le mois précédent const moisPrec = varsMois === 1 ? 12 : varsMois - 1; const anneePrec = varsMois === 1 ? varsAnnee - 1 : varsAnnee; const { data: existing } = await sb.from('depenses_variables') .select('id').eq('user_id', U.id).eq('mois', varsMois).eq('annee', varsAnnee); if (existing && existing.length > 0) { if (!confirm(`Tu as déjà ${existing.length} dépense(s) ce mois. Voulez-vous quand même copier le mois précédent en plus ?`)) return; } const { data: precedent } = await sb.from('depenses_variables') .select('*').eq('user_id', U.id).eq('mois', moisPrec).eq('annee', anneePrec); if (!precedent || !precedent.length) { notify('Aucune dépense trouvée le mois précédent.'); return; } const MOIS_NOMS = ['','Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre']; // Copier en retirant l'id, user_id, et en mettant le nouveau mois/année + réinitialisant montant_reel const nouvelles = precedent.map(r => ({ user_id: U.id, categorie: r.categorie, description: r.description, montant: r.montant, montant_reel: null, mois: varsMois, annee: varsAnnee })); const { error } = await sb.from('depenses_variables').insert(nouvelles); if (error) { notify('Erreur: ' + error.message); return; } loadVars(); notify(`${nouvelles.length} dépense(s) copiée(s) depuis ${MOIS_NOMS[moisPrec]} ${anneePrec} !`); } // ===== MONÉTISATION ===== const PLANS = { free: { label: 'Gratuit', color: '#888', features: { dashboard_mois: 6, objectifs: 1, abonnements: 5, themes: ['default'], ia: false, analyses: false, simulateur: false, export_pdf: false, partage: false, courses: false, todo: false, investissements: false, } }, confort: { label: 'Confort', color: '#3498db', features: { dashboard_mois: 999, objectifs: 999, abonnements: 999, themes: 'all', ia: false, analyses: false, simulateur: false, export_pdf: false, partage: true, courses: true, todo: true, investissements: true, } }, premium: { label: 'Premium', color: '#f39c12', features: { dashboard_mois: 999, objectifs: 999, abonnements: 999, themes: 'all', ia: true, analyses: true, simulateur: true, export_pdf: true, partage: true, courses: true, todo: true, investissements: true, } } }; function getPlanFeatures() { return PLANS[USER_PLAN]?.features || PLANS.free.features; } function hasFeature(feature) { return !!getPlanFeatures()[feature]; } function applyPlanRestrictions() { const f = getPlanFeatures(); // Masquer/afficher les sections selon le plan const restrictedSections = []; if (!f.ia) restrictedSections.push('ia-fab'); if (!f.analyses) restrictedSections.push('page-analyses'); if (!f.simulateur) restrictedSections.push('page-simulateur'); if (!f.courses) restrictedSections.push('page-courses'); if (!f.todo) restrictedSections.push('page-todo'); // Ajouter badge plan dans la sidebar et mobile const plan = PLANS[USER_PLAN]; ['plan-badge', 'mobile-plan-badge'].forEach(id => { const el = document.getElementById(id); if (el) { el.textContent = plan.label; el.style.background = plan.color + '22'; el.style.color = plan.color; } }); // Griser le FAB IA si pas premium const fab = document.getElementById('ia-fab'); if (fab) { if (!f.ia) { fab.style.opacity = '0.5'; fab.onclick = () => showUpgradeModal('ia'); } else { fab.style.opacity = '1'; fab.onclick = openIA; } } } function showUpgradeModal(feature) { const features = { ia: { name: 'l\'Assistant IA 💡', plan: 'Premium' }, analyses: { name: 'les Analyses & graphiques', plan: 'Premium' }, simulateur: { name: 'le Simulateur financier', plan: 'Premium' }, export_pdf: { name: 'l\'Export PDF', plan: 'Premium' }, partage: { name: 'le Partage & Foyer', plan: 'Confort' }, courses: { name: 'la Liste de courses', plan: 'Confort' }, todo: { name: 'la To Do List', plan: 'Confort' }, investissements: { name: 'les Investissements', plan: 'Confort' }, objectifs: { name: 'les Objectifs épargne illimités', plan: 'Confort' }, abos: { name: 'les Abonnements illimités', plan: 'Confort' }, }; const f = features[feature] || { name: feature, plan: 'supérieur' }; document.getElementById('upgrade-feature-name').textContent = f.name; document.getElementById('upgrade-plan-name').textContent = f.plan; openModal('modal-upgrade'); } // Vérifier les limites avant d'ajouter function checkLimit(type) { const f = getPlanFeatures(); if (type === 'objectif' && f.objectifs !== 999) { const count = document.querySelectorAll('#epargne-list .ep-card').length; if (count >= f.objectifs) { showUpgradeModal('objectifs'); return false; } } if (type === 'abo' && f.abonnements !== 999) { if (document.querySelectorAll('#tbody-abos tr').length >= f.abonnements) { showUpgradeModal('abos'); return false; } } return true; } // PAGE PLANS function loadPlansPage() { const labels = { free: 'Gratuit', confort: 'Confort ⭐', premium: 'Premium 💎' }; const descs = { free: 'Passez au plan supérieur pour débloquer plus de fonctionnalités', confort: 'Vous profitez du plan Confort — passez au Premium pour l\'IA et les analyses', premium: 'Vous profitez de toutes les fonctionnalités EasyBudget !' }; document.getElementById('current-plan-label').textContent = labels[USER_PLAN] || 'Gratuit'; document.getElementById('current-plan-desc').textContent = descs[USER_PLAN] || ''; // Afficher badge plan actuel ['free','confort','premium'].forEach(p => { const badge = document.getElementById('badge-' + p); if (badge) badge.style.display = p === USER_PLAN ? 'inline-flex' : 'none'; }); } // VÉRIFICATION LIMITES function checkLimitObj() { const f = getPlanFeatures(); if (f.objectifs === 999) return true; // Compter les boutons edit dans epargne-list = nombre d'objectifs réels const count = document.querySelectorAll('#epargne-list .btn-edit').length; console.log('checkLimitObj: plan=', USER_PLAN, 'limit=', f.objectifs, 'count=', count); if (count >= f.objectifs) { showUpgradeModal('objectifs'); return false; } return true; } function checkLimitAbo() { const f = getPlanFeatures(); if (f.abonnements === 999) return true; const count = document.querySelectorAll('#tbody-abos tr').length; if (count >= f.abonnements) { showUpgradeModal('abos'); return false; } return true; } // CGU ACCORDION function toggleSection(id) { var content = document.getElementById(id + '-content'); var arrow = document.getElementById(id + '-arrow'); if (!content) return; var isOpen = content.style.display !== 'none'; content.style.display = isOpen ? 'none' : 'block'; if (arrow) arrow.textContent = isOpen ? '▸' : '▾'; // Load categories when accordion opens if (!isOpen && id === 'sec-cats') loadCategories(); if (!isOpen && id === 'sec-apparence') { var scale = parseFloat(localStorage.getItem('eb_font_scale')) || 1; applyFontSize(scale); } } function toggleCGU() { const content = document.getElementById('cgu-content'); const arrow = document.getElementById('cgu-arrow'); const isOpen = content.style.display !== 'none'; content.style.display = isOpen ? 'none' : 'block'; arrow.textContent = isOpen ? '▸' : '▾'; } // ADMIN USERS let allAdminUsers = []; async function loadAdminUsers() { const list = document.getElementById('admin-users-list'); if (!list) return; list.innerHTML = '
Chargement...
'; const { data: users, error } = await sb.from('profiles') .select('id, email, plan, plan_expire, created_at, prenom') .order('created_at', { ascending: false }); if (error || !users) { list.innerHTML = '
Erreur de chargement
'; return; } allAdminUsers = users; renderAdminUsers(users); } function filterAdminUsers() { const q = document.getElementById('admin-users-search').value.toLowerCase().trim(); const filtered = q ? allAdminUsers.filter(u => (u.email || '').toLowerCase().includes(q) || (u.prenom || '').toLowerCase().includes(q) ) : allAdminUsers; renderAdminUsers(filtered); } function renderAdminUsers(users) { const list = document.getElementById('admin-users-list'); if (!list) return; if (!users.length) { list.innerHTML = '
Aucun utilisateur trouvé
'; return; } const planColors = { free: '#888', confort: '#3498db', premium: '#f39c12' }; const planLabels = { free: '🆓 Gratuit', confort: '⭐ Confort', premium: '💎 Premium' }; list.innerHTML = users.map(u => { const expire = u.plan_expire ? new Date(u.plan_expire).toLocaleDateString('fr-FR') : '—'; const joined = new Date(u.created_at).toLocaleDateString('fr-FR'); return `
${u.prenom || '—'} ${u.email}
Inscrit le ${joined} · Expire : ${expire}
${planLabels[u.plan] || u.plan}
`; }).join(''); } async function setUserPlan(userId, plan) { const expire = plan === 'free' ? null : new Date(new Date().setFullYear(new Date().getFullYear() + 1)).toISOString(); // Essayer d'abord une mise à jour directe const { error } = await sb.from('profiles') .update({ plan, plan_expire: expire }) .eq('id', userId); if (error) { // Si RLS bloque, passer par le proxy Vercel avec les droits admin try { const resp = await fetch('/api/admin-plan', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId, plan, expire, adminEmail: U.email }) }); if (!resp.ok) throw new Error('Erreur serveur'); notify('Plan mis à jour !'); loadAdminUsers(); } catch(e2) { notify('Erreur: ' + error.message); } return; } notify('Plan mis à jour !'); loadAdminUsers(); } // CONTACT ABONNEMENT function contactAbonnement(plan) { const subject = encodeURIComponent(`Demande d'abonnement EasyBudget — Plan ${plan}`); const body = encodeURIComponent(`Bonjour,\n\nJe souhaite souscrire au plan ${plan} d'EasyBudget.\n\nMon email de compte : ${U.email}\n\nMerci !`); window.open(`mailto:erwannchauvet@gmail.com?subject=${subject}&body=${body}`); notify(`Email préparé pour le plan ${plan} ! 📧`); }