1653 lines
93 KiB
HTML
1653 lines
93 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||
<meta name="apple-mobile-web-app-title" content="Budget">
|
||
<meta name="theme-color" content="#0f0e0c">
|
||
<link rel="manifest" href="/manifest.json">
|
||
<link rel="apple-touch-icon" href="/icon-192.png">
|
||
<title>Budget Commun</title>
|
||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&family=DM+Mono:wght@300;400;500&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root{--bg:#0f0e0c;--surface:#1a1814;--surface2:#232018;--border:#2e2a22;--aa:#e8b86d;--ab:#7eb8a4;--text:#e8e2d6;--muted:#6b6456;--danger:#c97b6a;--safe-top:env(safe-area-inset-top);--safe-bottom:env(safe-area-inset-bottom);}
|
||
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent;}
|
||
html,body{height:100%;}
|
||
body{background:var(--bg);color:var(--text);font-family:'DM Mono',monospace;min-height:100vh;display:flex;flex-direction:column;align-items:center;padding:calc(var(--safe-top) + 28px) 16px calc(var(--safe-bottom) + 60px);}
|
||
body::before{content:'';position:fixed;inset:0;background:radial-gradient(ellipse 70% 50% at 20% 10%,rgba(232,184,109,.05) 0%,transparent 60%),radial-gradient(ellipse 60% 40% at 80% 90%,rgba(126,184,164,.05) 0%,transparent 60%);pointer-events:none;z-index:0;}
|
||
.wrap{width:100%;max-width:860px;}
|
||
header{text-align:center;margin-bottom:18px;}
|
||
.eyebrow{font-size:10px;letter-spacing:.25em;color:var(--muted);text-transform:uppercase;margin-bottom:8px;}
|
||
h1{font-family:'Playfair Display',serif;font-size:clamp(22px,5vw,36px);font-weight:400;letter-spacing:-.02em;}
|
||
h1 span{color:var(--aa);}
|
||
/* Save bar */
|
||
.save-bar{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:10px 16px;display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:14px;flex-wrap:wrap;position:relative;z-index:auto;}
|
||
.save-status{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--muted);}
|
||
.save-dot{width:7px;height:7px;border-radius:50%;background:var(--muted);transition:background .3s;flex-shrink:0;}
|
||
.save-dot.ok{background:var(--ab);}.save-dot.saving{background:var(--aa);animation:pulse .8s infinite;}.save-dot.err{background:var(--danger);}
|
||
/* Tabs */
|
||
.tabs-nav{display:flex;gap:4px;background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:4px;margin-bottom:18px;}
|
||
.tab-btn{flex:1;padding:9px 10px;border:none;border-radius:9px;background:transparent;color:var(--muted);font-family:'DM Mono',monospace;font-size:10px;letter-spacing:.06em;cursor:pointer;transition:all .2s;text-transform:uppercase;display:flex;align-items:center;justify-content:center;gap:5px;white-space:nowrap;}
|
||
.tab-btn.active{background:var(--surface2);color:var(--text);border:1px solid var(--border);}
|
||
.tab-btn .tdot{width:5px;height:5px;border-radius:50%;opacity:.3;transition:opacity .2s;flex-shrink:0;}
|
||
.tab-btn.active .tdot{opacity:1;}
|
||
.tab-panel{display:none;}.tab-panel.active{display:block;}
|
||
/* Grid */
|
||
.grid{display:grid;grid-template-columns:1fr 1fr;gap:14px;}
|
||
@media(max-width:540px){.grid{grid-template-columns:1fr;}}
|
||
/* Cards */
|
||
.card{background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:22px;position:relative;overflow:hidden;}
|
||
.card::before{content:'';position:absolute;top:0;left:0;right:0;height:2px;}
|
||
.card.ca::before{background:var(--aa);}.card.cb::before{background:var(--ab);}
|
||
.card.cc::before{background:linear-gradient(90deg,var(--aa),var(--ab));}.card.cd::before{background:linear-gradient(90deg,var(--ab),var(--aa));}
|
||
.card.admin-card::before{background:linear-gradient(90deg,#a78bfa,#7eb8a4);}
|
||
.full{grid-column:1/-1;}
|
||
.card-lbl{font-size:10px;letter-spacing:.2em;text-transform:uppercase;color:var(--muted);margin-bottom:6px;}
|
||
.card-name{font-family:'Playfair Display',serif;font-size:18px;font-weight:400;margin-bottom:16px;display:flex;align-items:center;gap:7px;}
|
||
.dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;}
|
||
/* Inputs */
|
||
.inp-grp{margin-bottom:12px;}
|
||
.inp-lbl{display:block;font-size:10px;letter-spacing:.15em;text-transform:uppercase;color:var(--muted);margin-bottom:5px;}
|
||
.inp-row{display:flex;align-items:center;background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:0 12px;}
|
||
.inp-row input,.inp-row select{flex:1;background:transparent;border:none;outline:none;color:var(--text);font-family:'DM Mono',monospace;font-size:15px;font-weight:500;padding:11px 8px;width:100%;min-width:0;}
|
||
input[type=number]::-webkit-inner-spin-button{-webkit-appearance:none;}
|
||
.inp-sfx{font-size:12px;color:var(--muted);flex-shrink:0;}
|
||
select{-webkit-appearance:none;cursor:pointer;}
|
||
.stat-row{display:flex;justify-content:space-between;align-items:baseline;padding:7px 0;border-bottom:1px solid var(--border);}
|
||
.stat-row:last-child{border-bottom:none;}
|
||
.stat-lbl{font-size:11px;color:var(--muted);}.stat-val{font-size:14px;font-weight:500;}
|
||
.badge{display:inline-block;padding:2px 10px;border-radius:20px;font-size:11px;font-weight:500;}
|
||
/* Budget */
|
||
.budget-row{display:flex;align-items:center;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
|
||
.budget-row>div:first-child{flex:1;min-width:180px;}
|
||
.budget-box{text-align:center;padding:16px;background:var(--surface2);border-radius:12px;border:1px solid var(--border);flex-shrink:0;min-width:130px;}
|
||
.budget-big{font-family:'Playfair Display',serif;font-size:26px;}
|
||
.budget-sub{font-size:10px;color:var(--muted);letter-spacing:.1em;text-transform:uppercase;margin-top:3px;}
|
||
/* Rep */
|
||
.rep-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:14px;}
|
||
.rep-card{background:var(--surface2);border-radius:10px;padding:14px;border:1px solid var(--border);}
|
||
.rep-head{display:flex;align-items:center;gap:6px;font-size:10px;text-transform:uppercase;letter-spacing:.12em;color:var(--muted);margin-bottom:8px;}
|
||
.rep-amt{font-family:'Playfair Display',serif;font-size:22px;margin-bottom:3px;}
|
||
.rep-sub{font-size:11px;color:var(--muted);}
|
||
/* Bar */
|
||
.bar-wrap{margin-top:18px;padding-top:16px;border-top:1px solid var(--border);}
|
||
.bar-lbl{display:flex;justify-content:space-between;font-size:10px;color:var(--muted);margin-bottom:7px;}
|
||
.bar-track{height:7px;background:var(--surface2);border-radius:99px;overflow:hidden;display:flex;gap:2px;}
|
||
.bar-seg{height:100%;border-radius:99px;transition:width .5s cubic-bezier(.34,1.56,.64,1);}
|
||
/* Slider */
|
||
.slider-sec{margin-top:18px;padding-top:16px;border-top:1px solid var(--border);}
|
||
.slider-hdr{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;}
|
||
.slider-ttl{font-size:10px;letter-spacing:.2em;text-transform:uppercase;color:var(--muted);}
|
||
input[type=range]{-webkit-appearance:none;width:100%;height:4px;border-radius:99px;background:var(--border);outline:none;cursor:pointer;}
|
||
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:22px;height:22px;border-radius:50%;background:var(--text);border:3px solid var(--aa);box-shadow:0 0 0 4px rgba(232,184,109,.15);}
|
||
.slider-vals{display:flex;justify-content:space-between;margin-top:7px;font-size:11px;}
|
||
.alert{margin-top:14px;padding:11px 14px;border-radius:8px;font-size:11px;display:flex;align-items:center;gap:8px;}
|
||
.alert.warn{background:rgba(201,123,106,.1);border:1px solid rgba(201,123,106,.3);color:var(--danger);}
|
||
.alert.ok-al{background:rgba(126,184,164,.08);border:1px solid rgba(126,184,164,.2);color:var(--ab);}
|
||
/* Dépenses */
|
||
.dep-sum{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:18px;}
|
||
.sum-box{background:var(--surface2);border:1px solid var(--border);border-radius:10px;padding:12px;text-align:center;}
|
||
.sum-val{font-family:'Playfair Display',serif;font-size:20px;margin-bottom:3px;}
|
||
.sum-lbl{font-size:10px;color:var(--muted);letter-spacing:.1em;text-transform:uppercase;}
|
||
.add-form{display:grid;grid-template-columns:1fr auto auto auto auto auto;gap:7px;margin-bottom:16px;align-items:end;}
|
||
@media(max-width:600px){.add-form{grid-template-columns:1fr 1fr;}.add-form .btn-add{grid-column:1/-1;}}
|
||
.cat-filters{display:flex;gap:5px;flex-wrap:wrap;margin-bottom:12px;}
|
||
.cat-pill{padding:3px 11px;border-radius:20px;font-size:10px;letter-spacing:.1em;text-transform:uppercase;border:1px solid var(--border);background:transparent;color:var(--muted);cursor:pointer;font-family:'DM Mono',monospace;transition:all .15s;}
|
||
.cat-pill.active{background:var(--surface2);color:var(--text);border-color:var(--aa);}
|
||
.exp-list{display:flex;flex-direction:column;gap:5px;}
|
||
.exp-empty{text-align:center;padding:32px 16px;color:var(--muted);font-size:11px;border:1px dashed var(--border);border-radius:10px;line-height:1.9;}
|
||
.exp-item{display:grid;grid-template-columns:auto 1fr auto auto auto;align-items:center;gap:10px;background:var(--surface2);border:1px solid var(--border);border-radius:10px;padding:11px 14px;animation:fadeIn .2s ease both;}
|
||
.exp-icon{width:30px;height:30px;border-radius:7px;display:flex;align-items:center;justify-content:center;font-size:15px;flex-shrink:0;}
|
||
.exp-name{font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||
.exp-meta{font-size:9px;color:var(--muted);text-transform:uppercase;letter-spacing:.1em;margin-top:1px;}
|
||
.exp-amt{font-family:'Playfair Display',serif;font-size:16px;white-space:nowrap;}
|
||
.exp-badge{font-size:9px;color:var(--muted);background:var(--surface);border:1px solid var(--border);border-radius:4px;padding:2px 6px;white-space:nowrap;}
|
||
.exp-badge.ponctuel{color:#a78bfa;border-color:rgba(167,139,250,.3);background:rgba(167,139,250,.08);}
|
||
.btn-del{background:transparent;border:1px solid transparent;border-radius:6px;color:var(--muted);cursor:pointer;font-size:13px;padding:4px 6px;font-family:'DM Mono',monospace;}
|
||
/* Section ponctuel */
|
||
.section-title{font-size:10px;letter-spacing:.2em;text-transform:uppercase;color:var(--muted);margin:20px 0 10px;padding-top:18px;border-top:1px solid var(--border);display:flex;align-items:center;gap:8px;}
|
||
.section-badge{background:rgba(167,139,250,.12);color:#a78bfa;border:1px solid rgba(167,139,250,.2);border-radius:20px;padding:2px 10px;font-size:10px;}
|
||
/* Cat totals */
|
||
.cat-tot-row{display:flex;justify-content:space-between;align-items:center;padding:7px 0;border-bottom:1px solid var(--border);}
|
||
.cat-tot-row:last-child{border-bottom:none;}
|
||
.cat-bar-wrap{flex:1;margin:0 10px;height:4px;background:var(--border);border-radius:99px;overflow:hidden;}
|
||
.cat-bar-fill{height:100%;border-radius:99px;transition:width .4s;}
|
||
/* Buttons */
|
||
.btn-add{padding:11px 16px;background:var(--aa);color:#0f0e0c;border:none;border-radius:8px;font-family:'DM Mono',monospace;font-size:12px;font-weight:500;cursor:pointer;white-space:nowrap;align-self:end;}
|
||
.btn-sm{padding:6px 12px;border-radius:7px;font-family:'DM Mono',monospace;font-size:10px;font-weight:500;cursor:pointer;border:1px solid var(--border);background:var(--surface2);color:var(--muted);transition:all .15s;}
|
||
.btn-sm:hover{color:var(--text);}
|
||
.btn-sm.admin-btn{color:#a78bfa;border-color:rgba(167,139,250,.3);}
|
||
/* Admin */
|
||
.admin-section{margin-bottom:24px;}
|
||
.admin-section-title{font-size:10px;letter-spacing:.2em;text-transform:uppercase;color:var(--muted);margin-bottom:12px;}
|
||
.cat-admin-list{display:flex;flex-direction:column;gap:6px;}
|
||
.cat-admin-item{display:grid;grid-template-columns:auto auto 1fr auto auto auto;align-items:center;gap:10px;background:var(--surface2);border:1px solid var(--border);border-radius:10px;padding:10px 14px;}
|
||
.cat-admin-item.inactive{opacity:.45;}
|
||
.cat-emoji-btn{font-size:20px;background:transparent;border:1px solid var(--border);border-radius:8px;width:36px;height:36px;cursor:pointer;display:flex;align-items:center;justify-content:center;}
|
||
.cat-label-inp{background:transparent;border:none;outline:none;color:var(--text);font-family:'DM Mono',monospace;font-size:13px;font-weight:500;width:100%;}
|
||
.toggle-btn{background:transparent;border:1px solid var(--border);border-radius:6px;color:var(--muted);cursor:pointer;font-size:11px;padding:4px 9px;font-family:'DM Mono',monospace;white-space:nowrap;}
|
||
.toggle-btn.active{color:var(--ab);border-color:rgba(126,184,164,.3);}
|
||
.btn-icon{background:transparent;border:1px solid transparent;border-radius:6px;color:var(--muted);cursor:pointer;font-size:13px;padding:4px 7px;font-family:'DM Mono',monospace;}
|
||
.btn-icon:hover{color:var(--danger);border-color:rgba(201,123,106,.3);}
|
||
.emoji-picker{background:var(--surface2);border:1px solid var(--border);border-radius:10px;padding:10px;margin-top:4px;display:flex;flex-wrap:wrap;gap:6px;}
|
||
.emoji-opt{font-size:20px;cursor:pointer;padding:4px;border-radius:6px;border:1px solid transparent;}
|
||
.emoji-opt:hover{border-color:var(--border);}
|
||
/* Login */
|
||
.login-screen{position:fixed;inset:0;background:var(--bg);z-index:400;display:flex;align-items:center;justify-content:center;padding:24px;}
|
||
.login-screen.hidden{display:none;}
|
||
.login-card{background:var(--surface);border:1px solid var(--border);border-radius:20px;padding:32px 28px;width:100%;max-width:340px;position:relative;}
|
||
.login-card::before{content:'';position:absolute;top:0;left:0;right:0;height:2px;border-radius:20px 20px 0 0;background:linear-gradient(90deg,var(--aa),var(--ab));}
|
||
.login-err{background:rgba(201,123,106,.1);border:1px solid rgba(201,123,106,.3);color:var(--danger);font-size:12px;padding:10px 14px;border-radius:8px;margin-bottom:14px;display:none;}
|
||
.login-err.show{display:block;}
|
||
.btn-login{width:100%;padding:13px;background:var(--aa);color:#0f0e0c;border:none;border-radius:9px;font-family:'DM Mono',monospace;font-size:13px;font-weight:500;cursor:pointer;margin-top:6px;}
|
||
/* Modal */
|
||
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:500;display:flex;align-items:center;justify-content:center;padding:24px;}
|
||
.modal-overlay.hidden{display:none;}
|
||
.modal-card{background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:26px;width:100%;max-width:340px;}
|
||
.modal-title{font-family:'Playfair Display',serif;font-size:18px;font-weight:400;margin-bottom:18px;}
|
||
.modal-err{background:rgba(201,123,106,.1);border:1px solid rgba(201,123,106,.3);color:var(--danger);font-size:11px;padding:9px 12px;border-radius:7px;margin-bottom:12px;display:none;}
|
||
.modal-err.show{display:block;}
|
||
.modal-ok{background:rgba(126,184,164,.08);border:1px solid rgba(126,184,164,.2);color:var(--ab);font-size:11px;padding:9px 12px;border-radius:7px;margin-bottom:12px;display:none;}
|
||
.modal-ok.show{display:block;}
|
||
.modal-btns{display:flex;gap:8px;margin-top:14px;}
|
||
.btn-cancel{flex:1;padding:11px;background:var(--surface2);color:var(--muted);border:1px solid var(--border);border-radius:8px;font-family:'DM Mono',monospace;font-size:12px;cursor:pointer;}
|
||
.btn-confirm{flex:1;padding:11px;background:var(--aa);color:#0f0e0c;border:none;border-radius:8px;font-family:'DM Mono',monospace;font-size:12px;font-weight:500;cursor:pointer;}
|
||
/* Loading */
|
||
.loading{position:fixed;inset:0;background:var(--bg);z-index:300;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:14px;}
|
||
.loading.hidden{display:none;}
|
||
.spinner{width:28px;height:28px;border:2px solid var(--border);border-top-color:var(--aa);border-radius:50%;animation:spin .8s linear infinite;}
|
||
.loading-txt{font-size:10px;color:var(--muted);letter-spacing:.2em;text-transform:uppercase;}
|
||
/* Toast */
|
||
.toast{position:fixed;bottom:calc(var(--safe-bottom) + 16px);left:50%;transform:translateX(-50%) translateY(80px);background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:11px 18px;font-size:12px;color:var(--text);z-index:200;transition:transform .3s cubic-bezier(.34,1.56,.64,1);white-space:nowrap;}
|
||
.toast.show{transform:translateX(-50%) translateY(0);}
|
||
.toast.t-ok{border-color:rgba(126,184,164,.4);}.toast.t-err{border-color:rgba(201,123,106,.4);}
|
||
/* Simulator */
|
||
.simu-card::before{background:linear-gradient(90deg,#f9a875,#e8b86d);}
|
||
.simu-projects{display:flex;flex-direction:column;gap:10px;margin-top:4px;}
|
||
.simu-project{background:var(--surface2);border:1px solid var(--border);border-radius:12px;overflow:hidden;}
|
||
.simu-project-header{display:grid;grid-template-columns:auto 1fr auto auto auto;align-items:center;gap:10px;padding:14px 16px;cursor:pointer;}
|
||
.simu-project-emoji{font-size:22px;flex-shrink:0;}
|
||
.simu-project-name{font-size:14px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||
.simu-project-total{font-family:'Playfair Display',serif;font-size:18px;color:#f9a875;white-space:nowrap;}
|
||
.simu-project-chevron{color:var(--muted);font-size:12px;transition:transform .2s;flex-shrink:0;}
|
||
.simu-project.open .simu-project-chevron{transform:rotate(90deg);}
|
||
.simu-project-body{display:none;padding:0 16px 16px;border-top:1px solid var(--border);}
|
||
.simu-project.open .simu-project-body{display:block;}
|
||
.simu-result-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:14px;}
|
||
.simu-result-card{background:var(--surface);border-radius:10px;padding:14px;border:1px solid var(--border);}
|
||
.simu-result-head{font-size:10px;text-transform:uppercase;letter-spacing:.12em;color:var(--muted);margin-bottom:8px;display:flex;align-items:center;gap:6px;}
|
||
.simu-result-amt{font-family:'Playfair Display',serif;font-size:24px;margin-bottom:3px;}
|
||
.simu-result-sub{font-size:11px;color:var(--muted);}
|
||
.simu-progress-wrap{margin-top:14px;}
|
||
.simu-progress-lbl{display:flex;justify-content:space-between;font-size:10px;color:var(--muted);margin-bottom:6px;}
|
||
.simu-progress-track{height:6px;background:var(--border);border-radius:99px;overflow:hidden;}
|
||
.simu-progress-fill{height:100%;border-radius:99px;background:linear-gradient(90deg,#f9a875,#e8b86d);transition:width .6s cubic-bezier(.34,1.56,.64,1);}
|
||
.simu-deadline{display:flex;align-items:center;gap:6px;margin-top:10px;font-size:11px;color:var(--muted);}
|
||
.simu-deadline span{color:var(--text);}
|
||
.add-simu-form{background:var(--surface2);border:1px solid var(--border);border-radius:12px;padding:18px;margin-bottom:12px;}
|
||
.add-simu-title{font-size:10px;letter-spacing:.2em;text-transform:uppercase;color:var(--muted);margin-bottom:14px;}
|
||
.simu-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;}
|
||
@media(max-width:480px){.simu-form-grid{grid-template-columns:1fr;}.simu-result-grid{grid-template-columns:1fr;}}
|
||
.simu-empty{text-align:center;padding:36px 16px;color:var(--muted);font-size:11px;border:1px dashed var(--border);border-radius:10px;line-height:1.9;}
|
||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
|
||
@keyframes spin{to{transform:rotate(360deg)}}
|
||
@keyframes fadeIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
|
||
/* Pointage */
|
||
.ptg-card::before{background:linear-gradient(90deg,#f9a875,#e8b86d);}
|
||
.ptg-month-nav{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;gap:10px;}
|
||
.ptg-month-label{font-family:'Playfair Display',serif;font-size:20px;font-weight:400;}
|
||
.ptg-nav-btn{background:var(--surface2);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:18px;width:36px;height:36px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:border-color .15s;flex-shrink:0;}
|
||
.ptg-nav-btn:hover{border-color:var(--aa);}
|
||
.ptg-summary{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:20px;}
|
||
.ptg-item{display:grid;grid-template-columns:auto 1fr auto auto;align-items:center;gap:12px;background:var(--surface2);border:1px solid var(--border);border-radius:10px;padding:12px 16px;transition:border-color .2s,opacity .2s;cursor:pointer;margin-bottom:5px;}
|
||
.ptg-item.pointed{opacity:.55;border-color:transparent;}
|
||
.ptg-item:hover{border-color:#3e3830;}
|
||
.ptg-check{width:22px;height:22px;border-radius:6px;border:2px solid var(--border);display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:all .2s;background:transparent;}
|
||
.ptg-item.pointed .ptg-check{background:var(--ab);border-color:var(--ab);}
|
||
.ptg-check-icon{opacity:0;transition:opacity .2s;color:#0f0e0c;font-size:13px;font-weight:700;}
|
||
.ptg-item.pointed .ptg-check-icon{opacity:1;}
|
||
.ptg-info{min-width:0;}
|
||
.ptg-name{font-size:13px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||
.ptg-cat{font-size:9px;color:var(--muted);text-transform:uppercase;letter-spacing:.1em;margin-top:1px;}
|
||
.ptg-amount{font-family:'Playfair Display',serif;font-size:16px;white-space:nowrap;}
|
||
.ptg-date{font-size:9px;color:var(--muted);white-space:nowrap;}
|
||
.ptg-alert{background:rgba(201,123,106,.08);border:1px solid rgba(201,123,106,.25);border-radius:12px;padding:16px;margin-bottom:20px;}
|
||
.ptg-alert-title{font-size:10px;letter-spacing:.15em;text-transform:uppercase;color:var(--danger);margin-bottom:12px;display:flex;align-items:center;gap:7px;}
|
||
.ptg-alert-item{display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid rgba(201,123,106,.15);font-size:12px;}
|
||
.ptg-alert-item:last-child{border-bottom:none;}
|
||
.ptg-section-title{font-size:10px;letter-spacing:.2em;text-transform:uppercase;color:var(--muted);margin:20px 0 10px;display:flex;align-items:center;justify-content:space-between;}
|
||
.ptg-progress{height:5px;background:var(--border);border-radius:99px;overflow:hidden;margin-bottom:20px;}
|
||
.ptg-progress-fill{height:100%;border-radius:99px;background:linear-gradient(90deg,#f9a875,var(--ab));transition:width .5s cubic-bezier(.34,1.56,.64,1);}
|
||
/* Pull to refresh */
|
||
.ptr-bar{position:fixed;top:0;left:0;right:0;height:0;overflow:hidden;display:flex;align-items:center;justify-content:center;gap:8px;background:var(--bg);z-index:50;transition:height .2s ease;}
|
||
.ptr-bar.show{height:50px;}
|
||
.ptr-icon{width:18px;height:18px;color:var(--aa);}
|
||
.ptr-icon.spin{animation:spin .7s linear infinite;}
|
||
.ptr-txt{font-size:10px;color:var(--muted);letter-spacing:.1em;text-transform:uppercase;}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- PULL TO REFRESH -->
|
||
<div class="ptr-bar" id="ptrBar">
|
||
<svg class="ptr-icon" id="ptrIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4"/></svg>
|
||
<span class="ptr-txt" id="ptrTxt">Tirer pour rafraîchir</span>
|
||
</div>
|
||
|
||
<!-- LOGIN -->
|
||
<div class="login-screen" id="loginScreen">
|
||
<div class="login-card">
|
||
<div style="text-align:center;margin-bottom:22px">
|
||
<div style="font-size:10px;letter-spacing:.25em;color:var(--muted);text-transform:uppercase;margin-bottom:8px">Gestion financière · Couple</div>
|
||
<h1>Budget <span>Commun</span></h1>
|
||
<div style="font-size:11px;color:var(--muted);margin-top:6px">Connectez-vous pour accéder</div>
|
||
</div>
|
||
<div class="login-err" id="loginErr"></div>
|
||
<div class="inp-grp"><label class="inp-lbl">Identifiant</label><div class="inp-row"><input id="loginUser" type="text" autocomplete="username" placeholder="alexandre" onkeydown="if(event.key==='Enter')doLogin()"></div></div>
|
||
<div class="inp-grp"><label class="inp-lbl">Mot de passe</label><div class="inp-row"><input id="loginPass" type="password" autocomplete="current-password" placeholder="••••••" onkeydown="if(event.key==='Enter')doLogin()"></div></div>
|
||
<button class="btn-login" onclick="doLogin()">Se connecter</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- CHANGE PASSWORD MODAL -->
|
||
<div class="modal-overlay hidden" id="pwdModal">
|
||
<div class="modal-card">
|
||
<div class="modal-title">Changer le mot de passe</div>
|
||
<div class="modal-err" id="pwdErr"></div>
|
||
<div class="modal-ok" id="pwdOk">Mot de passe modifié ✓</div>
|
||
<div class="inp-grp"><label class="inp-lbl">Mot de passe actuel</label><div class="inp-row"><input id="pwdCurrent" type="password"></div></div>
|
||
<div class="inp-grp"><label class="inp-lbl">Nouveau mot de passe</label><div class="inp-row"><input id="pwdNew" type="password" placeholder="6 caractères min."></div></div>
|
||
<div class="inp-grp" style="margin:0"><label class="inp-lbl">Confirmer</label><div class="inp-row"><input id="pwdConfirm" type="password"></div></div>
|
||
<div class="modal-btns">
|
||
<button class="btn-cancel" onclick="closePwdModal()">Annuler</button>
|
||
<button class="btn-confirm" onclick="doChangePassword()">Confirmer</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="loading hidden" id="loading"><div class="spinner"></div><div class="loading-txt">Chargement…</div></div>
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<header class="wrap">
|
||
<div class="eyebrow">Gestion financière · Couple</div>
|
||
<h1>Budget <span>Commun</span></h1>
|
||
</header>
|
||
|
||
<!-- SAVE BAR -->
|
||
<div class="wrap save-bar">
|
||
<div class="save-status"><div class="save-dot" id="sdot"></div><span id="slbl">Chargement…</span></div>
|
||
<div style="display:flex;align-items:center;gap:10px;position:relative">
|
||
<span id="currentUser" style="font-size:10px;color:var(--muted)"></span>
|
||
<button onclick="toggleAccountMenu()" style="background:var(--surface2);border:1px solid var(--border);border-radius:50%;width:34px;height:34px;display:flex;align-items:center;justify-content:center;cursor:pointer;flex-shrink:0" id="accountBtn">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="color:var(--muted)"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg>
|
||
</button>
|
||
<div id="accountMenu" style="display:none;position:absolute;top:42px;right:0;background:var(--surface);border:1px solid var(--border);border-radius:10px;min-width:170px;overflow:hidden;z-index:1000;box-shadow:0 8px 24px rgba(0,0,0,.4)">
|
||
<button onclick="openPwdModal();toggleAccountMenu()" style="width:100%;padding:12px 16px;background:transparent;border:none;border-bottom:1px solid var(--border);color:var(--text);font-family:'DM Mono',monospace;font-size:12px;cursor:pointer;text-align:left;display:flex;align-items:center;gap:8px">
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||
Mot de passe
|
||
</button>
|
||
<button onclick="doLogout()" style="width:100%;padding:12px 16px;background:transparent;border:none;color:var(--danger);font-family:'DM Mono',monospace;font-size:12px;cursor:pointer;text-align:left;display:flex;align-items:center;gap:8px">
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||
Déconnexion
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- TABS -->
|
||
<nav class="wrap tabs-nav" id="tabsNav">
|
||
<button class="tab-btn active" id="btn-revenus" onclick="switchTab('revenus')"><span class="tdot" style="background:var(--aa)"></span>Revenus</button>
|
||
<button class="tab-btn" id="btn-depenses" onclick="switchTab('depenses')"><span class="tdot" style="background:var(--ab)"></span>Dépenses</button>
|
||
<button class="tab-btn" id="btn-pointage" onclick="switchTab('pointage')"><span class="tdot" style="background:#f9a875"></span>Pointage</button>
|
||
<button class="tab-btn" id="btn-simu" onclick="switchTab('simu')"><span class="tdot" style="background:#c4b5fd"></span>Simulateur</button>
|
||
<button class="tab-btn hidden" id="btn-admin" onclick="switchTab('admin')" style="display:none"><span class="tdot" style="background:#a78bfa"></span>Admin</button>
|
||
</nav>
|
||
|
||
<!-- TAB REVENUS -->
|
||
<div class="tab-panel active wrap" id="tab-revenus">
|
||
<div class="grid">
|
||
<div class="card ca">
|
||
<div class="card-lbl">Partenaire A</div>
|
||
<div class="card-name"><span class="dot" style="background:var(--aa)"></span><span id="na-disp">—</span></div>
|
||
<div class="inp-grp"><label class="inp-lbl">Prénom</label><div class="inp-row"><input id="nameA" type="text" oninput="onSettingChange()"></div></div>
|
||
<div class="inp-grp"><label class="inp-lbl">Revenu mensuel net</label><div class="inp-row"><input id="incomeA" type="number" oninput="onSettingChange(true)"><span class="inp-sfx">€/mois</span></div></div>
|
||
<div class="stat-row"><span class="stat-lbl">Annuel</span><span class="stat-val" id="annualA">—</span></div>
|
||
<div class="stat-row" style="border:none"><span class="stat-lbl">Part du foyer</span><span class="badge" id="shareA" style="background:rgba(232,184,109,.12);color:var(--aa)">—</span></div>
|
||
</div>
|
||
<div class="card cb">
|
||
<div class="card-lbl">Partenaire B</div>
|
||
<div class="card-name"><span class="dot" style="background:var(--ab)"></span><span id="nb-disp">—</span></div>
|
||
<div class="inp-grp"><label class="inp-lbl">Prénom</label><div class="inp-row"><input id="nameB" type="text" oninput="onSettingChange()"></div></div>
|
||
<div class="inp-grp"><label class="inp-lbl">Revenu mensuel net</label><div class="inp-row"><input id="incomeB" type="number" oninput="onSettingChange(true)"><span class="inp-sfx">€/mois</span></div></div>
|
||
<div class="stat-row"><span class="stat-lbl">Annuel</span><span class="stat-val" id="annualB">—</span></div>
|
||
<div class="stat-row" style="border:none"><span class="stat-lbl">Part du foyer</span><span class="badge" id="shareB" style="background:rgba(126,184,164,.12);color:var(--ab)">—</span></div>
|
||
</div>
|
||
<div class="card cc full">
|
||
<div class="card-lbl">Compte Commun</div>
|
||
<div class="card-name">Charges & Budget partagé</div>
|
||
<div class="budget-row">
|
||
<div>
|
||
<div class="inp-lbl">Budget mensuel récurrent (calculé)</div>
|
||
<div class="inp-row" style="opacity:.6"><input id="budgetDisplay" type="number" readonly><span class="inp-sfx">€/mois</span></div>
|
||
<div style="font-size:11px;color:var(--muted);margin-top:7px" id="budgetHint">Ajoutez des dépenses dans l'onglet Dépenses.</div>
|
||
</div>
|
||
<div class="budget-box"><div class="budget-big" id="budgetAnnual">—</div><div class="budget-sub">par an</div></div>
|
||
</div>
|
||
<div class="slider-sec">
|
||
<div class="slider-hdr"><span class="slider-ttl">Répartition</span><span style="font-size:10px;color:var(--muted);font-style:italic">Glissez pour affiner</span></div>
|
||
<input type="range" id="slider" min="0" max="100" step="1" value="63" oninput="onSlider()">
|
||
<div class="slider-vals"><span style="color:var(--aa)" id="slvA">—</span><span style="color:var(--ab)" id="slvB">—</span></div>
|
||
</div>
|
||
<div class="rep-grid">
|
||
<div class="rep-card"><div class="rep-head"><span class="dot" style="background:var(--aa)"></span><span id="rna">—</span></div><div class="rep-amt" style="color:var(--aa)" id="cA">—</div><div class="rep-sub" id="cAy">—</div></div>
|
||
<div class="rep-card"><div class="rep-head"><span class="dot" style="background:var(--ab)"></span><span id="rnb">—</span></div><div class="rep-amt" style="color:var(--ab)" id="cB">—</div><div class="rep-sub" id="cBy">—</div></div>
|
||
</div>
|
||
<div class="bar-wrap">
|
||
<div class="bar-lbl"><span id="blA">—</span><span id="blB">—</span></div>
|
||
<div class="bar-track"><div class="bar-seg" id="barA" style="background:var(--aa);width:63%"></div><div class="bar-seg" id="barB" style="background:var(--ab);width:37%"></div></div>
|
||
</div>
|
||
<div class="rep-grid">
|
||
<div class="rep-card"><div class="rep-head"><span class="dot" style="background:var(--aa)"></span>Reste <span id="rrna">—</span></div><div class="rep-amt" style="color:var(--aa);font-size:20px" id="rA">—</div><div class="rep-sub" id="rAp">—</div></div>
|
||
<div class="rep-card"><div class="rep-head"><span class="dot" style="background:var(--ab)"></span>Reste <span id="rrnb">—</span></div><div class="rep-amt" style="color:var(--ab);font-size:20px" id="rB">—</div><div class="rep-sub" id="rBp">—</div></div>
|
||
</div>
|
||
<div id="alertZone"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- TAB DÉPENSES -->
|
||
<div class="tab-panel wrap" id="tab-depenses">
|
||
<div class="grid">
|
||
<div class="card cd full">
|
||
<div class="card-lbl">Dépenses communes</div>
|
||
<div class="card-name">Liste & Suivi</div>
|
||
<div class="dep-sum">
|
||
<div class="sum-box"><div class="sum-val" id="dtm" style="color:var(--aa)">0 €</div><div class="sum-lbl">Récurrent/mois</div></div>
|
||
<div class="sum-box"><div class="sum-val" id="dty" style="color:var(--text)">0 €</div><div class="sum-lbl">Récurrent/an</div></div>
|
||
<div class="sum-box"><div class="sum-val" id="dcnt" style="color:var(--muted)">0</div><div class="sum-lbl">dépenses</div></div>
|
||
</div>
|
||
|
||
<!-- Formulaire ajout -->
|
||
<div class="add-form">
|
||
<div class="inp-grp" style="margin:0"><label class="inp-lbl">Libellé</label><div class="inp-row"><input id="depName" type="text" placeholder="Loyer, EDF…" style="font-size:14px;padding:10px 8px" onkeydown="if(event.key==='Enter')addExpense()"></div></div>
|
||
<div class="inp-grp" style="margin:0"><label class="inp-lbl">Montant</label><div class="inp-row"><input id="depAmt" type="number" placeholder="0" style="font-size:14px;padding:10px 8px;width:75px" onkeydown="if(event.key==='Enter')addExpense()"><span class="inp-sfx">€</span></div></div>
|
||
<div class="inp-grp" style="margin:0"><label class="inp-lbl">Catégorie</label><div class="inp-row"><select id="depCat" style="font-size:13px;padding:11px 8px"></select></div></div>
|
||
<div class="inp-grp" style="margin:0"><label class="inp-lbl">Fréquence</label><div class="inp-row"><select id="depFreq" style="font-size:13px;padding:11px 8px" onchange="toggleMonthField()">
|
||
<option value="mensuel">Mensuel</option>
|
||
<option value="hebdo">Hebdo</option>
|
||
<option value="annuel">Annuel</option>
|
||
<option value="ponctuel">Ponctuel</option>
|
||
</select></div></div>
|
||
<div class="inp-grp" style="margin:0;display:none" id="monthField"><label class="inp-lbl">Mois</label><div class="inp-row"><select id="depMonth" style="font-size:13px;padding:11px 8px">
|
||
<option value="2025-01">Janvier 2025</option><option value="2025-02">Février 2025</option>
|
||
<option value="2025-03">Mars 2025</option><option value="2025-04">Avril 2025</option>
|
||
<option value="2025-05">Mai 2025</option><option value="2025-06">Juin 2025</option>
|
||
<option value="2025-07">Juillet 2025</option><option value="2025-08">Août 2025</option>
|
||
<option value="2025-09">Septembre 2025</option><option value="2025-10">Octobre 2025</option>
|
||
<option value="2025-11">Novembre 2025</option><option value="2025-12">Décembre 2025</option>
|
||
<option value="2026-01">Janvier 2026</option><option value="2026-02">Février 2026</option>
|
||
<option value="2026-03">Mars 2026</option><option value="2026-04">Avril 2026</option>
|
||
<option value="2026-05">Mai 2026</option><option value="2026-06">Juin 2026</option>
|
||
<option value="2026-07">Juillet 2026</option><option value="2026-08">Août 2026</option>
|
||
<option value="2026-09">Septembre 2026</option><option value="2026-10">Octobre 2026</option>
|
||
<option value="2026-11">Novembre 2026</option><option value="2026-12">Décembre 2026</option>
|
||
</select></div></div>
|
||
<button class="btn-add" onclick="addExpense()">+ Ajouter</button>
|
||
</div>
|
||
|
||
<!-- Filtres -->
|
||
<div class="cat-filters" id="catFilters">
|
||
<button class="cat-pill active" onclick="filterCat('all',this)">Toutes</button>
|
||
<button class="cat-pill" onclick="filterCat('__ponctuel__',this)">🗓 Ponctuelles</button>
|
||
</div>
|
||
|
||
<!-- Liste récurrentes -->
|
||
<div id="recurSection">
|
||
<div class="exp-list" id="expListRecur"></div>
|
||
</div>
|
||
|
||
<!-- Liste ponctuelles -->
|
||
<div id="ponctuelSection" style="display:none">
|
||
<div class="section-title">Dépenses ponctuelles <span class="section-badge" id="ponctuelCount">0</span></div>
|
||
<div class="exp-list" id="expListPonctuel"></div>
|
||
</div>
|
||
|
||
<!-- Cat totals -->
|
||
<div id="catTotals" style="display:none;margin-top:20px;padding-top:16px;border-top:1px solid var(--border)">
|
||
<div style="font-size:10px;letter-spacing:.2em;text-transform:uppercase;color:var(--muted);margin-bottom:12px">Répartition par catégorie (récurrentes)</div>
|
||
<div id="catTotList"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- TAB POINTAGE -->
|
||
<div class="tab-panel wrap" id="tab-pointage">
|
||
<div class="grid">
|
||
<div class="card ptg-card full">
|
||
<div class="card-lbl">Pointage mensuel</div>
|
||
|
||
<!-- Navigation mois -->
|
||
<div class="ptg-month-nav">
|
||
<button class="ptg-nav-btn" onclick="changePointageMonth(-1)">‹</button>
|
||
<div>
|
||
<div class="ptg-month-label" id="ptgMonthLabel">—</div>
|
||
<div style="font-size:10px;color:var(--muted);margin-top:2px" id="ptgMonthSub">Chargement…</div>
|
||
</div>
|
||
<button class="ptg-nav-btn" onclick="changePointageMonth(1)" id="ptgNextBtn">›</button>
|
||
</div>
|
||
|
||
<!-- Barre de progression -->
|
||
<div class="ptg-progress"><div class="ptg-progress-fill" id="ptgProgress" style="width:0%"></div></div>
|
||
|
||
<!-- Résumé -->
|
||
<div class="ptg-summary" id="ptgSummary">
|
||
<div class="sum-box"><div class="sum-val" id="ptgPointed" style="color:var(--ab)">0</div><div class="sum-lbl">Pointées</div></div>
|
||
<div class="sum-box"><div class="sum-val" id="ptgRemaining" style="color:var(--danger)">0</div><div class="sum-lbl">Restantes</div></div>
|
||
<div class="sum-box"><div class="sum-val" id="ptgTotal" style="color:var(--text)">0 €</div><div class="sum-lbl">Total pointé</div></div>
|
||
</div>
|
||
|
||
<!-- Alertes mois précédent -->
|
||
<div id="ptgAlertZone"></div>
|
||
|
||
<!-- Liste à pointer -->
|
||
<div class="ptg-section-title">
|
||
<span id="ptgSectionLabel">À pointer</span>
|
||
<button class="btn-sm" onclick="pointAll()" id="ptgPointAllBtn" style="font-size:10px">✓ Tout pointer</button>
|
||
</div>
|
||
<div id="ptgList"></div>
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- TAB ADMIN -->
|
||
<div class="tab-panel wrap" id="tab-admin">
|
||
<div class="grid">
|
||
<div class="card admin-card full">
|
||
<div class="card-lbl">Administration</div>
|
||
<div class="card-name" style="color:#a78bfa">⚙ Gestion des catégories</div>
|
||
|
||
<div class="admin-section">
|
||
<div class="admin-section-title">Catégories existantes</div>
|
||
<div class="cat-admin-list" id="catAdminList"></div>
|
||
</div>
|
||
|
||
<div class="admin-section" style="margin-bottom:0">
|
||
<div class="admin-section-title">Ajouter une catégorie</div>
|
||
<div style="display:grid;grid-template-columns:auto 1fr auto;gap:8px;align-items:end">
|
||
<div>
|
||
<label class="inp-lbl">Emoji</label>
|
||
<input id="newCatEmoji" type="text" value="📦" style="background:var(--surface2);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:20px;padding:9px;width:48px;text-align:center;outline:none;">
|
||
</div>
|
||
<div>
|
||
<label class="inp-lbl">Nom de la catégorie</label>
|
||
<div class="inp-row"><input id="newCatLabel" type="text" placeholder="ex: Vacances" onkeydown="if(event.key==='Enter')addCategory()"></div>
|
||
</div>
|
||
<button class="btn-add" style="align-self:end" onclick="addCategory()">+ Ajouter</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- TAB SIMULATEUR -->
|
||
<div class="tab-panel wrap" id="tab-simu">
|
||
<div class="grid">
|
||
<div class="card simu-card full">
|
||
<div class="card-lbl">Simulateur de budget</div>
|
||
<div class="card-name" style="color:#f9a875">🎯 Projets & Objectifs</div>
|
||
|
||
<!-- Formulaire ajout projet -->
|
||
<div class="add-simu-form">
|
||
<div class="add-simu-title">Nouveau projet</div>
|
||
<div class="simu-form-grid">
|
||
<div>
|
||
<label class="inp-lbl">Emoji</label>
|
||
<input id="simuEmoji" type="text" value="🏖" maxlength="2" style="background:var(--surface);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:20px;padding:9px;width:48px;text-align:center;outline:none;">
|
||
</div>
|
||
<div>
|
||
<label class="inp-lbl">Nom du projet</label>
|
||
<div class="inp-row"><input id="simuName" type="text" placeholder="ex: Vacances Espagne" onkeydown="if(event.key==='Enter')addSimuProject()"></div>
|
||
</div>
|
||
<div>
|
||
<label class="inp-lbl">Montant cible</label>
|
||
<div class="inp-row"><input id="simuTarget" type="number" placeholder="3000" min="1" onkeydown="if(event.key==='Enter')addSimuProject()"><span class="inp-sfx">€</span></div>
|
||
</div>
|
||
<div>
|
||
<label class="inp-lbl">Déjà économisé</label>
|
||
<div class="inp-row"><input id="simuSaved" type="number" placeholder="0" min="0" value="0"><span class="inp-sfx">€</span></div>
|
||
</div>
|
||
<div>
|
||
<label class="inp-lbl">Échéance</label>
|
||
<div class="inp-row"><input id="simuDeadline" type="month"></div>
|
||
</div>
|
||
<div>
|
||
<label class="inp-lbl">Répartition</label>
|
||
<div class="inp-row">
|
||
<select id="simuSplit" style="font-size:13px;padding:11px 8px">
|
||
<option value="auto">Proportionnelle aux revenus</option>
|
||
<option value="50">50% / 50%</option>
|
||
<option value="custom">Personnalisée</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="simuCustomSplit" style="display:none;margin-top:10px">
|
||
<label class="inp-lbl">Part de <span id="simuSplitNameA">—</span> (%)</label>
|
||
<div style="display:flex;align-items:center;gap:10px">
|
||
<input type="range" id="simuCustomPct" min="0" max="100" step="1" value="50" oninput="updateSimuSplitLabel()" style="flex:1;accentColor:#f9a875">
|
||
<span id="simuSplitLabel" style="font-size:12px;color:#f9a875;white-space:nowrap;min-width:90px">50% / 50%</span>
|
||
</div>
|
||
</div>
|
||
<button class="btn-add" style="background:#f9a875;margin-top:14px;width:100%" onclick="addSimuProject()">+ Créer le projet</button>
|
||
</div>
|
||
|
||
<!-- Liste projets -->
|
||
<div class="simu-projects" id="simuProjects">
|
||
<div class="simu-empty" id="simuEmpty">Aucun projet pour l'instant.<br>Créez votre premier objectif d'épargne ci-dessus.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- EDIT EXPENSE MODAL -->
|
||
<div class="modal-overlay hidden" id="editModal">
|
||
<div class="modal-card">
|
||
<div class="modal-title">Modifier la dépense</div>
|
||
<div class="modal-err" id="editErr"></div>
|
||
<div class="inp-grp">
|
||
<label class="inp-lbl">Libellé</label>
|
||
<div class="inp-row"><input id="editName" type="text" onkeydown="if(event.key==='Enter')confirmEdit()"></div>
|
||
</div>
|
||
<div class="inp-grp">
|
||
<label class="inp-lbl">Montant</label>
|
||
<div class="inp-row"><input id="editAmount" type="number" min="0" onkeydown="if(event.key==='Enter')confirmEdit()"><span class="inp-sfx">€</span></div>
|
||
</div>
|
||
<div class="inp-grp">
|
||
<label class="inp-lbl">Catégorie</label>
|
||
<div class="inp-row"><select id="editCat" style="font-size:13px;padding:11px 8px"></select></div>
|
||
</div>
|
||
<div class="inp-grp">
|
||
<label class="inp-lbl">Fréquence</label>
|
||
<div class="inp-row"><select id="editFreq" style="font-size:13px;padding:11px 8px" onchange="toggleEditMonthField()">
|
||
<option value="mensuel">Mensuel</option>
|
||
<option value="hebdo">Hebdo</option>
|
||
<option value="annuel">Annuel</option>
|
||
<option value="ponctuel">Ponctuel</option>
|
||
</select></div>
|
||
</div>
|
||
<div class="inp-grp" id="editMonthField" style="display:none">
|
||
<label class="inp-lbl">Mois</label>
|
||
<div class="inp-row"><select id="editMonth" style="font-size:13px;padding:11px 8px"></select></div>
|
||
</div>
|
||
<div class="modal-btns">
|
||
<button class="btn-cancel" onclick="closeEditModal()">Annuler</button>
|
||
<button class="btn-confirm" onclick="confirmEdit()">Enregistrer</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- VALIDATE SIMU MODAL -->
|
||
<div class="modal-overlay hidden" id="validateModal">
|
||
<div class="modal-card">
|
||
<div class="modal-title">✓ Valider le projet</div>
|
||
<div style="font-size:12px;color:var(--muted);margin-bottom:18px" id="validateModalDesc"></div>
|
||
<div class="modal-err" id="validateErr"></div>
|
||
<div class="inp-grp">
|
||
<label class="inp-lbl">À partir de quel mois ?</label>
|
||
<div class="inp-row">
|
||
<select id="validateMonth" style="font-size:13px;padding:11px 8px"></select>
|
||
</div>
|
||
</div>
|
||
<div style="background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:12px;font-size:11px;color:var(--muted);line-height:1.7;margin-bottom:4px" id="validatePreview"></div>
|
||
<div class="modal-btns">
|
||
<button class="btn-cancel" onclick="closeValidateModal()">Annuler</button>
|
||
<button class="btn-confirm" style="background:#f9a875" onclick="confirmValidateSimu()">Ajouter aux dépenses</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<footer class="wrap">Données hébergées sur votre réseau · Budget Commun</footer>
|
||
|
||
<script>
|
||
// ─── AUTH ─────────────────────────────────────────────────────
|
||
let authToken = localStorage.getItem('budget-token') || null;
|
||
let isAdmin = false;
|
||
|
||
function getToken(){ return authToken; }
|
||
function setToken(t){ authToken=t; t?localStorage.setItem('budget-token',t):localStorage.removeItem('budget-token'); }
|
||
|
||
async function doLogin(){
|
||
const username=document.getElementById('loginUser').value.trim();
|
||
const password=document.getElementById('loginPass').value;
|
||
const errEl=document.getElementById('loginErr');
|
||
errEl.classList.remove('show');
|
||
if(!username||!password){ errEl.textContent='Remplissez tous les champs.'; errEl.classList.add('show'); return; }
|
||
try{
|
||
const r=await fetch('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username,password})});
|
||
const data=await r.json();
|
||
if(!r.ok){ errEl.textContent=data.error||'Erreur'; errEl.classList.add('show'); return; }
|
||
setToken(data.token);
|
||
isAdmin=data.isAdmin;
|
||
document.getElementById('currentUser').textContent=data.username;
|
||
document.getElementById('loginScreen').classList.add('hidden');
|
||
document.getElementById('loginPass').value='';
|
||
if(isAdmin) document.getElementById('btn-admin').style.display='flex';
|
||
loadAll();
|
||
}catch{ errEl.textContent='Impossible de joindre le serveur.'; errEl.classList.add('show'); }
|
||
}
|
||
|
||
function doLogout(){
|
||
setToken(null); isAdmin=false;
|
||
document.getElementById('loginScreen').classList.remove('hidden');
|
||
document.getElementById('loginUser').value=''; document.getElementById('loginPass').value='';
|
||
document.getElementById('currentUser').textContent='';
|
||
document.getElementById('btn-admin').style.display='none';
|
||
switchTab('revenus');
|
||
}
|
||
|
||
function openPwdModal(){ document.getElementById('pwdModal').classList.remove('hidden'); }
|
||
function closePwdModal(){
|
||
document.getElementById('pwdModal').classList.add('hidden');
|
||
['pwdCurrent','pwdNew','pwdConfirm'].forEach(id=>document.getElementById(id).value='');
|
||
document.getElementById('pwdErr').classList.remove('show');
|
||
document.getElementById('pwdOk').classList.remove('show');
|
||
}
|
||
async function doChangePassword(){
|
||
const cur=document.getElementById('pwdCurrent').value;
|
||
const nw=document.getElementById('pwdNew').value;
|
||
const cf=document.getElementById('pwdConfirm').value;
|
||
const errEl=document.getElementById('pwdErr');
|
||
errEl.classList.remove('show'); document.getElementById('pwdOk').classList.remove('show');
|
||
if(!cur||!nw||!cf){ errEl.textContent='Remplissez tous les champs.'; errEl.classList.add('show'); return; }
|
||
if(nw!==cf){ errEl.textContent='Les mots de passe ne correspondent pas.'; errEl.classList.add('show'); return; }
|
||
if(nw.length<6){ errEl.textContent='6 caractères minimum.'; errEl.classList.add('show'); return; }
|
||
try{
|
||
await api('POST','/auth/change-password',{currentPassword:cur,newPassword:nw});
|
||
document.getElementById('pwdOk').classList.add('show');
|
||
setTimeout(closePwdModal,1500);
|
||
}catch(e){ errEl.textContent=e.message||'Erreur'; errEl.classList.add('show'); }
|
||
}
|
||
|
||
// ─── API ──────────────────────────────────────────────────────
|
||
async function api(method,path,body){
|
||
const opts={method,headers:{'Content-Type':'application/json','Authorization':'Bearer '+getToken()}};
|
||
if(body) opts.body=JSON.stringify(body);
|
||
const r=await fetch('/api'+path,opts);
|
||
if(r.status===401){ doLogout(); throw new Error('Session expirée'); }
|
||
if(!r.ok){ const d=await r.json().catch(()=>({})); throw new Error(d.error||'Erreur serveur'); }
|
||
return r.json();
|
||
}
|
||
|
||
// ─── TOAST ────────────────────────────────────────────────────
|
||
let toastT=null;
|
||
function toast(msg,type='ok'){ const t=document.getElementById('toast'); t.textContent=msg; t.className='toast show t-'+type; clearTimeout(toastT); toastT=setTimeout(()=>t.className='toast',2600); }
|
||
function setSave(s,l){ document.getElementById('sdot').className='save-dot '+s; document.getElementById('slbl').textContent=l; }
|
||
|
||
// ─── STATE ────────────────────────────────────────────────────
|
||
let settings={nameA:'Alexandre',nameB:'Stéphanie',incomeA:'2600',incomeB:'1500',sliderVal:63,sliderLocked:false};
|
||
let expenses=[];
|
||
let categories=[];
|
||
let currentFilter='all';
|
||
let saveTimer=null;
|
||
|
||
// ─── LOAD ─────────────────────────────────────────────────────
|
||
async function loadAll(){
|
||
if(!getToken()){
|
||
document.getElementById('loginScreen').classList.remove('hidden');
|
||
return;
|
||
}
|
||
document.getElementById('loginScreen').classList.add('hidden');
|
||
const loadEl = document.getElementById('loading');
|
||
loadEl.classList.remove('hidden');
|
||
try{
|
||
const me=await api('GET','/auth/me');
|
||
isAdmin=me.isAdmin;
|
||
document.getElementById('currentUser').textContent=me.username;
|
||
if(isAdmin) document.getElementById('btn-admin').style.display='flex';
|
||
const [s,e,c]=await Promise.all([api('GET','/settings'),api('GET','/expenses'),api('GET','/categories')]);
|
||
settings={...settings,...s,sliderVal:parseInt(s.sliderVal)||63,sliderLocked:s.sliderLocked==='true'};
|
||
expenses=e; categories=c;
|
||
applySettings(); populateCatSelect(); renderExpenses(); renderCatAdmin(); calculate();
|
||
setSave('ok','Synchronisé');
|
||
}catch(err){
|
||
if(err.message==='Session expirée'){
|
||
document.getElementById('loginScreen').classList.remove('hidden');
|
||
} else {
|
||
setSave('err','Hors ligne');
|
||
toast('Impossible de joindre le serveur','err');
|
||
}
|
||
} finally {
|
||
loadEl.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
// ─── SETTINGS ─────────────────────────────────────────────────
|
||
function applySettings(){
|
||
document.getElementById('nameA').value=settings.nameA;
|
||
document.getElementById('nameB').value=settings.nameB;
|
||
document.getElementById('incomeA').value=settings.incomeA;
|
||
document.getElementById('incomeB').value=settings.incomeB;
|
||
const ia=parseFloat(settings.incomeA)||0,ib=parseFloat(settings.incomeB)||0;
|
||
const pct=settings.sliderLocked?settings.sliderVal:(ia+ib>0?Math.round(ia/(ia+ib)*100):63);
|
||
document.getElementById('slider').value=pct;
|
||
updateNameDisplays(pct);
|
||
}
|
||
function updateNameDisplays(pct){
|
||
const a=settings.nameA||'Moi',b=settings.nameB||'Partenaire';
|
||
['na-disp','rna','rrna','blA'].forEach(id=>document.getElementById(id).textContent=a);
|
||
['nb-disp','rnb','rrnb','blB'].forEach(id=>document.getElementById(id).textContent=b);
|
||
document.getElementById('slvA').textContent=a+' : '+pct+'%';
|
||
document.getElementById('slvB').textContent=b+' : '+(100-pct)+'%';
|
||
}
|
||
function onSettingChange(resetSlider=false){
|
||
settings.nameA=document.getElementById('nameA').value;
|
||
settings.nameB=document.getElementById('nameB').value;
|
||
settings.incomeA=document.getElementById('incomeA').value;
|
||
settings.incomeB=document.getElementById('incomeB').value;
|
||
if(resetSlider) settings.sliderLocked=false;
|
||
const ia=parseFloat(settings.incomeA)||0,ib=parseFloat(settings.incomeB)||0;
|
||
const pct=settings.sliderLocked?settings.sliderVal:(ia+ib>0?Math.round(ia/(ia+ib)*100):63);
|
||
if(!settings.sliderLocked) document.getElementById('slider').value=pct;
|
||
updateNameDisplays(pct); calculate(); scheduleSave();
|
||
}
|
||
function onSlider(){
|
||
const pct=parseInt(document.getElementById('slider').value);
|
||
settings.sliderVal=pct; settings.sliderLocked=true;
|
||
updateNameDisplays(pct); calculate(); scheduleSave();
|
||
}
|
||
function scheduleSave(){
|
||
setSave('saving','Sauvegarde…'); clearTimeout(saveTimer);
|
||
saveTimer=setTimeout(async()=>{
|
||
try{
|
||
await api('POST','/settings',{...settings,sliderVal:String(settings.sliderVal),sliderLocked:String(settings.sliderLocked)});
|
||
setSave('ok','Sauvegardé à '+new Date().toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'}));
|
||
}catch{ setSave('err','Erreur sauvegarde'); }
|
||
},800);
|
||
}
|
||
|
||
// ─── CALCULATE ────────────────────────────────────────────────
|
||
function calculate(){
|
||
const ia=parseFloat(settings.incomeA)||0,ib=parseFloat(settings.incomeB)||0;
|
||
const recur=expenses.filter(e=>e.freq!=='ponctuel');
|
||
const tot=recur.reduce((s,e)=>s+mly(e),0);
|
||
const totalInc=ia+ib;
|
||
const shareA=totalInc>0?(ia/totalInc)*100:50;
|
||
document.getElementById('annualA').textContent=fmt(ia*12);
|
||
document.getElementById('annualB').textContent=fmt(ib*12);
|
||
document.getElementById('shareA').textContent=fmtPct(shareA);
|
||
document.getElementById('shareB').textContent=fmtPct(100-shareA);
|
||
const pct=settings.sliderLocked?settings.sliderVal:Math.round(shareA);
|
||
document.getElementById('budgetDisplay').value=Math.round(tot)||0;
|
||
document.getElementById('budgetAnnual').textContent=fmt(tot*12);
|
||
document.getElementById('budgetHint').textContent=recur.length>0?'↑ Calculé depuis '+recur.length+' dépense(s) récurrente(s).':'Ajoutez des dépenses récurrentes dans l\'onglet Dépenses.';
|
||
const cA=tot*pct/100,cB=tot-cA,rA=ia-cA,rB=ib-cB;
|
||
document.getElementById('cA').textContent=fmt(cA)+'/mois';document.getElementById('cAy').textContent=fmt(cA*12)+' / an';
|
||
document.getElementById('cB').textContent=fmt(cB)+'/mois';document.getElementById('cBy').textContent=fmt(cB*12)+' / an';
|
||
document.getElementById('barA').style.width=pct+'%';document.getElementById('barB').style.width=(100-pct)+'%';
|
||
document.getElementById('rA').textContent=fmt(rA)+'/mois';document.getElementById('rB').textContent=fmt(rB)+'/mois';
|
||
document.getElementById('rAp').textContent=ia>0?fmtPct(rA/ia*100)+' du revenu':'—';
|
||
document.getElementById('rBp').textContent=ib>0?fmtPct(rB/ib*100)+' du revenu':'—';
|
||
const az=document.getElementById('alertZone');
|
||
if(rA<0||rB<0){const who=[];if(rA<0)who.push(settings.nameA);if(rB<0)who.push(settings.nameB);az.innerHTML='<div class="alert warn">⚠ Budget dépasse le revenu de : '+who.join(', ')+'.</div>';}
|
||
else if(tot>0&&Math.abs(rA/(ia||1)*100-rB/(ib||1)*100)<5){az.innerHTML='<div class="alert ok-al">✓ Répartition équitable ('+fmtPct(rA/(ia||1)*100)+' vs '+fmtPct(rB/(ib||1)*100)+' restant).</div>';}
|
||
else{az.innerHTML='';}
|
||
document.getElementById('dtm').textContent=fmt(tot);
|
||
document.getElementById('dty').textContent=fmt(tot*12);
|
||
document.getElementById('dcnt').textContent=expenses.length;
|
||
}
|
||
|
||
// ─── UTILS ────────────────────────────────────────────────────
|
||
const fmt=n=>new Intl.NumberFormat('fr-FR',{maximumFractionDigits:0}).format(Math.round(n))+' €';
|
||
const fmtPct=n=>n.toFixed(1)+'%';
|
||
const FREQS={mensuel:{l:'mensuel',m:1},hebdo:{l:'hebdo ×4',m:4.33},annuel:{l:'annuel ÷12',m:1/12},ponctuel:{l:'ponctuel',m:0}};
|
||
function mly(e){ return e.freq==='ponctuel'?0:e.amount*(FREQS[e.freq]?.m||1); }
|
||
function getCat(slug){ return categories.find(c=>c.slug===slug)||{label:'Autre',emoji:'📦',color:'#888'}; }
|
||
const MONTHS={'01':'Jan','02':'Fév','03':'Mar','04':'Avr','05':'Mai','06':'Jun','07':'Jul','08':'Aoû','09':'Sep','10':'Oct','11':'Nov','12':'Déc'};
|
||
function fmtMonth(m){ if(!m)return''; const [y,mo]=m.split('-'); return (MONTHS[mo]||mo)+' '+y; }
|
||
|
||
// ─── DÉPENSES ─────────────────────────────────────────────────
|
||
function populateCatSelect(){
|
||
const sel=document.getElementById('depCat');
|
||
sel.innerHTML=categories.filter(c=>c.active).map(c=>`<option value="${c.slug}">${c.emoji} ${c.label}</option>`).join('');
|
||
// S'assurer que la fréquence est à mensuel et le champ mois masqué
|
||
document.getElementById('depFreq').value='mensuel';
|
||
document.getElementById('monthField').style.display='none';
|
||
}
|
||
|
||
function toggleMonthField(){
|
||
const freq=document.getElementById('depFreq').value;
|
||
const mf=document.getElementById('monthField');
|
||
mf.style.display=freq==='ponctuel'?'block':'none';
|
||
// preset current month
|
||
if(freq==='ponctuel'){
|
||
const now=new Date();
|
||
const val=now.getFullYear()+'-'+String(now.getMonth()+1).padStart(2,'0');
|
||
const sel=document.getElementById('depMonth');
|
||
if(sel.querySelector('option[value="'+val+'"]')) sel.value=val;
|
||
}
|
||
}
|
||
|
||
async function addExpense(){
|
||
const name=document.getElementById('depName').value.trim();
|
||
const amount=parseFloat(document.getElementById('depAmt').value);
|
||
const cat=document.getElementById('depCat').value;
|
||
const freq=document.getElementById('depFreq').value;
|
||
const month=freq==='ponctuel'?document.getElementById('depMonth').value:null;
|
||
if(!name||!amount||amount<=0){ const el=document.getElementById('depName'); el.style.outline='2px solid var(--danger)'; setTimeout(()=>el.style.outline='',800); return; }
|
||
setSave('saving','Sauvegarde…');
|
||
try{
|
||
const newExp=await api('POST','/expenses',{name,amount,cat,freq,month});
|
||
expenses.push(newExp);
|
||
document.getElementById('depName').value=''; document.getElementById('depAmt').value='';
|
||
document.getElementById('depName').focus();
|
||
renderExpenses(); calculate();
|
||
setSave('ok','Sauvegardé à '+new Date().toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'}));
|
||
}catch{ setSave('err','Erreur sauvegarde'); toast('Erreur réseau','err'); }
|
||
}
|
||
|
||
// ─── EDIT EXPENSE ─────────────────────────────────────────────
|
||
let editTargetId = null;
|
||
|
||
function openEditModal(id){
|
||
const e = expenses.find(e=>e.id===id);
|
||
if(!e) return;
|
||
editTargetId = id;
|
||
|
||
document.getElementById('editName').value = e.name;
|
||
document.getElementById('editAmount').value = e.amount;
|
||
document.getElementById('editFreq').value = e.freq;
|
||
|
||
// Populate cat select
|
||
const catSel = document.getElementById('editCat');
|
||
catSel.innerHTML = categories.filter(c=>c.active).map(c=>`<option value="${c.slug}">${c.emoji} ${c.label}</option>`).join('');
|
||
catSel.value = e.cat;
|
||
|
||
// Populate month select
|
||
const monthSel = document.getElementById('editMonth');
|
||
const MOIS=['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
|
||
monthSel.innerHTML = '';
|
||
const now = new Date();
|
||
for(let i=-3; i<12; i++){
|
||
const d = new Date(now.getFullYear(), now.getMonth()+i, 1);
|
||
const val = d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0');
|
||
const opt = document.createElement('option');
|
||
opt.value = val; opt.textContent = MOIS[d.getMonth()]+' '+d.getFullYear();
|
||
monthSel.appendChild(opt);
|
||
}
|
||
if(e.month) monthSel.value = e.month;
|
||
|
||
toggleEditMonthField();
|
||
document.getElementById('editErr').classList.remove('show');
|
||
document.getElementById('editModal').classList.remove('hidden');
|
||
setTimeout(()=>document.getElementById('editName').focus(), 50);
|
||
}
|
||
|
||
function toggleEditMonthField(){
|
||
const freq = document.getElementById('editFreq').value;
|
||
document.getElementById('editMonthField').style.display = freq==='ponctuel' ? 'block' : 'none';
|
||
}
|
||
|
||
function closeEditModal(){
|
||
document.getElementById('editModal').classList.add('hidden');
|
||
editTargetId = null;
|
||
}
|
||
|
||
async function confirmEdit(){
|
||
const name = document.getElementById('editName').value.trim();
|
||
const amount = parseFloat(document.getElementById('editAmount').value);
|
||
const cat = document.getElementById('editCat').value;
|
||
const freq = document.getElementById('editFreq').value;
|
||
const month = freq==='ponctuel' ? document.getElementById('editMonth').value : null;
|
||
const errEl = document.getElementById('editErr');
|
||
errEl.classList.remove('show');
|
||
|
||
if(!name||!amount||amount<=0){ errEl.textContent='Libellé et montant requis.'; errEl.classList.add('show'); return; }
|
||
|
||
setSave('saving','Sauvegarde…');
|
||
try{
|
||
const updated = await api('PUT','/expenses/'+editTargetId, {name, amount, cat, freq, month});
|
||
const idx = expenses.findIndex(e=>e.id===editTargetId);
|
||
if(idx>=0) expenses[idx] = updated;
|
||
renderExpenses(); calculate();
|
||
const now = new Date().toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'});
|
||
setSave('ok','Sauvegardé à '+now);
|
||
toast('Dépense modifiée ✓');
|
||
closeEditModal();
|
||
}catch(e){
|
||
errEl.textContent = e.message||'Erreur serveur';
|
||
errEl.classList.add('show');
|
||
setSave('err','Erreur sauvegarde');
|
||
}
|
||
}
|
||
|
||
async function deleteExpense(id){
|
||
setSave('saving','Sauvegarde…');
|
||
try{
|
||
await api('DELETE','/expenses/'+id);
|
||
expenses=expenses.filter(e=>e.id!==id);
|
||
renderExpenses(); calculate();
|
||
setSave('ok','Sauvegardé à '+new Date().toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'}));
|
||
}catch{ setSave('err','Erreur sauvegarde'); }
|
||
}
|
||
|
||
function filterCat(cat,btn){
|
||
currentFilter=cat;
|
||
document.querySelectorAll('.cat-pill').forEach(p=>p.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
renderExpenses();
|
||
}
|
||
|
||
function expItemHTML(e){
|
||
const cat=getCat(e.cat);
|
||
const isPonctuel=e.freq==='ponctuel';
|
||
const badge=isPonctuel?`<span class="exp-badge ponctuel">🗓 ${fmtMonth(e.month)}</span>`:`<span class="exp-badge">${FREQS[e.freq]?.l||e.freq}</span>`;
|
||
return `<div class="exp-item">
|
||
<div class="exp-icon" style="background:${cat.color||cat.c||'#888'}22">${cat.emoji}</div>
|
||
<div><div class="exp-name">${e.name}</div><div class="exp-meta">${cat.label}</div></div>
|
||
<div class="exp-amt">${fmt(e.amount)}</div>
|
||
${badge}
|
||
<div style="display:flex;gap:4px">
|
||
<button class="btn-del" onclick="openEditModal(${e.id})" title="Modifier" style="color:var(--muted)">✏️</button>
|
||
<button class="btn-del" onclick="deleteExpense(${e.id})" title="Supprimer">✕</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderExpenses(){
|
||
const showPonctuel=currentFilter==='__ponctuel__';
|
||
const recurEl=document.getElementById('expListRecur');
|
||
const ponctuelEl=document.getElementById('expListPonctuel');
|
||
const recurSec=document.getElementById('recurSection');
|
||
const ponctuelSec=document.getElementById('ponctuelSection');
|
||
|
||
const recur=expenses.filter(e=>e.freq!=='ponctuel');
|
||
const ponctuel=expenses.filter(e=>e.freq==='ponctuel');
|
||
|
||
if(showPonctuel){
|
||
recurSec.style.display='none'; ponctuelSec.style.display='block';
|
||
ponctuelEl.innerHTML=ponctuel.length?ponctuel.map(expItemHTML).join(''):'<div class="exp-empty">Aucune dépense ponctuelle.</div>';
|
||
} else {
|
||
ponctuelSec.style.display='none'; recurSec.style.display='block';
|
||
const filtered=currentFilter==='all'?recur:recur.filter(e=>e.cat===currentFilter);
|
||
recurEl.innerHTML=filtered.length?filtered.map(expItemHTML).join(''):'<div class="exp-empty">'+(recur.length===0?'Aucune dépense pour l\'instant.<br>Ajoutez votre première dépense ci-dessus.':'Aucune dépense dans cette catégorie.')+'</div>';
|
||
}
|
||
|
||
document.getElementById('ponctuelCount').textContent=ponctuel.length;
|
||
|
||
// Update cat filters
|
||
const cf=document.getElementById('catFilters');
|
||
const activeCats=categories.filter(c=>c.active);
|
||
cf.innerHTML='<button class="cat-pill'+(currentFilter==='all'?' active':'')+'" onclick="filterCat(\'all\',this)">Toutes</button>'
|
||
+'<button class="cat-pill'+( currentFilter==='__ponctuel__'?' active':'')+'" onclick="filterCat(\'__ponctuel__\',this)">🗓 Ponctuelles</button>'
|
||
+activeCats.map(c=>`<button class="cat-pill${currentFilter===c.slug?' active':''}" onclick="filterCat('${c.slug}',this)">${c.emoji}</button>`).join('');
|
||
|
||
renderCatTotals();
|
||
}
|
||
|
||
function renderCatTotals(){
|
||
const recur=expenses.filter(e=>e.freq!=='ponctuel');
|
||
const tot=recur.reduce((s,e)=>s+mly(e),0);
|
||
const sec=document.getElementById('catTotals');
|
||
if(!recur.length){ sec.style.display='none'; return; }
|
||
sec.style.display='block';
|
||
const by={};recur.forEach(e=>{by[e.cat]=(by[e.cat]||0)+mly(e);});
|
||
document.getElementById('catTotList').innerHTML=Object.entries(by).sort((a,b)=>b[1]-a[1]).map(([slug,amount])=>{
|
||
const c=getCat(slug);const pct=tot>0?amount/tot*100:0;
|
||
return `<div class="cat-tot-row"><div style="display:flex;align-items:center;gap:7px;font-size:11px"><span>${c.emoji}</span><span>${c.label}</span></div><div class="cat-bar-wrap"><div class="cat-bar-fill" style="width:${pct.toFixed(1)}%;background:${c.color||c.c||'#888'}"></div></div><div style="font-size:12px;white-space:nowrap">${fmt(amount)}<span style="color:var(--muted);font-size:9px">/mois</span></div></div>`;
|
||
}).join('');
|
||
}
|
||
|
||
// ─── ADMIN : CATEGORIES ───────────────────────────────────────
|
||
function renderCatAdmin(){
|
||
const list=document.getElementById('catAdminList');
|
||
if(!categories.length){ list.innerHTML='<div style="color:var(--muted);font-size:12px;text-align:center;padding:16px">Aucune catégorie.</div>'; return; }
|
||
list.innerHTML=categories.map(c=>`
|
||
<div class="cat-admin-item${c.active?'':' inactive'}" id="catrow-${c.id}">
|
||
<input type="text" value="${c.emoji}" maxlength="2" style="background:var(--surface);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:18px;padding:6px;width:40px;text-align:center;outline:none;" onchange="updateCategory(${c.id},{emoji:this.value})">
|
||
<div style="width:6px;height:6px;border-radius:50%;background:${c.active?'var(--ab)':'var(--border)'};flex-shrink:0"></div>
|
||
<input class="cat-label-inp" type="text" value="${c.label}" onblur="updateCategory(${c.id},{label:this.value})">
|
||
<button class="toggle-btn${c.active?' active':''}" onclick="toggleCategory(${c.id},${c.active})">${c.active?'Actif':'Inactif'}</button>
|
||
<button class="btn-icon" onclick="confirmDeleteCat(${c.id},'${c.label}')">🗑</button>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
async function updateCategory(id, patch){
|
||
try{
|
||
const updated=await api('PUT','/categories/'+id,patch);
|
||
const idx=categories.findIndex(c=>c.id===id);
|
||
if(idx>=0) categories[idx]=updated;
|
||
populateCatSelect(); renderExpenses(); renderCatAdmin();
|
||
setSave('ok','Sauvegardé');
|
||
}catch(e){ toast(e.message,'err'); }
|
||
}
|
||
|
||
async function toggleCategory(id, currentActive){
|
||
await updateCategory(id,{active:currentActive?0:1});
|
||
}
|
||
|
||
function confirmDeleteCat(id,label){
|
||
if(!confirm(`Supprimer la catégorie "${label}" ? Les dépenses associées seront déplacées vers "Autre".`)) return;
|
||
deleteCat(id);
|
||
}
|
||
async function deleteCat(id){
|
||
try{
|
||
await api('DELETE','/categories/'+id);
|
||
categories=categories.filter(c=>c.id!==id);
|
||
// Réassigner localement
|
||
expenses=expenses.map(e=>e.cat===categories.find(c=>c.id===id)?.slug?{...e,cat:'autre'}:e);
|
||
populateCatSelect(); renderExpenses(); renderCatAdmin();
|
||
toast('Catégorie supprimée');
|
||
}catch(e){ toast(e.message,'err'); }
|
||
}
|
||
|
||
async function addCategory(){
|
||
const label=document.getElementById('newCatLabel').value.trim();
|
||
const emoji=document.getElementById('newCatEmoji').value.trim()||'📦';
|
||
if(!label){ document.getElementById('newCatLabel').style.outline='2px solid var(--danger)'; setTimeout(()=>document.getElementById('newCatLabel').style.outline='',800); return; }
|
||
try{
|
||
const newCat=await api('POST','/categories',{label,emoji});
|
||
categories.push(newCat);
|
||
document.getElementById('newCatLabel').value=''; document.getElementById('newCatEmoji').value='📦';
|
||
populateCatSelect(); renderCatAdmin();
|
||
toast('Catégorie ajoutée ✓');
|
||
}catch(e){ toast(e.message,'err'); }
|
||
}
|
||
|
||
// ─── POINTAGE ─────────────────────────────────────────────────
|
||
let ptgData = null;
|
||
let ptgCurrentMonth = null; // 'YYYY-MM'
|
||
|
||
const MOIS_LONG = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
|
||
|
||
function ptgFmtMonth(m) {
|
||
if(!m) return '—';
|
||
const [y, mo] = m.split('-').map(Number);
|
||
// Période du 26 du mois m au 25 du mois m+1
|
||
const end = new Date(y, mo, 25); // mois+1 en JS = mo (0-indexé)
|
||
const endLabel = MOIS_LONG[end.getMonth()] + ' ' + end.getFullYear();
|
||
return MOIS_LONG[mo-1] + ' ' + y + ' – 25 ' + endLabel;
|
||
}
|
||
|
||
function ptgTodayMonth() {
|
||
const d = new Date();
|
||
// Avant le 26 → le mois courant de pointage est le mois précédent
|
||
if(d.getDate() < 26) {
|
||
const prev = new Date(d.getFullYear(), d.getMonth() - 1, 1);
|
||
return prev.getFullYear() + '-' + String(prev.getMonth()+1).padStart(2,'0');
|
||
}
|
||
return d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0');
|
||
}
|
||
|
||
function changePointageMonth(dir) {
|
||
const [y, mo] = ptgCurrentMonth.split('-').map(Number);
|
||
const d = new Date(y, mo - 1 + dir, 1);
|
||
const next = d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0');
|
||
// Ne pas aller au-delà du mois courant
|
||
if(next > ptgTodayMonth()) return;
|
||
ptgCurrentMonth = next;
|
||
loadPointage();
|
||
}
|
||
|
||
async function loadPointage() {
|
||
if(!ptgCurrentMonth) ptgCurrentMonth = ptgTodayMonth();
|
||
// Masquer bouton suivant si mois courant
|
||
document.getElementById('ptgNextBtn').style.opacity = ptgCurrentMonth >= ptgTodayMonth() ? '0.3' : '1';
|
||
document.getElementById('ptgNextBtn').style.pointerEvents = ptgCurrentMonth >= ptgTodayMonth() ? 'none' : '';
|
||
|
||
try {
|
||
ptgData = await api('GET', '/pointage?month=' + ptgCurrentMonth);
|
||
renderPointage();
|
||
} catch(e) {
|
||
toast('Erreur chargement pointage', 'err');
|
||
}
|
||
}
|
||
|
||
function renderPointage() {
|
||
if(!ptgData) return;
|
||
const { month, rows, unpointedPrev, prevMonth } = ptgData;
|
||
|
||
// Header
|
||
document.getElementById('ptgMonthLabel').textContent = (() => {
|
||
const [y, mo] = month.split('-').map(Number);
|
||
return MOIS_LONG[mo-1] + ' ' + y;
|
||
})();
|
||
const [y, mo] = month.split('-').map(Number);
|
||
const end = new Date(y, mo, 25);
|
||
const endLabel = MOIS_LONG[end.getMonth()] + ' ' + end.getFullYear();
|
||
const pointed = rows.filter(r=>r.pointed).length;
|
||
const total = rows.length;
|
||
const pct = total > 0 ? Math.round(pointed/total*100) : 0;
|
||
document.getElementById('ptgMonthSub').textContent = '26 ' + MOIS_LONG[mo-1] + ' → 25 ' + endLabel + ' · ' + pct + '% pointé';
|
||
document.getElementById('ptgProgress').style.width = pct + '%';
|
||
|
||
// Résumé
|
||
const totalPointed = rows.filter(r=>r.pointed).reduce((s,r)=>s+r.amount, 0);
|
||
document.getElementById('ptgPointed').textContent = pointed;
|
||
document.getElementById('ptgRemaining').textContent = total - pointed;
|
||
document.getElementById('ptgTotal').textContent = fmt(totalPointed);
|
||
|
||
// Alerte mois précédent
|
||
const alertZone = document.getElementById('ptgAlertZone');
|
||
if(unpointedPrev && unpointedPrev.length > 0) {
|
||
alertZone.innerHTML = `
|
||
<div class="ptg-alert">
|
||
<div class="ptg-alert-title">
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||
${unpointedPrev.length} dépense(s) non pointée(s) en ${ptgFmtMonth(prevMonth)}
|
||
</div>
|
||
${unpointedPrev.map(r => `
|
||
<div class="ptg-alert-item">
|
||
<span style="color:var(--text)">${r.name}</span>
|
||
<div style="display:flex;align-items:center;gap:10px">
|
||
<span style="color:var(--danger)">${fmt(r.amount)}</span>
|
||
<button class="btn-sm" style="font-size:10px;padding:3px 9px" onclick="pointItem(${r.id}, true, '${prevMonth}')">Pointer</button>
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>`;
|
||
} else {
|
||
alertZone.innerHTML = '';
|
||
}
|
||
|
||
// Liste principale
|
||
const list = document.getElementById('ptgList');
|
||
if(!rows.length) {
|
||
list.innerHTML = '<div class="exp-empty">Aucune dépense récurrente à pointer.<br>Ajoutez des dépenses dans l\'onglet Dépenses.</div>';
|
||
return;
|
||
}
|
||
|
||
// Trier : non pointées en premier
|
||
const sorted = [...rows].sort((a,b) => a.pointed - b.pointed);
|
||
const cat = r => getCat(r.cat || 'autre');
|
||
|
||
list.innerHTML = sorted.map(r => `
|
||
<div class="ptg-item${r.pointed?' pointed':''}" id="ptg-${r.id}" onclick="pointItem(${r.id}, ${r.pointed?0:1}, '${month}')">
|
||
<div class="ptg-check"><span class="ptg-check-icon">✓</span></div>
|
||
<div class="ptg-info">
|
||
<div class="ptg-name">${r.name}</div>
|
||
<div class="ptg-cat">${cat(r).emoji} ${cat(r).label}</div>
|
||
</div>
|
||
<div class="ptg-amount">${fmt(r.amount)}</div>
|
||
<div class="ptg-date">${r.pointed_at ? new Date(r.pointed_at).toLocaleDateString('fr-FR',{day:'2-digit',month:'2-digit'}) : ''}</div>
|
||
</div>
|
||
`).join('');
|
||
|
||
// Bouton tout pointer
|
||
const allPointed = rows.every(r=>r.pointed);
|
||
document.getElementById('ptgPointAllBtn').style.display = allPointed ? 'none' : '';
|
||
}
|
||
|
||
async function pointItem(id, pointed, month) {
|
||
// Mise à jour optimiste — UI immédiate
|
||
const rows = ptgData.rows;
|
||
const prev = ptgData.unpointedPrev;
|
||
const row = rows.find(r=>r.id===id) || prev.find(r=>r.id===id);
|
||
if(row){
|
||
row.pointed = pointed ? 1 : 0;
|
||
row.pointed_at = pointed ? new Date().toISOString() : null;
|
||
}
|
||
renderPointage();
|
||
// Sync API en arrière-plan
|
||
try {
|
||
await api('PUT', '/pointage/' + id, { pointed: pointed ? 1 : 0 });
|
||
} catch(e) {
|
||
// Rollback si erreur
|
||
if(row){ row.pointed = pointed ? 0 : 1; row.pointed_at = null; }
|
||
renderPointage();
|
||
toast('Erreur de synchronisation', 'err');
|
||
}
|
||
}
|
||
|
||
async function pointAll() {
|
||
if(!ptgData) return;
|
||
const unpointed = ptgData.rows.filter(r=>!r.pointed);
|
||
// Mise à jour optimiste
|
||
const now = new Date().toISOString();
|
||
unpointed.forEach(r=>{ r.pointed=1; r.pointed_at=now; });
|
||
renderPointage();
|
||
toast('Toutes les dépenses pointées ✓');
|
||
// Sync API en arrière-plan
|
||
try {
|
||
await Promise.all(unpointed.map(r => api('PUT', '/pointage/'+r.id, {pointed:1})));
|
||
} catch(e) {
|
||
// Rollback
|
||
unpointed.forEach(r=>{ r.pointed=0; r.pointed_at=null; });
|
||
renderPointage();
|
||
toast('Erreur de synchronisation', 'err');
|
||
}
|
||
}
|
||
|
||
// ─── SIMULATEUR ───────────────────────────────────────────────
|
||
let simuProjects = JSON.parse(localStorage.getItem('budget-simu') || '[]');
|
||
let simuNextId = simuProjects.reduce((m,p)=>Math.max(m,p.id),0) + 1;
|
||
|
||
function saveSimu(){ localStorage.setItem('budget-simu', JSON.stringify(simuProjects)); }
|
||
|
||
// Init deadline to next month
|
||
(function(){
|
||
const d=new Date(); d.setMonth(d.getMonth()+6);
|
||
const val=d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0');
|
||
document.getElementById('simuDeadline').value=val;
|
||
})();
|
||
|
||
document.getElementById('simuSplit').addEventListener('change',function(){
|
||
document.getElementById('simuCustomSplit').style.display=this.value==='custom'?'block':'none';
|
||
});
|
||
|
||
function updateSimuSplitLabel(){
|
||
const pct=parseInt(document.getElementById('simuCustomPct').value);
|
||
const a=settings.nameA||'Moi', b=settings.nameB||'Partenaire';
|
||
document.getElementById('simuSplitLabel').textContent=pct+'% / '+(100-pct)+'%';
|
||
document.getElementById('simuSplitNameA').textContent=a;
|
||
}
|
||
|
||
function getSimuPctA(splitMode){
|
||
if(splitMode==='50') return 50;
|
||
if(splitMode==='custom') return parseInt(document.getElementById('simuCustomPct').value)||50;
|
||
// auto: proportionnel aux revenus
|
||
const ia=parseFloat(settings.incomeA)||0, ib=parseFloat(settings.incomeB)||0;
|
||
return (ia+ib)>0 ? Math.round(ia/(ia+ib)*100) : 50;
|
||
}
|
||
|
||
function monthsUntil(deadlineStr){
|
||
if(!deadlineStr) return 0;
|
||
const [y,m]=deadlineStr.split('-').map(Number);
|
||
const now=new Date(); const target=new Date(y,m-1,1);
|
||
return Math.max(1, (target.getFullYear()-now.getFullYear())*12+(target.getMonth()-now.getMonth()));
|
||
}
|
||
|
||
function fmtDeadline(str){
|
||
if(!str) return '—';
|
||
const [y,m]=str.split('-');
|
||
const MOIS=['Jan','Fév','Mar','Avr','Mai','Jun','Jul','Aoû','Sep','Oct','Nov','Déc'];
|
||
return (MOIS[parseInt(m)-1]||m)+' '+y;
|
||
}
|
||
|
||
function addSimuProject(){
|
||
const name=document.getElementById('simuName').value.trim();
|
||
const target=parseFloat(document.getElementById('simuTarget').value);
|
||
const saved=parseFloat(document.getElementById('simuSaved').value)||0;
|
||
const deadline=document.getElementById('simuDeadline').value;
|
||
const split=document.getElementById('simuSplit').value;
|
||
const customPct=parseInt(document.getElementById('simuCustomPct').value)||50;
|
||
const emoji=document.getElementById('simuEmoji').value.trim()||'🎯';
|
||
|
||
if(!name||!target||target<=0){
|
||
const el=document.getElementById('simuName'); el.style.outline='2px solid var(--danger)';
|
||
setTimeout(()=>el.style.outline='',800); return;
|
||
}
|
||
if(!deadline){ const el=document.getElementById('simuDeadline'); el.style.outline='2px solid var(--danger)'; setTimeout(()=>el.style.outline='',800); return; }
|
||
|
||
const pctA = split==='custom' ? customPct : getSimuPctA(split);
|
||
simuProjects.push({ id:simuNextId++, name, emoji, target, saved, deadline, split, customPct, pctA });
|
||
saveSimu();
|
||
document.getElementById('simuName').value='';
|
||
document.getElementById('simuTarget').value='';
|
||
document.getElementById('simuSaved').value='0';
|
||
renderSimu();
|
||
}
|
||
|
||
function deleteSimuProject(id){
|
||
if(!confirm('Supprimer ce projet ?')) return;
|
||
simuProjects=simuProjects.filter(p=>p.id!==id);
|
||
saveSimu(); renderSimu();
|
||
}
|
||
|
||
function toggleSimuProject(id){
|
||
const el=document.getElementById('simu-'+id);
|
||
el.classList.toggle('open');
|
||
}
|
||
|
||
function renderSimu(){
|
||
const container=document.getElementById('simuProjects');
|
||
const empty=document.getElementById('simuEmpty');
|
||
// Update split name
|
||
document.getElementById('simuSplitNameA').textContent=settings.nameA||'Moi';
|
||
updateSimuSplitLabel();
|
||
|
||
if(!simuProjects.length){ empty.style.display='block'; container.innerHTML=''; container.appendChild(empty); return; }
|
||
empty.style.display='none';
|
||
|
||
const nameA=settings.nameA||'Moi', nameB=settings.nameB||'Partenaire';
|
||
const ia=parseFloat(settings.incomeA)||0, ib=parseFloat(settings.incomeB)||0;
|
||
|
||
container.innerHTML = simuProjects.map(p=>{
|
||
const months = monthsUntil(p.deadline);
|
||
const remaining = Math.max(0, p.target - p.saved);
|
||
const totalPerMonth = months > 0 ? remaining / months : remaining;
|
||
const pctA = p.split==='auto' ? ((ia+ib)>0?Math.round(ia/(ia+ib)*100):50) : p.pctA;
|
||
const pctB = 100 - pctA;
|
||
const perMonthA = totalPerMonth * pctA / 100;
|
||
const perMonthB = totalPerMonth * pctB / 100;
|
||
const progress = p.target > 0 ? Math.min(100, p.saved / p.target * 100) : 0;
|
||
const splitLabel = p.split==='50' ? '50% / 50%' : p.split==='auto' ? 'Proportionnel' : 'Personnalisé';
|
||
|
||
// Impact sur reste disponible
|
||
const rA = ia - (parseFloat(document.getElementById('budgetDisplay').value)||0) * pctA/100;
|
||
const rB = ib - (parseFloat(document.getElementById('budgetDisplay').value)||0) * pctB/100;
|
||
const afterA = rA - perMonthA;
|
||
const afterB = rB - perMonthB;
|
||
|
||
return `<div class="simu-project" id="simu-${p.id}">
|
||
<div class="simu-project-header" onclick="toggleSimuProject(${p.id})">
|
||
<div class="simu-project-emoji">${p.emoji}</div>
|
||
<div>
|
||
<div class="simu-project-name">${p.name}</div>
|
||
<div style="font-size:10px;color:var(--muted);margin-top:2px">${fmtDeadline(p.deadline)} · ${months} mois · ${splitLabel}</div>
|
||
</div>
|
||
<div class="simu-project-total">${fmt(p.target)}</div>
|
||
<button class="btn-icon" onclick="event.stopPropagation();deleteSimuProject(${p.id})" style="color:var(--muted)">🗑</button>
|
||
<div class="simu-project-chevron">▶</div>
|
||
</div>
|
||
<div class="simu-project-body">
|
||
|
||
<!-- Progress bar -->
|
||
<div class="simu-progress-wrap">
|
||
<div class="simu-progress-lbl">
|
||
<span>Épargne actuelle : ${fmt(p.saved)}</span>
|
||
<span>${progress.toFixed(0)}% · Reste ${fmt(remaining)}</span>
|
||
</div>
|
||
<div class="simu-progress-track"><div class="simu-progress-fill" style="width:${progress.toFixed(1)}%"></div></div>
|
||
</div>
|
||
|
||
<!-- Résultats par personne -->
|
||
<div class="simu-result-grid">
|
||
<div class="simu-result-card">
|
||
<div class="simu-result-head"><span style="width:6px;height:6px;border-radius:50%;background:var(--aa);display:inline-block"></span>${nameA} <span style="color:var(--muted)">(${pctA}%)</span></div>
|
||
<div class="simu-result-amt" style="color:var(--aa)">${fmt(perMonthA)}<span style="font-size:13px">/mois</span></div>
|
||
<div class="simu-result-sub">${fmt(perMonthA*months)} au total</div>
|
||
<div style="margin-top:8px;padding-top:8px;border-top:1px solid var(--border);font-size:11px;color:${afterA<0?'var(--danger)':'var(--muted)'}">
|
||
Reste après : ${fmt(afterA)}/mois ${afterA<0?'⚠':''}
|
||
</div>
|
||
</div>
|
||
<div class="simu-result-card">
|
||
<div class="simu-result-head"><span style="width:6px;height:6px;border-radius:50%;background:var(--ab);display:inline-block"></span>${nameB} <span style="color:var(--muted)">(${pctB}%)</span></div>
|
||
<div class="simu-result-amt" style="color:var(--ab)">${fmt(perMonthB)}<span style="font-size:13px">/mois</span></div>
|
||
<div class="simu-result-sub">${fmt(perMonthB*months)} au total</div>
|
||
<div style="margin-top:8px;padding-top:8px;border-top:1px solid var(--border);font-size:11px;color:${afterB<0?'var(--danger)':'var(--muted)'}">
|
||
Reste après : ${fmt(afterB)}/mois ${afterB<0?'⚠':''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Deadline info -->
|
||
<div class="simu-deadline">
|
||
🗓 Objectif atteint en <span>${fmtDeadline(p.deadline)}</span> si vous épargnez <span>${fmt(totalPerMonth)}/mois</span> ensemble.
|
||
</div>
|
||
|
||
<!-- Valider et ajouter aux dépenses -->
|
||
<div style="margin-top:14px;padding-top:14px;border-top:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap">
|
||
${p.validated
|
||
? `<div style="display:flex;align-items:center;gap:7px;font-size:11px;color:var(--ab)">✓ Ajouté aux dépenses communes depuis ${fmtDeadline(p.validatedFrom)}<button class="btn-sm" style="font-size:10px;margin-left:8px" onclick="event.stopPropagation();unvalidateSimu(${p.id})">Annuler</button></div>`
|
||
: `<div style="font-size:11px;color:var(--muted)">Prêt à démarrer l'épargne ?</div>
|
||
<button class="btn-add" style="background:#f9a875;padding:10px 16px;font-size:11px" onclick="event.stopPropagation();openValidateModal(${p.id})">✓ Valider & ajouter aux dépenses</button>`
|
||
}
|
||
</div>
|
||
|
||
<!-- Mettre à jour l'épargne -->
|
||
<div style="margin-top:12px;display:flex;align-items:end;gap:8px;flex-wrap:wrap">
|
||
<div style="flex:1;min-width:140px">
|
||
<label class="inp-lbl">Mettre à jour l'épargne actuelle</label>
|
||
<div class="inp-row"><input type="number" id="simuUpdate-${p.id}" value="${p.saved}" min="0" placeholder="0"><span class="inp-sfx">€</span></div>
|
||
</div>
|
||
<button class="btn-add" style="background:#f9a875;padding:10px 14px;font-size:11px" onclick="updateSimuSaved(${p.id})">Mettre à jour</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function updateSimuSaved(id){
|
||
const val=parseFloat(document.getElementById('simuUpdate-'+id).value)||0;
|
||
const p=simuProjects.find(p=>p.id===id);
|
||
if(!p) return;
|
||
p.saved=val;
|
||
saveSimu(); renderSimu();
|
||
// Réouvrir le panel
|
||
const el=document.getElementById('simu-'+id);
|
||
if(el) el.classList.add('open');
|
||
toast('Épargne mise à jour ✓');
|
||
}
|
||
|
||
// ─── VALIDATION SIMULATEUR → DÉPENSES ────────────────────────
|
||
let validateTargetId = null;
|
||
|
||
function openValidateModal(id){
|
||
const p = simuProjects.find(p=>p.id===id);
|
||
if(!p) return;
|
||
validateTargetId = id;
|
||
|
||
const ia=parseFloat(settings.incomeA)||0, ib=parseFloat(settings.incomeB)||0;
|
||
const months = monthsUntil(p.deadline);
|
||
const remaining = Math.max(0, p.target - p.saved);
|
||
const totalPerMonth = months>0 ? remaining/months : remaining;
|
||
const pctA = p.split==='auto' ? ((ia+ib)>0?Math.round(ia/(ia+ib)*100):50) : p.pctA;
|
||
const pctB = 100-pctA;
|
||
const perMonthA = totalPerMonth*pctA/100;
|
||
const perMonthB = totalPerMonth*pctB/100;
|
||
|
||
// Description
|
||
document.getElementById('validateModalDesc').textContent =
|
||
`${p.emoji} ${p.name} — ${fmt(totalPerMonth)}/mois pendant ${months} mois`;
|
||
|
||
// Remplir le sélecteur de mois (mois courant + 11 suivants)
|
||
const sel = document.getElementById('validateMonth');
|
||
sel.innerHTML = '';
|
||
const MOIS=['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
|
||
const now = new Date();
|
||
for(let i=0; i<12; i++){
|
||
const d = new Date(now.getFullYear(), now.getMonth()+i, 1);
|
||
const val = d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0');
|
||
const opt = document.createElement('option');
|
||
opt.value = val;
|
||
opt.textContent = MOIS[d.getMonth()]+' '+d.getFullYear();
|
||
if(i===0) opt.selected = true;
|
||
sel.appendChild(opt);
|
||
}
|
||
|
||
// Preview
|
||
updateValidatePreview(p, perMonthA, perMonthB, totalPerMonth);
|
||
sel.addEventListener('change', () => updateValidatePreview(p, perMonthA, perMonthB, totalPerMonth));
|
||
|
||
document.getElementById('validateErr').classList.remove('show');
|
||
document.getElementById('validateModal').classList.remove('hidden');
|
||
}
|
||
|
||
function updateValidatePreview(p, perMonthA, perMonthB, totalPerMonth){
|
||
const month = document.getElementById('validateMonth').value;
|
||
const nameA = settings.nameA||'Moi', nameB = settings.nameB||'Partenaire';
|
||
document.getElementById('validatePreview').innerHTML =
|
||
`Sera ajouté dans <strong style="color:var(--text)">Dépenses communes</strong> :<br>
|
||
· <span style="color:var(--aa)">${nameA}</span> : <strong style="color:var(--text)">${fmt(perMonthA)}/mois</strong><br>
|
||
· <span style="color:var(--ab)">${nameB}</span> : <strong style="color:var(--text)">${fmt(perMonthB)}/mois</strong><br>
|
||
· À partir de <strong style="color:var(--text)">${fmtDeadline(month)}</strong>, fréquence mensuelle`;
|
||
}
|
||
|
||
function closeValidateModal(){
|
||
document.getElementById('validateModal').classList.add('hidden');
|
||
validateTargetId = null;
|
||
}
|
||
|
||
async function confirmValidateSimu(){
|
||
const p = simuProjects.find(p=>p.id===validateTargetId);
|
||
if(!p) return;
|
||
const fromMonth = document.getElementById('validateMonth').value;
|
||
const errEl = document.getElementById('validateErr');
|
||
errEl.classList.remove('show');
|
||
|
||
const ia=parseFloat(settings.incomeA)||0, ib=parseFloat(settings.incomeB)||0;
|
||
const months = monthsUntil(p.deadline);
|
||
const remaining = Math.max(0, p.target - p.saved);
|
||
const totalPerMonth = months>0 ? remaining/months : remaining;
|
||
|
||
// Créer UNE dépense commune mensuelle pour le total
|
||
// (la répartition est gérée par le slider du budget commun)
|
||
setSave('saving','Sauvegarde…');
|
||
try{
|
||
// Chercher ou créer catégorie "épargne" si elle existe, sinon "autre"
|
||
const catSlug = categories.find(c=>c.slug==='epargne'||c.label.toLowerCase().includes('épargne'))?.slug || 'autre';
|
||
|
||
const newExp = await api('POST','/expenses',{
|
||
name: p.emoji+' '+p.name,
|
||
amount: Math.round(totalPerMonth*100)/100,
|
||
cat: catSlug,
|
||
freq: 'mensuel',
|
||
month: null
|
||
});
|
||
expenses.push(newExp);
|
||
|
||
// Marquer le projet comme validé
|
||
p.validated = true;
|
||
p.validatedFrom = fromMonth;
|
||
p.expenseId = newExp.id;
|
||
saveSimu();
|
||
|
||
renderExpenses(); calculate(); renderSimu();
|
||
// Rouvrir le projet
|
||
setTimeout(()=>{ const el=document.getElementById('simu-'+p.id); if(el) el.classList.add('open'); }, 50);
|
||
|
||
const now=new Date().toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'});
|
||
setSave('ok','Sauvegardé à '+now);
|
||
toast(`${p.emoji} ${p.name} ajouté aux dépenses ✓`);
|
||
closeValidateModal();
|
||
}catch(e){
|
||
errEl.textContent = e.message||'Erreur lors de l\'ajout';
|
||
errEl.classList.add('show');
|
||
setSave('err','Erreur sauvegarde');
|
||
}
|
||
}
|
||
|
||
async function unvalidateSimu(id){
|
||
const p = simuProjects.find(p=>p.id===id);
|
||
if(!p) return;
|
||
if(!confirm('Retirer cette dépense du budget commun ?')) return;
|
||
// Supprimer la dépense liée si elle existe encore
|
||
if(p.expenseId){
|
||
try{ await api('DELETE','/expenses/'+p.expenseId); expenses=expenses.filter(e=>e.id!==p.expenseId); }
|
||
catch{}
|
||
}
|
||
p.validated=false; p.validatedFrom=null; p.expenseId=null;
|
||
saveSimu(); renderExpenses(); calculate(); renderSimu();
|
||
setTimeout(()=>{ const el=document.getElementById('simu-'+p.id); if(el) el.classList.add('open'); },50);
|
||
toast('Dépense retirée du budget');
|
||
}
|
||
|
||
function toggleAccountMenu(){
|
||
const m=document.getElementById('accountMenu');
|
||
m.style.display=m.style.display==='none'?'block':'none';
|
||
}
|
||
document.addEventListener('click',function(e){
|
||
const menu=document.getElementById('accountMenu');
|
||
const btn=document.getElementById('accountBtn');
|
||
if(menu&&btn&&!menu.contains(e.target)&&!btn.contains(e.target)){
|
||
menu.style.display='none';
|
||
}
|
||
});
|
||
|
||
// ─── TABS & RESET ─────────────────────────────────────────────
|
||
function switchTab(name){
|
||
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
|
||
document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));
|
||
document.getElementById('tab-'+name).classList.add('active');
|
||
document.getElementById('btn-'+name).classList.add('active');
|
||
if(name==='simu') renderSimu();
|
||
if(name==='pointage'){ if(!ptgCurrentMonth) ptgCurrentMonth=ptgTodayMonth(); loadPointage(); }
|
||
}
|
||
|
||
async function confirmReset(){
|
||
if(!confirm('Réinitialiser toutes les données ?')) return;
|
||
try{
|
||
await Promise.all(expenses.map(e=>api('DELETE','/expenses/'+e.id)));
|
||
await api('POST','/settings',{nameA:'Alexandre',nameB:'Stéphanie',incomeA:'2600',incomeB:'1500',sliderVal:'63',sliderLocked:'false'});
|
||
expenses=[]; settings={nameA:'Alexandre',nameB:'Stéphanie',incomeA:'2600',incomeB:'1500',sliderVal:63,sliderLocked:false};
|
||
applySettings(); renderExpenses(); calculate();
|
||
setSave('ok','Réinitialisé'); toast('Budget réinitialisé ✓');
|
||
}catch{ toast('Erreur lors de la réinitialisation','err'); }
|
||
}
|
||
|
||
// ─── PULL TO REFRESH ──────────────────────────────────────────
|
||
(function(){
|
||
const THRESHOLD = 80; // px to pull before triggering
|
||
let startY = 0, pulling = false, triggered = false;
|
||
const bar = document.getElementById('ptrBar');
|
||
const icon = document.getElementById('ptrIcon');
|
||
const txt = document.getElementById('ptrTxt');
|
||
|
||
function isAtTop(){ return window.scrollY <= 0; }
|
||
|
||
document.addEventListener('touchstart', e => {
|
||
if(!isAtTop() || !getToken()) return;
|
||
startY = e.touches[0].clientY;
|
||
pulling = true;
|
||
triggered = false;
|
||
}, { passive: true });
|
||
|
||
document.addEventListener('touchmove', e => {
|
||
if(!pulling) return;
|
||
const dy = e.touches[0].clientY - startY;
|
||
if(dy <= 0){ pulling = false; return; }
|
||
|
||
const progress = Math.min(dy / THRESHOLD, 1);
|
||
bar.style.height = Math.min(dy * 0.5, 50) + 'px';
|
||
bar.classList.add('show');
|
||
icon.style.transform = `rotate(${progress * 180}deg)`;
|
||
|
||
if(dy >= THRESHOLD && !triggered){
|
||
triggered = true;
|
||
txt.textContent = 'Relâcher pour rafraîchir';
|
||
icon.style.color = 'var(--aa)';
|
||
} else if(dy < THRESHOLD) {
|
||
txt.textContent = 'Tirer pour rafraîchir';
|
||
}
|
||
}, { passive: true });
|
||
|
||
document.addEventListener('touchend', async () => {
|
||
if(!pulling) return;
|
||
pulling = false;
|
||
|
||
if(triggered){
|
||
// Spinner actif
|
||
icon.classList.add('spin');
|
||
icon.style.transform = '';
|
||
txt.textContent = 'Mise à jour…';
|
||
bar.style.height = '50px';
|
||
|
||
try {
|
||
await loadAll();
|
||
txt.textContent = 'Mis à jour ✓';
|
||
icon.style.color = 'var(--ab)';
|
||
} catch {
|
||
txt.textContent = 'Erreur réseau';
|
||
icon.style.color = 'var(--danger)';
|
||
}
|
||
|
||
setTimeout(() => {
|
||
icon.classList.remove('spin');
|
||
icon.style.color = '';
|
||
bar.classList.remove('show');
|
||
bar.style.height = '';
|
||
txt.textContent = 'Tirer pour rafraîchir';
|
||
}, 800);
|
||
} else {
|
||
bar.classList.remove('show');
|
||
bar.style.height = '';
|
||
icon.style.transform = '';
|
||
}
|
||
});
|
||
})();
|
||
|
||
// ─── SERVICE WORKER ───────────────────────────────────────────
|
||
if('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(()=>{});
|
||
|
||
// ─── INIT ─────────────────────────────────────────────────────
|
||
if(getToken()){ loadAll(); }
|
||
else{ document.getElementById('loading').classList.add('hidden'); document.getElementById('loginScreen').classList.remove('hidden'); }
|
||
</script>
|
||
</body>
|
||
</html>
|