Files

1653 lines
93 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 &amp; 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 &amp; 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 &amp; 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 &amp; 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>