Финансы
Загружаю...
История
Все
Расходы
Доходы
Накопления
Загружаю...
Накопления
Загружаю...
Регулярные
Загружаю...
Долги
Загружаю...
Инвестиции
Загружаю...
Лимиты бюджета 2026
Расходы
Доходы
Накопления
const W="https://finance-tracker-bot.daniel-chistyakov.workers.dev"; const CC=["#378ADD","#1D9E75","#EF9F27","#D4537E","#7F77DD","#D85A30","#888780","#639922","#BA7517","#0F6E56","#993C1D","#534AB7"]; const EM={"Супермаркеты":"🛒","Супермаркет":"🛒","Кафе":"☕","Кафе и рестораны":"☕","Транспорт":"🚗","Подписки":"📱","Квартира":"🏠","Здоровье":"💊","Красота и здоровье":"💊","Одежда":"👕","Развлечения":"🎬","Семья":"👨‍👩‍👧","Прочее":"📦","Основная работа":"💼","Фриланс":"💻","Кэшбек и %":"💳","Инвестиции":"📈","Накопления":"🏦","Подарки":"🎁"}; const MN=["янв","фев","мар","апр","май","июн","июл","авг","сен","окт","ноя","дек"]; const MNF=["января","февраля","марта","апреля","мая","июня","июля","августа","сентября","октября","ноября","декабря"]; const MNR=["Январь","Февраль","Март","Апрель","Май","Июнь","Июль","Август","Сентябрь","Октябрь","Ноябрь","Декабрь"]; const DN=["вс","пн","вт","ср","чт","пт","сб"]; const tg=window.Telegram?.WebApp; if(tg){tg.ready();tg.expand();} const initData=tg?.initData||""; const dark=matchMedia("(prefers-color-scheme:dark)").matches; const tick=dark?"#5f5e5a":"#888780"; const grid=dark?"rgba(255,255,255,0.05)":"rgba(0,0,0,0.05)"; // Period state — from/to as Date objects const now=new Date(); function monthStart(y,m){return new Date(y,m,1,0,0,0,0);} function monthEnd(y,m){return new Date(y,m+1,0,23,59,59,999);} function quickPeriod(key){ const n=new Date(); const y=n.getFullYear(),m=n.getMonth(),d=n.getDay(); const wd=d===0?6:d-1; // monday=0 if(key==="week") return {from:new Date(n.getFullYear(),n.getMonth(),n.getDate()-wd),to:new Date(n.getFullYear(),n.getMonth(),n.getDate()-wd+6,23,59,59,999),label:"Эта неделя"}; if(key==="this_month") return {from:monthStart(y,m),to:monthEnd(y,m),label:MNR[m]+" "+y}; if(key==="last_month"){const pm=m===0?11:m-1,py=m===0?y-1:y;return{from:monthStart(py,pm),to:monthEnd(py,pm),label:MNR[pm]+" "+py};} if(key==="year") return {from:new Date(y,0,1),to:new Date(y,11,31,23,59,59,999),label:"Год "+y}; if(key==="last_year") return {from:new Date(y-1,0,1),to:new Date(y-1,11,31,23,59,59,999),label:"Год "+(y-1)}; return {from:monthStart(y,m),to:monthEnd(y,m),label:MNR[m]+" "+y}; } function fmtPeriodLabel(from,to){ const f=from,t=to; if(f.getMonth()===t.getMonth()&&f.getFullYear()===t.getFullYear()&&f.getDate()===1&&t.getDate()===new Date(t.getFullYear(),t.getMonth()+1,0).getDate()) return MNR[f.getMonth()]+" "+f.getFullYear(); if(f.getMonth()===0&&f.getDate()===1&&t.getMonth()===11&&t.getDate()===31) return "Год "+f.getFullYear(); return `${f.getDate()} ${MN[f.getMonth()]} — ${t.getDate()} ${MN[t.getMonth()]}${f.getFullYear()!==t.getFullYear()?" "+t.getFullYear():""}`; } function fmtDateShort(d){return d.getDate()+" "+MN[d.getMonth()]+" "+d.getFullYear();} let state={ dashFrom:monthStart(now.getFullYear(),now.getMonth()), dashTo:monthEnd(now.getFullYear(),now.getMonth()), txFrom:monthStart(now.getFullYear(),now.getMonth()), txTo:monthEnd(now.getFullYear(),now.getMonth()), txType:"",txSearch:"", dashData:null,txData:null,goalsData:null,recData:null, charts:{},editTx:null, drpTarget:null,drpFrom:null,drpTo:null,drpSelecting:null, drpViewYear:now.getFullYear(),drpViewMonth:now.getMonth(), }; async function api(path,params={},method="GET",body=null){ const url=new URL(W+path); if(method==="GET")Object.entries(params).forEach(([k,v])=>{if(v!=null&&v!=="")url.searchParams.set(k,v);}); const o={method,headers:{"X-Init-Data":initData}}; if(body){o.headers["Content-Type"]="application/json";o.body=JSON.stringify(body);} const r=await fetch(url,o); if(!r.ok)throw new Error(await r.text()); return r.json(); } function fmt(n){return new Intl.NumberFormat("ru-RU").format(Math.round(Math.abs(n)))+" ₽";} // Безопасный парсер дат: поддерживает ISO (2026-03-14) и DD.MM.YYYY function parseTxDate(dtStr){ if(!dtStr)return new Date(NaN); if(/^\d{4}-\d{2}-\d{2}/.test(dtStr))return new Date(dtStr); const parts=dtStr.split("."); if(parts.length===3){const[d,m,y]=parts;return new Date(+y,+m-1,+d);} return new Date(dtStr); } function delta(c,p,type="expense"){ if(!p||p===0)return{text:"",cls:""}; const pct=Math.round(((c-p)/p)*100); if(Math.abs(pct)<3)return{text:"",cls:""}; if(type==="income"){ // для дохода: рост — хорошо (зелёный = neg), падение — плохо (красный = pos) return pct>0?{text:`↑${pct}%`,cls:"neg"}:{text:`↓${Math.abs(pct)}%`,cls:"pos"}; } // для расходов: рост — плохо (красный = pos), снижение — хорошо (зелёный = neg) return pct>0?{text:`↑${pct}%`,cls:"pos"}:{text:`↓${Math.abs(pct)}%`,cls:"neg"}; } function dc(id){if(state.charts[id]){state.charts[id].destroy();delete state.charts[id];}} function sp(){return'
Загружаю...
';} function periodParams(from,to){ return{from_ts:from.getTime(),to_ts:to.getTime()}; } // ── Dashboard ────────────────────────────────────────────────────────── async function loadDashboard(){ document.getElementById("dash-content").innerHTML=sp(); updatePeriodBtn("dash"); try{ const d=await api("/api/dashboard",periodParams(state.dashFrom,state.dashTo)); state.dashData=d;renderDashboard(d); }catch(e){document.getElementById("dash-content").innerHTML='
Не удалось загрузить данные
';} } function updatePeriodBtn(target){ const f=target==="dash"?state.dashFrom:state.txFrom; const t=target==="dash"?state.dashTo:state.txTo; const lbl=fmtPeriodLabel(f,t); const id=target==="dash"?"dash-period-btn":"tx-period-btn"; document.getElementById(id).innerHTML=lbl+' '; } function renderDashboard(d){ const s=d.summary,p=d.prev; const dI=delta(s.income,p.income,"income"),dE=delta(s.expense,p.expense,"expense"); const isYearRange=(state.dashTo-state.dashFrom)>60*24*3600*1000; const trend=(d.monthlyTrend||[]).filter(m=>m.expense>0||m.income>0||m.savings>0); let html=`
Доходы
${fmt(s.income)}
${dI.text||" "}
Расходы
${fmt(s.expense)}
${dE.text||" "}
Cashflow
${s.cashflow>0?"+":s.cashflow<0?"−":""}${fmt(s.cashflow)}
Накоплено
${fmt(s.savings)}
${s.savingsRate!=null?s.savingsRate+"% дохода отложено":" "}
`; // Круговые (для коротких периодов) if(!isYearRange){ html+=`
Структура
`; } // Расходы по категориям (с бюджетами если есть) const hasBudgets=d.budgets&&Object.keys(d.budgets).length>0; html+=`
Расходы по категориям
`; (d.expenseByCategory||[]).forEach((c,i)=>{ const col=CC[i%CC.length]; const lim=d.budgets?.[c.category]; if(lim){ const pct=Math.min(100,Math.round((c.amount/lim)*100)); const barCol=pct>=100?"var(--rd)":pct>=80?"#EF9F27":"var(--gn)"; const left=lim-c.amount; const leftTxt=left>0?`ост. ${fmt(left)}`:`перерасход ${fmt(-left)}`; html+=`
${c.category} ${fmt(c.amount)} ${fmt(lim)}
=80?"#EF9F27":"var(--tx3)"}">${pct}% ${leftTxt}
`; }else{ const bw=Math.round((c.pct/(d.expenseByCategory[0]?.pct||1))*100); html+=`
${c.category}
${fmt(c.amount)}${c.pct}%
`; } }); html+=`
`; // Сравнение с предыдущим периодом if(d.expenseByCategoryMap&&Object.keys(d.expenseByCategoryMap).length>0){ html+=`
Сравнение с прошлым
КатегорияСейчасРаньше
`; const prevMap=d.prevExpenseByCategory||{}; (d.expenseByCategory||[]).slice(0,6).forEach(c=>{ const prev=prevMap[c.category]||0; const dlt=delta(c.amount,prev); html+=`
${c.category} ${fmt(c.amount)} ${prev>0?fmt(prev):"—"} ${dlt.text||"—"}
`; }); html+=`
`; } // Тренд (для длинных периодов) if(isYearRange&&trend.length>0){ html+=`
Динамика
`; } document.getElementById("dash-content").innerHTML=html; if(!isYearRange){ renderPie(d,"expense"); document.getElementById("pie-tabs")?.addEventListener("click",e=>{ const b=e.target.closest(".ctab");if(!b)return; document.querySelectorAll("#pie-tabs .ctab").forEach(x=>x.classList.remove("active")); b.classList.add("active");renderPie(d,b.dataset.chart); }); } if(isYearRange&&trend.length>0){ renderTrend(trend,"expense"); document.getElementById("trend-tabs")?.addEventListener("click",e=>{ const b=e.target.closest(".ctab");if(!b)return; document.querySelectorAll("#trend-tabs .ctab").forEach(x=>x.classList.remove("active")); b.classList.add("active");renderTrend(trend,b.dataset.trend); }); } } function renderPie(d,type){ dc("pie"); const ctx=document.getElementById("pie-chart"),leg=document.getElementById("pie-legend"); if(!ctx||!leg)return; let es=[]; if(type==="expense")es=(d.expenseByCategory||[]).map((c,i)=>({l:c.category,v:c.amount,p:c.pct,c:CC[i%CC.length]})); else if(type==="income"){const t=Object.values(d.incomeByCategory||{}).reduce((a,b)=>a+b,0);es=Object.entries(d.incomeByCategory||{}).map(([k,v],i)=>({l:k,v,p:t>0?Math.round(v/t*100):0,c:CC[i%CC.length]}));} else{const t=Object.values(d.savingsByAccount||{}).reduce((a,b)=>a+b,0);es=Object.entries(d.savingsByAccount||{}).map(([k,v],i)=>({l:k,v,p:t>0?Math.round(v/t*100):0,c:["#378ADD","#1D9E75","#EF9F27"][i]||"#888780"}));} es=es.filter(e=>e.v>0); if(!es.length){leg.innerHTML='Нет данных';return;} state.charts["pie"]=new Chart(ctx,{type:"doughnut",data:{labels:es.map(e=>e.l),datasets:[{data:es.map(e=>e.v),backgroundColor:es.map(e=>e.c),borderWidth:0,hoverOffset:4}]},options:{responsive:true,maintainAspectRatio:true,cutout:"65%",plugins:{legend:{display:false},tooltip:{callbacks:{label:c=>fmt(c.raw)}}}}}); leg.innerHTML=es.slice(0,6).map(e=>`
${e.l}${e.p}%
`).join(""); } function renderTrend(trend,type){ dc("months"); const ctx=document.getElementById("months-chart");if(!ctx)return; const cm={expense:"#E24B4A",income:"#1D9E75",saving:"#378ADD"}; state.charts["months"]=new Chart(ctx,{type:"bar",data:{labels:trend.map(m=>m.label),datasets:[{data:trend.map(m=>type==="saving"?m.savings:m[type]||0),backgroundColor:cm[type],borderRadius:4,borderSkipped:false}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false},tooltip:{callbacks:{label:c=>fmt(c.raw)}}},scales:{x:{grid:{display:false},ticks:{color:tick,font:{size:11}}},y:{grid:{color:grid},border:{display:false},ticks:{color:tick,font:{size:11},callback:v=>Math.round(v/1000)+"к"}}}}}); } // ── Detail overlay ───────────────────────────────────────────────────── function openDetail(type,data){ const ov=document.getElementById("detail-overlay"); document.getElementById("overlay-title").textContent=type==="income"?"Доходы":"Расходы"; const tot=document.getElementById("overlay-total"); tot.textContent=fmt(type==="income"?data.summary.income:data.summary.expense); tot.style.color=type==="income"?"var(--gn)":"var(--rd)"; document.getElementById("overlay-body").innerHTML=renderDetailCats( type==="income"?data.incomeByCategory:data.expenseByCategoryMap, type==="income"?data.incomeBySubcategory:data.expenseBySubcategory ); ov.classList.add("open"); } function closeDetail(){document.getElementById("detail-overlay").classList.remove("open");} function renderDetailCats(byCat,bySub){ if(!byCat||!Object.keys(byCat).length)return'
Нет данных
'; const total=Object.values(byCat).reduce((a,b)=>a+b,0); const entries=Object.entries(byCat).sort((a,b)=>b[1]-a[1]); let html='
'; entries.forEach(([cat,val],i)=>{ const c=CC[i%CC.length]; const pct=total>0?Math.round((val/total)*100):0; const subs=Object.entries(bySub||{}).filter(([k])=>k.startsWith(cat+"/")).sort((a,b)=>b[1]-a[1]); html+=`
${cat} ${fmt(val)} ${pct}%
`; if(subs.length){ html+=`
`; subs.forEach(([k,sv])=>{const sn=k.split("/").slice(1).join("/");const sp=val>0?Math.round((sv/val)*100):0; html+=`
└ ${sn} ${fmt(sv)} ${sp}%
`; }); html+=`
`; } html+=`
`; }); return html+"
"; } document.getElementById("overlay-back").addEventListener("click",closeDetail); // ── Date Range Picker ────────────────────────────────────────────────── function openDRP(target){ state.drpTarget=target; state.drpFrom=target==="dash"?new Date(state.dashFrom):new Date(state.txFrom); state.drpTo=target==="dash"?new Date(state.dashTo):new Date(state.txTo); state.drpSelecting="start"; state.drpViewYear=state.drpFrom.getFullYear(); state.drpViewMonth=state.drpFrom.getMonth(); renderDRP(); updateDRPFooter(); document.getElementById("drp-overlay").classList.remove("hidden"); } function closeDRP(){document.getElementById("drp-overlay").classList.add("hidden");} function renderDRP(){ const y=state.drpViewYear,m=state.drpViewMonth; const first=new Date(y,m,1).getDay(); const startWd=first===0?6:first-1; const daysInMonth=new Date(y,m+1,0).getDate(); const today=new Date(); let html=`
${MNR[m]} ${y}
${["Пн","Вт","Ср","Чт","Пт","Сб","Вс"].map(d=>`
${d}
`).join("")}`; for(let i=0;i`; for(let d=1;d<=daysInMonth;d++){ const date=new Date(y,m,d); const ts=date.getTime(); const fts=state.drpFrom?new Date(state.drpFrom.getFullYear(),state.drpFrom.getMonth(),state.drpFrom.getDate()).getTime():null; const tts=state.drpTo?new Date(state.drpTo.getFullYear(),state.drpTo.getMonth(),state.drpTo.getDate()).getTime():null; const dts=new Date(y,m,d).getTime(); const isToday=d===today.getDate()&&m===today.getMonth()&&y===today.getFullYear(); const isFuture=date>today; let cls="drp-day"; if(isToday)cls+=" today"; if(isFuture)cls+=" disabled"; if(fts&&dts===fts&&tts&&dts===tts)cls+=" selected"; else if(fts&&dts===fts)cls+=" range-start"; else if(tts&&dts===tts)cls+=" range-end"; else if(fts&&tts&&dts>fts&&dts${d}`; } html+=`
`; document.getElementById("drp-cal").innerHTML=html; } function updateDRPFooter(){ document.getElementById("drp-start-lbl").textContent=state.drpFrom?fmtDateShort(state.drpFrom):"—"; document.getElementById("drp-end-lbl").textContent=state.drpTo?fmtDateShort(state.drpTo):"—"; document.getElementById("drp-apply").disabled=!(state.drpFrom&&state.drpTo); // Подсветить активный пресет document.querySelectorAll(".drp-q").forEach(b=>b.classList.remove("active")); } // Один постоянный listener для календаря (не пересоздаётся при каждом renderDRP) document.getElementById("drp-cal").addEventListener("click",e=>{ const nav=e.target.closest("#drp-prev,#drp-next"); if(nav){ if(nav.id==="drp-prev"){state.drpViewMonth--;if(state.drpViewMonth<0){state.drpViewMonth=11;state.drpViewYear--;}} else{state.drpViewMonth++;if(state.drpViewMonth>11){state.drpViewMonth=0;state.drpViewYear++;}} renderDRP();return; } const btn=e.target.closest("[data-date]");if(!btn)return; const [y,m,d]=btn.dataset.date.split("-").map(Number); const clicked=new Date(y,m,d); if(state.drpSelecting==="start"||!state.drpFrom){ state.drpFrom=clicked;state.drpTo=null;state.drpSelecting="end"; }else{ if(clicked{ if(!state.drpFrom||!state.drpTo)return; if(state.drpTarget==="dash"){state.dashFrom=state.drpFrom;state.dashTo=state.drpTo;state.dashData=null;closeDRP();loadDashboard();} else{state.txFrom=state.drpFrom;state.txTo=state.drpTo;state.txData=null;closeDRP();loadTransactions();} }); document.querySelectorAll(".drp-q").forEach(btn=>{ btn.addEventListener("click",()=>{ const p=quickPeriod(btn.dataset.quick); state.drpFrom=p.from;state.drpTo=p.to;state.drpSelecting="start"; state.drpViewYear=p.from.getFullYear();state.drpViewMonth=p.from.getMonth(); document.querySelectorAll(".drp-q").forEach(b=>b.classList.remove("active")); btn.classList.add("active"); updateDRPFooter();renderDRP(); }); }); document.getElementById("drp-overlay").addEventListener("click",e=>{ if(e.target===document.getElementById("drp-overlay"))closeDRP(); }); document.getElementById("dash-period-btn").addEventListener("click",()=>openDRP("dash")); document.getElementById("tx-period-btn").addEventListener("click",()=>openDRP("tx")); // ── Transactions ─────────────────────────────────────────────────────── async function loadTransactions(){ document.getElementById("tx-content").innerHTML=sp(); updatePeriodBtn("tx"); try{ const d=await api("/api/transactions",{...periodParams(state.txFrom,state.txTo),type:state.txType}); state.txData=d;renderTransactions(d,state.txSearch); }catch(e){document.getElementById("tx-content").innerHTML='
Не удалось загрузить данные
';} } function renderTransactions(d,search=""){ const txs=(d.transactions||[]).filter(tx=>{ if(!search)return true; const q=search.toLowerCase(); return(tx.category||"").toLowerCase().includes(q)||(tx.subcategory||"").toLowerCase().includes(q)||(tx.comment||"").toLowerCase().includes(q); }); if(!txs.length){document.getElementById("tx-content").innerHTML='
Нет записей
';return;} const byDay={}; txs.forEach(tx=>{ const raw=tx.dt||""; const parsed=parseTxDate(raw); const day=isNaN(parsed.getTime())?raw:parsed.toISOString().slice(0,10); if(!byDay[day])byDay[day]=[];byDay[day].push({...tx,_parsed:parsed}); }); let html='
'; Object.entries(byDay).sort((a,b)=>b[0].localeCompare(a[0])).forEach(([day,txs2])=>{ const dt=txs2[0]._parsed; const label=isNaN(dt.getTime())?day:`${dt.getDate()} ${MN[dt.getMonth()]}, ${DN[dt.getDay()]}`; html+=`
${label}
`; txs2.forEach(tx=>{ const isE=tx.type==="Расход",isI=tx.type==="Доход"; const emoji=EM[tx.category]||(isI?"💰":isE?"📦":"🏦"); const ac=isE?"e":isI?"i":"s"; const sign=isE?"−":isI?"+":""; const sub=tx.subcategory||tx.comment||""; html+=`
${emoji}
${tx.category}
${sub?`
${sub}
`:""}
${sign}${fmt(Math.abs(tx.amount))}
`; }); }); document.getElementById("tx-content").innerHTML=html+"
"; } document.getElementById("tx-search").addEventListener("input",e=>{ state.txSearch=e.target.value; if(state.txData)renderTransactions(state.txData,state.txSearch); }); document.getElementById("tx-content").addEventListener("click",e=>{ const item=e.target.closest(".txi");if(!item)return;openEdit(item); }); document.getElementById("page-transactions").addEventListener("click",e=>{ const pill=e.target.closest(".fpill"); if(pill){document.querySelectorAll(".fpill").forEach(p=>p.classList.remove("active"));pill.classList.add("active");state.txType=pill.dataset.type;state.txData=null;loadTransactions();} }); // ── Edit ─────────────────────────────────────────────────────────────── function openEdit(item){ state.editTx={ txId:Number(item.dataset.txid||0)||null, row:Number(item.dataset.row), type:item.dataset.type, cat:item.dataset.cat, sub:item.dataset.sub, amt:item.dataset.amt, com:item.dataset.com }; document.getElementById("edit-ttl").textContent="Редактировать · "+item.dataset.type; document.getElementById("edit-cat").value=item.dataset.cat; document.getElementById("edit-sub").value=item.dataset.sub; document.getElementById("edit-amt").value=item.dataset.amt; document.getElementById("edit-com").value=item.dataset.com; document.getElementById("edit-overlay").classList.remove("hidden"); } function closeEdit(){document.getElementById("edit-overlay").classList.add("hidden");state.editTx=null;} document.getElementById("edit-cancel").addEventListener("click",closeEdit); document.getElementById("edit-save").addEventListener("click",async()=>{ if(!state.editTx)return; const btn=document.getElementById("edit-save");btn.textContent="Сохраняю...";btn.disabled=true; try{ const tx=state.editTx; const amt=Number(document.getElementById("edit-amt").value); await api("/api/edit",{},"POST",{ txId:tx.txId||undefined, row:tx.row, amount:tx.type==="Расход"?-Math.abs(amt):Math.abs(amt), category:document.getElementById("edit-cat").value, subcategory:document.getElementById("edit-sub").value, comment:document.getElementById("edit-com").value, type:tx.type }); closeEdit();state.txData=null;state.dashData=null;loadTransactions(); }catch(e){alert("Ошибка: "+e.message);} finally{btn.textContent="Сохранить";btn.disabled=false;} }); document.getElementById("edit-del").addEventListener("click",async()=>{ if(!state.editTx||!confirm("Удалить запись?"))return; const btn=document.getElementById("edit-del");btn.textContent="Удаляю...";btn.disabled=true; try{ await api("/api/delete",{},"POST",{txId:state.editTx.txId||undefined,row:state.editTx.row}); closeEdit();state.txData=null;state.dashData=null;loadTransactions(); }catch(e){alert("Ошибка: "+e.message);} finally{btn.textContent="Удалить";btn.disabled=false;} }); // ── Goals ────────────────────────────────────────────────────────────── async function loadGoals(){ document.getElementById("goals-content").innerHTML=sp(); try{const d=await api("/api/goals");state.goalsData=d;renderGoals(d);} catch(e){document.getElementById("goals-content").innerHTML='
Не удалось загрузить данные
';} } function renderGoals(d){ const gc={"Резерв":"#378ADD","Инвестиции":"#1D9E75","Дети":"#EF9F27"}; const goals=d.goals||[]; if(!goals.length){document.getElementById("goals-content").innerHTML='
Нет данных по накоплениям
';return;} let html='
'; goals.forEach(g=>{ const c=gc[g.account]||"#888780"; if(!g.goal){html+=`
${g.account}${fmt(g.balance)}
Цель не задана
`;return;} const g2=g.goal; html+=`
${g.account}${g2.pct}%
${fmt(g.balance)}из ${fmt(g2.target)}
${g2.monthPlan?`
Ежемесячно: ${fmt(g2.monthPlan)} · осталось ${g2.monthsLeft} мес.
`:""}
`; }); document.getElementById("goals-content").innerHTML=html+"
"; } // ── Recurring ────────────────────────────────────────────────────────── async function loadRecurring(){ document.getElementById("rec-content").innerHTML=sp(); try{const d=await api("/api/recurring");state.recData=d;renderRecurring(d);} catch(e){document.getElementById("rec-content").innerHTML='
Не удалось загрузить данные
';} } function renderRecurring(d){ const items=d.items||[]; if(!items.length){ document.getElementById("rec-content").innerHTML='
Регулярных платежей нет.
Добавь их через бота командой 🔁 Регулярные
'; return; } const now=new Date(); const totalDays=new Date(now.getFullYear(),now.getMonth()+1,0).getDate(); const today=now.getDate(); // Сводка вверху const hasIncome=d.totalIncome>0; let html=`
Расходы в месяц
${fmt(d.totalExpense)}
оплачено ${fmt(d.paidExpense)}
Осталось оплатить
${d.unpaidExpense>0?fmt(d.unpaidExpense):"✓ Всё"}
${d.unpaidExpense>0?"до конца месяца":""}
`; if(hasIncome){ html+=`
Регулярный доход
${fmt(d.totalIncome)}
в месяц
`; } // Разбиваем на "ожидают" и "оплачены" const active=items.filter(r=>r.enabled); const paused=items.filter(r=>!r.enabled); const unpaid=active.filter(r=>r.type==="expense"&&!r.paid).sort((a,b)=>a.day-b.day); const paid=active.filter(r=>r.type==="expense"&&r.paid).sort((a,b)=>a.day-b.day); const incomes=active.filter(r=>r.type==="income").sort((a,b)=>a.day-b.day); function recRow(r){ const emoji=r.type==="income"?"💰":EM[r.category]||"📦"; const daysUntil=r.day-today; const sub=r.subcategory||r.comment||""; let badge=""; if(r.type==="expense"){ if(r.paid) badge=`✓ списано`; else if(daysUntil===0) badge=`сегодня`; else if(daysUntil>0) badge=`через ${daysUntil} дн.`; } return `
${emoji}
${r.category}${sub?" · "+sub+"":""}
${r.day}-го числа${badge?" · ":""}${badge}
${r.type==="income"?"+":"−"}${fmt(r.amount)}
`; } if(unpaid.length){ html+=`
Предстоят${fmt(unpaid.reduce((s,r)=>s+r.amount,0))}
`; unpaid.forEach(r=>{html+=recRow(r);}); html+=`
`; } if(incomes.length){ html+=`
Регулярные доходы
`; incomes.forEach(r=>{html+=recRow(r);}); html+=`
`; } if(paid.length){ html+=`
Списаны в этом месяце${fmt(paid.reduce((s,r)=>s+r.amount,0))}
`; paid.forEach(r=>{html+=recRow(r);}); html+=`
`; } if(paused.length){ html+=`
На паузе
`; paused.forEach(r=>{html+=recRow(r);}); html+=`
`; } html+=`
Управление через бота: 🔁 Регулярные
`; document.getElementById("rec-content").innerHTML=html; } // ── Navigation ───────────────────────────────────────────────────────── function showPage(name){ document.querySelectorAll(".page").forEach(p=>p.classList.remove("active")); document.querySelectorAll(".ni").forEach(n=>n.classList.remove("active")); document.getElementById("page-"+name).classList.add("active"); document.querySelector(`.ni[data-page="${name}"]`).classList.add("active"); if(name==="dashboard"&&!state.dashData)loadDashboard(); if(name==="transactions"&&!state.txData)loadTransactions(); if(name==="goals"&&!state.goalsData)loadGoals(); if(name==="recurring"&&!state.recData)loadRecurring(); if(name==="investments")loadInvestments(); if(name==="debts"&&!state.debtData)loadDebts(); } document.querySelectorAll(".ni").forEach(el=>el.addEventListener("click",()=>showPage(el.dataset.page))); document.getElementById("page-dashboard").addEventListener("click",e=>{ const m=e.target.closest(".mc[data-detail]"); if(m&&state.dashData)openDetail(m.dataset.detail,state.dashData); }); // ── Investments ──────────────────────────────────────────────────────── let invTab="all"; const CLASS_COLORS={"Акции":"#378ADD","Фонды":"#EF9F27","Облигации":"#1D9E75","Фьючерсы":"#D4537E","Валюта":"#7F77DD","Прочее":"#888780"}; function fmtPnl(rub,pct){ const sign=rub>=0?"+":"−"; const cls=rub>=0?"g":"r"; return `${sign}${fmt(Math.abs(rub))} · ${sign}${Math.abs(pct)}%`; } function fmtPct(pct){ return `${pct>=0?"+":""}${pct}%`; } async function loadInvestments(force=false){ if(!force&&state.invData){renderInvestments(state.invData);return;} document.getElementById("inv-content").innerHTML=sp(); try{ const d=await api("/api/investments"); state.invData=d; renderInvestments(d); }catch(e){ document.getElementById("inv-content").innerHTML='
Не удалось загрузить данные
'; } } function fmtK(v){return v>=1000000?`${(v/1000000).toFixed(1)}М ₽`:v>=1000?`${Math.round(v/1000)}к ₽`:`${Math.round(v)} ₽`;} function renderInvestments(d){ const s=d.summary; let html=""; // ── Шапка: общий портфель + сетка ── if(invTab==="all"){ const pnlColor=s.pnlRub>=0?"var(--gn)":"var(--rd)"; const pnlSign=s.pnlRub>=0?"+":"−"; html+=`
Общий портфель
${fmt(s.totalRub)}
≈ ${s.totalUsd.toLocaleString("ru")} $   ${pnlSign}${fmt(Math.abs(s.pnlRub))} · ${pnlSign}${Math.abs(s.pnlPct)}%
Фондовый
${fmtK(s.brokerRub)}
≈ ${Math.round(s.brokerRub/d.usdRub).toLocaleString("ru")} $
${s.brokerPnlPct>=0?"+":""}${s.brokerPnlPct}%
Крипта
${fmtK(s.cryptoRub)}
≈ ${Math.round(s.cryptoRub/d.usdRub).toLocaleString("ru")} $
${s.cryptoPnlPct>=0?"+":""}${s.cryptoPnlPct}%
Кэш
${fmtK(s.cashRub)}
≈ ${s.cashUsd.toLocaleString("ru")} $
`; } // ── Фондовый ── if(invTab==="all"||invTab==="broker"){ if(!d.hasTinkoffToken){ html+=`
Добавьте токен Tinkoff Invest API в Worker:
TINKOFF_TOKEN = ваш_токен
`; } else if(!d.broker.accounts.length){ const errMsg = d.tinkoffError === "http_401" ? "Токен недействителен или истёк. Создайте новый токен в приложении Т-Инвестиции." : d.tinkoffError === "http_403" ? "Токен не имеет прав на чтение. Убедитесь что выбран тип «Только чтение»." : d.tinkoffError ? `Ошибка подключения: ${d.tinkoffError}. Откройте /api/debug для диагностики.` : "Счета не найдены. Возможно, брокерский счёт ещё не открыт."; html+=`
${errMsg}
`; } else { const ACCDOTS=["#378ADD","#EF9F27","#D4537E","#1D9E75"]; html+=`
Фондовый рынокТбанк · live
`; d.broker.accounts.forEach((acc,i)=>{ const dot=ACCDOTS[i%ACCDOTS.length]; const pSign=acc.pnlRub>=0?"+":"−"; const pCol=acc.pnlRub>=0?"var(--gn)":"var(--rd)"; html+=`
${acc.name}${acc.type?`${acc.type}`:""}
${fmt(acc.totalRub)}
${pSign}${fmt(Math.abs(acc.pnlRub))} · ${pSign}${Math.abs(acc.pnlPct)}%
`; acc.classBreakdown.forEach(c=>{ const col=CLASS_COLORS[c.class]||"#888780"; html+=`
${c.class}
${c.pct}%
`; }); html+=`
`; }); html+=`
`; } } // ── Крипта ── if(invTab==="all"||invTab==="crypto"){ const cryptoUsd=Math.round(d.crypto.totalRub/d.usdRub); html+=`
Крипта≈ ${cryptoUsd.toLocaleString("ru")} $
`; if(!d.crypto.positions.length){ html+=`
Монет нет — добавь первую
`; } else { d.crypto.positions.forEach(p=>{ const pSign=p.pnlRub>=0?"+":"−"; const pCol=p.pnlRub>=0?"var(--gn)":"var(--rd)"; const priceStr=p.priceUsd?`$${p.priceUsd.toLocaleString("ru")}`:"загрузка..."; const avgStr=p.avgPriceUsd?` · ср. $${p.avgPriceUsd.toLocaleString("ru")}`:""; html+=`
${p.ticker.slice(0,4)}
${p.name}
${p.qty} ${p.ticker}${avgStr}
${priceStr}
${p.currentRub>0?fmt(p.currentRub):"—"}
≈ ${p.currentUsd>0?p.currentUsd.toLocaleString("ru"):"—"} $
${p.currentRub>0?`
${pSign}${fmt(Math.abs(p.pnlRub))} · ${pSign}${Math.abs(p.pnlPct)}%
`:`
цена не загружена
`}
`; }); } html+=`
+
Добавить монету
`; } // ── Кэш ── if(invTab==="all"||invTab==="cash"){ const CASH_C={USDT:"#26A17B",USD:"#378ADD",EUR:"#534AB7",RUB:"#888780"}; const CASH_N={USDT:"Tether USDT",USD:"Доллары",EUR:"Евро",RUB:"Рубли"}; const cashUsd=Math.round(d.cash.totalRub/d.usdRub); html+=`
Кэш≈ ${cashUsd.toLocaleString("ru")} $
`; if(!d.cash.positions.length){ html+=`
Кэш не добавлен
`; } else { d.cash.positions.forEach(p=>{ html+=`
${p.currency}
${CASH_N[p.currency]||p.currency}
${p.amount.toLocaleString("ru")} ${p.currency}
${fmt(p.rub)}
${p.currency!=="RUB"?`
≈ ${p.usd.toLocaleString("ru")} $
`:""}
`; }); } html+=`
+
Изменить остатки
`; } // ── Подвал ── const upd=new Date(d.updatedAt); const updStr=`${upd.getHours()}:${String(upd.getMinutes()).padStart(2,"0")}`; const syncNote=d.newDivCouponCount>0?` · записано ${d.newDivCouponCount} дивид./купон.`:""; html+=`
Обновлено ${updStr} · курс ЦБ ${d.usdRub} ₽/$${syncNote}
`; document.getElementById("inv-content").innerHTML=html; // Клики document.querySelectorAll("[data-acc-id]").forEach(el=>{ el.addEventListener("click",()=>{ const acc=d.broker.accounts.find(a=>a.id===el.dataset.accId); if(acc)openBrokerDetail(acc); }); }); document.querySelectorAll("[data-crypto-id]").forEach(el=>{ el.addEventListener("click",()=>{ const pos=d.crypto.positions.find(p=>p.id===el.dataset.cryptoId); if(pos)openCryptoSheet(pos); }); }); document.querySelectorAll("[data-cash-id]").forEach(el=>{ el.addEventListener("click",()=>{ const pos=d.cash.positions.find(p=>p.id===el.dataset.cashId); if(pos)openCashSheet(pos); }); }); document.getElementById("inv-add-crypto")?.addEventListener("click",()=>openCryptoSheet(null)); document.getElementById("inv-add-cash")?.addEventListener("click",()=>openCashSheet(null)); } // Таб переключение document.getElementById("page-investments").addEventListener("click",e=>{ const tab=e.target.closest(".inv-tab"); if(!tab)return; document.querySelectorAll(".inv-tab").forEach(t=>t.classList.remove("active")); tab.classList.add("active"); invTab=tab.dataset.tab; if(state.invData)renderInvestments(state.invData); }); document.getElementById("inv-refresh-btn").addEventListener("click",()=>{ state.invData=null; loadInvestments(true); }); // ── Broker detail overlay ────────────────────────────────────────────── const PIE_COLORS=["#378ADD","#EF9F27","#1D9E75","#D4537E","#7F77DD","#D85A30"]; function renderBrokerDetailBody(acc){ // Donut SVG const total=acc.classBreakdown.reduce((s,c)=>s+c.rub,0); const r=36,cx=50,cy=50,circ=2*Math.PI*r; let offset=0,arcs=""; acc.classBreakdown.forEach((c,i)=>{ const pct=total>0?c.rub/total:0; const dash=pct*circ,gap=circ-dash; const col=CLASS_COLORS[c.class]||PIE_COLORS[i%PIE_COLORS.length]; arcs+=``; offset+=dash; }); const donutSvg=`${arcs}всего${fmtK(acc.totalRub)}`; const legend=acc.classBreakdown.map((c,i)=>{ const col=CLASS_COLORS[c.class]||PIE_COLORS[i%PIE_COLORS.length]; return `
${c.class}
${c.pct}%
${fmt(c.rub)}
`; }).join(""); const pSign=acc.pnlRub>=0?"+":"−"; const pCol=acc.pnlRub>=0?"var(--gn)":"var(--rd)"; const cards=`
Вложено
${fmt(acc.netInvested||0)}
P&L всего
${pSign}${fmt(Math.abs(acc.pnlRub))}
${pSign}${Math.abs(acc.pnlPct)}% за всё время
Дивиденды
+${fmt(acc.dividends||0)}
Купоны
+${fmt(acc.coupons||0)}
Комиссии
−${fmt(acc.commissions||0)}
`; const BADGE={"Акции":"stock","Облигации":"bond","Фонды":"fund"}; const posHtml=(acc.positions||[]).map(p=>`
${(p.ticker||p.name.slice(0,4)).slice(0,4)}
${p.name}
${p.qty} шт · ${fmt(p.currentPrice)}
${p.type}
${fmt(p.currentRub)}
${p.pnlRub>=0?"+":"−"}${fmt(Math.abs(p.pnlRub))} · ${p.pnlRub>=0?"+":""}${p.pnlPct}%
`).join(""); return `
Структура
${donutSvg}
${legend}
${cards}
Позиции${(acc.positions||[]).length} инструментов
${posHtml||'
Нет позиций
'}
Tinkoff Invest API · с учётом операций с 2022
`; } async function openBrokerDetail(acc){ // Заголовок и спиннер сразу document.getElementById("broker-overlay-title").textContent=acc.name+(acc.type?` · ${acc.type}`:""); document.getElementById("broker-overlay-pnl").innerHTML=""; document.getElementById("broker-overlay-body").innerHTML=sp(); document.getElementById("broker-overlay").classList.add("open"); try { // Загружаем полные данные с операциями const d = await api("/api/account-detail", {id: acc.id}); // Мерджим быстрые данные из списка с полными из detail const full = {...acc, ...d}; document.getElementById("broker-overlay-pnl").innerHTML=fmtPnl(full.pnlRub, full.pnlPct); document.getElementById("broker-overlay-body").innerHTML=renderBrokerDetailBody(full); } catch(e) { document.getElementById("broker-overlay-body").innerHTML=`
Не удалось загрузить данные
`; } } document.getElementById("broker-overlay-back").addEventListener("click",()=>{ document.getElementById("broker-overlay").classList.remove("open"); }); // ── Crypto sheet ─────────────────────────────────────────────────────── const CRYPTO_COLORS={BTC:"#F7931A",ETH:"#627EEA",SOL:"#9945FF",USDT:"#26A17B",BNB:"#F3BA2F",TON:"#0088CC",AVAX:"#E84142",ADA:"#0033AD",DOT:"#E6007A",MATIC:"#8247E5"}; function openCryptoSheet(pos){ document.getElementById("crypto-sheet-ttl").textContent=pos?"Редактировать":"Добавить монету"; document.getElementById("crypto-edit-id").value=pos?.id||""; document.getElementById("crypto-ticker").value=pos?.ticker||""; document.getElementById("crypto-name").value=pos?.name||""; document.getElementById("crypto-qty").value=pos?.qty||""; // avgPriceUsd — новый формат; avgPrice — старый (в ₽), показываем как есть для совместимости document.getElementById("crypto-avg").value=pos?.avgPriceUsd||pos?.avgPrice||""; document.getElementById("crypto-sheet-del").classList.toggle("hidden",!pos); document.getElementById("crypto-sheet").classList.remove("hidden"); } function closeCryptoSheet(){document.getElementById("crypto-sheet").classList.add("hidden");} document.getElementById("crypto-sheet-cancel").addEventListener("click",closeCryptoSheet); document.getElementById("crypto-sheet-del").addEventListener("click",async()=>{ const id=document.getElementById("crypto-edit-id").value; if(!id||!confirm("Удалить позицию?"))return; await api("/api/investments/crypto",{},"POST",{action:"delete",id}); closeCryptoSheet();state.invData=null;loadInvestments(true); }); document.getElementById("crypto-sheet-save").addEventListener("click",async()=>{ const ticker=document.getElementById("crypto-ticker").value.trim().toUpperCase(); const name=document.getElementById("crypto-name").value.trim()||ticker; const qty=parseFloat(document.getElementById("crypto-qty").value); const avgPriceUsd=parseFloat(document.getElementById("crypto-avg").value); if(!ticker||!qty||!avgPriceUsd){alert("Заполни все поля");return;} const id=document.getElementById("crypto-edit-id").value||undefined; const color=CRYPTO_COLORS[ticker]||"#888780"; await api("/api/investments/crypto",{},"POST",{id,ticker,name,qty,avgPriceUsd,color}); closeCryptoSheet();state.invData=null;loadInvestments(true); }); // ── Cash sheet ───────────────────────────────────────────────────────── function openCashSheet(pos){ document.getElementById("cash-edit-id").value=pos?.id||""; document.getElementById("cash-currency").value=pos?.currency||"USDT"; document.getElementById("cash-amount").value=pos?.amount||""; document.getElementById("cash-sheet-del").classList.toggle("hidden",!pos); document.getElementById("cash-sheet").classList.remove("hidden"); } function closeCashSheet(){document.getElementById("cash-sheet").classList.add("hidden");} document.getElementById("cash-sheet-cancel").addEventListener("click",closeCashSheet); document.getElementById("cash-sheet-del").addEventListener("click",async()=>{ const id=document.getElementById("cash-edit-id").value; if(!id||!confirm("Удалить?"))return; await api("/api/investments/cash",{},"POST",{action:"delete",id}); closeCashSheet();state.invData=null;loadInvestments(true); }); document.getElementById("cash-sheet-save").addEventListener("click",async()=>{ const currency=document.getElementById("cash-currency").value; const amount=parseFloat(document.getElementById("cash-amount").value); if(!amount){alert("Введи сумму");return;} const id=document.getElementById("cash-edit-id").value||undefined; await api("/api/investments/cash",{},"POST",{id,currency,amount}); closeCashSheet();state.invData=null;loadInvestments(true); }); // ── Debts ─────────────────────────────────────────────────────────────── function fmtNum(n){return new Intl.NumberFormat("ru-RU").format(Math.round(Math.abs(n||0)));} async function loadDebts(force){ if(!force&&state.debtData){renderDebts(state.debtData);return;} document.getElementById("debt-content").innerHTML='
Загружаю...
'; try{ const d=await api("/api/debts"); state.debtData=d; renderDebts(d); }catch(e){ document.getElementById("debt-content").innerHTML='
Не удалось загрузить
'; } } function renderDebts(d){ const items=d.items||[]; const s=d.summary||{}; const loans=items.filter(x=>x.type==="loan"); const cards=items.filter(x=>x.type==="card"); let html=""; html+=`
Общий долг
${fmtNum(s.totalDebt)} ₽
Кредитки долг / лимит
${fmtNum(s.totalCardDebt)} / ${fmtNum(s.totalCardLimit)} ₽
`; if(!items.length){ html+='
Долгов нет 🎉
Нажми «+ добавить» чтобы внести кредит или карту
'; document.getElementById("debt-content").innerHTML=html; return; } if(loans.length){ html+='
Кредиты и рассрочки
'; for(const item of loans){ const total=(Number(item.amount)||0)+(Number(item.paid)||0); const paidAmt=Number(item.paid)||0; const pct=total>0?Math.min(100,Math.round(paidAmt/total*100)):0; const barStyle=pct===0?"background:#888780":""; const meta=[]; if((item.rate||0)>0)meta.push(item.rate+"%");else meta.push("0% / рассрочка"); if(item.dueDate)meta.push("до "+item.dueDate); const enc=encodeURIComponent(JSON.stringify(item)); html+=`
${item.name}${item.bank?" · "+item.bank:""} ${fmtNum(item.amount)} ₽
${meta.join(" · ")}
${total>0?`
выплачено ${fmtNum(paidAmt)} ₽${pct}%
`:""}
`; } html+="
"; } if(cards.length){ html+='
Кредитные карты
'; for(const item of cards){ const limit=Number(item.limit)||0; const debt=Number(item.debt)||0; const free=limit-debt; const pct=limit>0?Math.min(100,Math.round(debt/limit*100)):0; const isZero=debt===0; const meta=[]; if((item.rate||0)>0)meta.push(item.rate+"%"); if((item.graceDays||0)>0)meta.push("грейс "+item.graceDays+" дн."); meta.push("лимит "+fmtNum(limit)+" ₽"); const enc=encodeURIComponent(JSON.stringify(item)); html+=`
${item.bank} ${isZero?"0":fmtNum(debt)} ₽
${meta.join(" · ")}
=100?" full":""}" style="width:${pct}%">
${isZero?"✓ свободно":"долг "+fmtNum(debt)+" ₽"} ${isZero?"свободно "+fmtNum(free)+" ₽":pct+"% лимита"}
`; } html+="
"; } html+='
'; document.getElementById("debt-content").innerHTML=html; } function openDebtSheetFromData(enc){ try{openDebtSheet(JSON.parse(decodeURIComponent(enc)));}catch(e){} } let _debtDeleteId=null; function debtSwitchTab(type){ document.getElementById("debt-edit-type").value=type; document.getElementById("debt-fields-loan").style.display=type==="loan"?"block":"none"; document.getElementById("debt-fields-card").style.display=type==="card"?"block":"none"; document.getElementById("debt-tab-loan").classList.toggle("active",type==="loan"); document.getElementById("debt-tab-card").classList.toggle("active",type==="card"); } function openDebtSheet(item){ const isNew=!item||!item.id; document.getElementById("debt-sheet-ttl").textContent=isNew?"Новый долг":(item.type==="loan"?item.name:item.bank); document.getElementById("debt-edit-id").value=item?.id||""; document.getElementById("debt-sheet-del").classList.toggle("hidden",isNew); const type=item?.type||"loan"; debtSwitchTab(type); if(type==="loan"){ document.getElementById("debt-name").value=item?.name||""; document.getElementById("debt-bank").value=item?.bank||""; document.getElementById("debt-amount").value=item?.amount||""; document.getElementById("debt-rate").value=item?.rate||""; document.getElementById("debt-paid").value=item?.paid||""; document.getElementById("debt-duedate").value=item?.dueDate||""; }else{ document.getElementById("debt-card-bank").value=item?.bank||""; document.getElementById("debt-card-limit").value=item?.limit||""; document.getElementById("debt-card-debt").value=item?.debt||""; document.getElementById("debt-card-rate").value=item?.rate||""; document.getElementById("debt-card-grace").value=item?.graceDays||""; } document.getElementById("debt-sheet").classList.remove("hidden"); } function closeDebtSheet(){document.getElementById("debt-sheet").classList.add("hidden");} document.getElementById("debt-add-btn").addEventListener("click",()=>openDebtSheet(null)); document.getElementById("debt-sheet-cancel").addEventListener("click",closeDebtSheet); document.getElementById("debt-sheet-del").addEventListener("click",()=>{ const id=document.getElementById("debt-edit-id").value; if(!id)return; _debtDeleteId=id; closeDebtSheet(); document.getElementById("debt-confirm-sheet").classList.remove("hidden"); }); document.getElementById("debt-confirm-no").addEventListener("click",()=>{ document.getElementById("debt-confirm-sheet").classList.add("hidden"); _debtDeleteId=null; }); document.getElementById("debt-confirm-yes").addEventListener("click",async()=>{ if(!_debtDeleteId)return; document.getElementById("debt-confirm-sheet").classList.add("hidden"); try{ await api("/api/debts",{},"POST",{action:"delete",id:_debtDeleteId}); _debtDeleteId=null; state.debtData=null; loadDebts(true); }catch(e){alert("Ошибка удаления");} }); document.getElementById("debt-sheet-save").addEventListener("click",async()=>{ const type=document.getElementById("debt-edit-type").value; const id=document.getElementById("debt-edit-id").value||undefined; let body; if(type==="loan"){ const name=document.getElementById("debt-name").value.trim(); const bank=document.getElementById("debt-bank").value.trim(); const amount=parseFloat(document.getElementById("debt-amount").value); const rate=parseFloat(document.getElementById("debt-rate").value)||0; const paid=parseFloat(document.getElementById("debt-paid").value)||0; const dueDate=document.getElementById("debt-duedate").value.trim(); if(!name||!amount){alert("Заполни название и сумму долга");return;} body={id,type:"loan",name,bank,amount,rate,paid,dueDate}; }else{ const bank=document.getElementById("debt-card-bank").value.trim(); const limit=parseFloat(document.getElementById("debt-card-limit").value); const debt=parseFloat(document.getElementById("debt-card-debt").value)||0; const rate=parseFloat(document.getElementById("debt-card-rate").value)||0; const graceDays=parseInt(document.getElementById("debt-card-grace").value)||0; if(!bank||!limit){alert("Заполни банк и лимит");return;} body={id,type:"card",bank,limit,debt,rate,graceDays}; } try{ await api("/api/debts",{},"POST",body); closeDebtSheet(); state.debtData=null; loadDebts(true); }catch(e){alert("Ошибка сохранения");} }); loadDashboard(); // ── LIMITS ───────────────────────────────────────────────────────────── const limState={ year:now.getFullYear(), tab:"expenses", // "expenses"|"income"|"savings" view:"month", // "month"|"year" month:now.getMonth(), // 0-based data:null, // raw response from /api/limits editing:null, // {cat, month (2-digit string)} }; function openLimits(){ document.getElementById("limits-overlay").classList.add("open"); if(!limState.data) loadLimits(); else renderLimits(); } function closeLimits(){ document.getElementById("limits-overlay").classList.remove("open"); } document.getElementById("limits-back").addEventListener("click",closeLimits); async function loadLimits(){ document.getElementById("limits-body").innerHTML=sp(); try{ const d=await api("/api/limits"); limState.data=d; renderLimits(); }catch(e){ document.getElementById("limits-body").innerHTML='
Не удалось загрузить лимиты
'; } } // Year controls document.getElementById("lim-yr-prev").addEventListener("click",()=>{ limState.year--;document.getElementById("lim-yr-val").textContent=limState.year;renderLimits(); }); document.getElementById("lim-yr-next").addEventListener("click",()=>{ limState.year++;document.getElementById("lim-yr-val").textContent=limState.year;renderLimits(); }); // Tab controls document.getElementById("limits-overlay").addEventListener("click",e=>{ const tab=e.target.closest(".lim-tab"); if(tab){ limState.tab=tab.dataset.ltab; document.querySelectorAll(".lim-tab").forEach(t=>{ const active=t.dataset.ltab===limState.tab; t.style.borderBottom=active?"2px solid var(--tx)":"2px solid transparent"; t.style.fontWeight=active?"600":"400"; t.style.color=active?"var(--tx)":"var(--tx2)"; }); renderLimits(); return; } const view=e.target.closest(".lim-view"); if(view){ limState.view=view.dataset.lview; document.querySelectorAll(".lim-view").forEach(v=>{ const active=v.dataset.lview===limState.view; v.style.background=active?"var(--tx)":"none"; v.style.color=active?"var(--bg)":"var(--tx2)"; }); renderLimits(); return; } // Month pill click const pill=e.target.closest(".lim-mpill"); if(pill){ limState.month=parseInt(pill.dataset.mi); renderLimits(); return; } // Category row click (month view) const row=e.target.closest(".lim-crow"); if(row){ openLimSheet(row.dataset.cat, row.dataset.month); } }); function getLimValue(cat, monthStr){ const d=limState.data; if(!d) return 0; const yr=String(limState.year); return d.limits?.[yr]?.[limState.tab]?.[cat]?.[monthStr] || 0; } function fmtLim(n){return n>0?new Intl.NumberFormat("ru-RU").format(Math.round(n))+" ₽":"—";} function renderLimits(){ if(!limState.data){loadLimits();return;} const d=limState.data; const yr=String(limState.year); const cats=d.categories?.[limState.tab]||[]; const limData=d.limits?.[yr]?.[limState.tab]||{}; const curMonth=now.getMonth(); // 0-based if(limState.view==="month"){ renderLimitsMonth(cats,limData,yr,curMonth); }else{ renderLimitsYear(cats,limData,yr); } } function renderLimitsMonth(cats,limData,yr,curMonth){ const mi=limState.month; // 0-based const monthStr=String(mi+1).padStart(2,"0"); // Month strip let strip=`
`; MNR.forEach((m,i)=>{ const isPast=parseInt(yr)===now.getFullYear()&&i${MN[i]}
`; }); strip+=`
`; // Total const total=cats.reduce((s,c)=>s+(limData[c]?.[monthStr]||0),0); let html=strip; html+=`
План на ${MNR[mi]} ${yr} ${total>0?new Intl.NumberFormat("ru-RU").format(total)+" ₽":"—"}
`; if(!cats.length){ html+=`
Нет категорий
`; }else{ cats.forEach(cat=>{ const val=limData[cat]?.[monthStr]||0; html+=`
${cat} ${fmtLim(val)}
`; }); } // Year total const yearTotal=cats.reduce((s,c)=>{ const months=["01","02","03","04","05","06","07","08","09","10","11","12"]; return s+months.reduce((ms,m)=>ms+(limData[c]?.[m]||0),0); },0); html+=`
Итого план на ${yr} ${yearTotal>0?new Intl.NumberFormat("ru-RU").format(yearTotal)+" ₽":"—"}
`; document.getElementById("limits-body").innerHTML=html; } function renderLimitsYear(cats,limData,yr){ const months=["01","02","03","04","05","06","07","08","09","10","11","12"]; if(!cats.length){ document.getElementById("limits-body").innerHTML='
Нет категорий
'; return; } let html=`
`; MN.forEach(m=>{html+=``;}); html+=``; const colTotals=months.map(()=>0); let grandTotal=0; cats.forEach(cat=>{ const vals=months.map(m=>limData[cat]?.[m]||0); const rowTotal=vals.reduce((a,b)=>a+b,0); vals.forEach((v,i)=>{colTotals[i]+=v;}); grandTotal+=rowTotal; const curMi=now.getMonth(); html+=``; vals.forEach((v,i)=>{ const isCur=parseInt(yr)===now.getFullYear()&&i===now.getMonth(); html+=``; }); html+=``; html+=``; }); // Итого строка html+=``; colTotals.forEach(v=>{html+=``;}); html+=``; html+=`
Категория${m}Год
${cat}${v>0?new Intl.NumberFormat("ru-RU").format(v):"—"}${rowTotal>0?new Intl.NumberFormat("ru-RU").format(rowTotal):"—"}
Итого${v>0?new Intl.NumberFormat("ru-RU").format(v):"—"}${grandTotal>0?new Intl.NumberFormat("ru-RU").format(grandTotal):"—"}
`; document.getElementById("limits-body").innerHTML=html; } // ── Sheet ────────────────────────────────────────────────────────────── let _limEditCtx=null; function openLimSheet(cat, monthStr){ _limEditCtx={cat,month:monthStr}; const mi=parseInt(monthStr,10)-1; const yr=String(limState.year); const currentVal=(limState.data?.limits?.[yr]?.[limState.tab]?.[cat]?.[monthStr])||0; const tabLabel={expenses:"Расходы",income:"Доходы",savings:"Накопления"}[limState.tab]; document.getElementById("lim-sheet-ttl").textContent=cat; document.getElementById("lim-sheet-sub").textContent=`${tabLabel} · ${MNR[mi]} ${yr}`; document.getElementById("lim-sheet-inp").value=currentVal||""; // Годовой итог по категории const months=["01","02","03","04","05","06","07","08","09","10","11","12"]; const limData=limState.data?.limits?.[yr]?.[limState.tab]||{}; const yearSum=months.reduce((s,m)=>s+(limData[cat]?.[m]||0),0); document.getElementById("lim-sheet-meta").textContent= yearSum>0?`Годовой план: ${new Intl.NumberFormat("ru-RU").format(yearSum)} ₽`:"Годовой план не задан"; document.getElementById("lim-sheet").classList.remove("hidden"); setTimeout(()=>document.getElementById("lim-sheet-inp").focus(),100); } function closeLimSheet(){ document.getElementById("lim-sheet").classList.add("hidden"); _limEditCtx=null; } async function saveLim(applyTo){ if(!_limEditCtx)return; const val=parseFloat(document.getElementById("lim-sheet-inp").value)||0; const {cat,month}=_limEditCtx; const yr=String(limState.year); const btn=document.getElementById( applyTo==="single"?"lim-sheet-save":applyTo==="rest"?"lim-sheet-rest":"lim-sheet-all" ); const orig=btn.textContent; btn.textContent="Сохраняю...";btn.disabled=true; try{ await api("/api/limits",{},"POST",{ year:yr, tab:limState.tab, category:cat, month, amount:val, applyTo }); // Обновляем локальный кэш if(!limState.data.limits)limState.data.limits={}; if(!limState.data.limits[yr])limState.data.limits[yr]={expenses:{},income:{},savings:{}}; if(!limState.data.limits[yr][limState.tab])limState.data.limits[yr][limState.tab]={}; if(!limState.data.limits[yr][limState.tab][cat]){ const ms=["01","02","03","04","05","06","07","08","09","10","11","12"]; limState.data.limits[yr][limState.tab][cat]=Object.fromEntries(ms.map(m=>[m,0])); } const ms=["01","02","03","04","05","06","07","08","09","10","11","12"]; const idx=ms.indexOf(month); if(applyTo==="all") ms.forEach(m=>{limState.data.limits[yr][limState.tab][cat][m]=val;}); else if(applyTo==="rest") ms.slice(idx).forEach(m=>{limState.data.limits[yr][limState.tab][cat][m]=val;}); else limState.data.limits[yr][limState.tab][cat][month]=val; closeLimSheet(); renderLimits(); // Сбросить кэш дашборда чтобы лимиты обновились state.dashData=null; }catch(err){ alert("Ошибка сохранения: "+err.message); }finally{ btn.textContent=orig;btn.disabled=false; } } document.getElementById("lim-sheet-save").addEventListener("click",()=>saveLim("single")); document.getElementById("lim-sheet-rest").addEventListener("click",()=>saveLim("rest")); document.getElementById("lim-sheet-all").addEventListener("click",()=>saveLim("all")); document.getElementById("lim-sheet-cancel").addEventListener("click",closeLimSheet);