Все
Расходы
Доходы
Накопления
Инвестиции
↻ Обновить
Всё
Фондовый
Крипта
Кэш
Дашборд
История
Цели
Редактировать
Отмена
Удалить
Сохранить
Добавить монету
Отмена
Удалить
Сохранить
Кэш и валюта
Валюта
USDT (Tether)
USD (Доллары)
EUR (Евро)
RUB (Рубли)
Отмена
Удалить
Сохранить
Новый долг
Кредит / рассрочка
Кредитная карта
Отмена
Удалить
Сохранить
Удалить долг?
Запись будет удалена. Это действие нельзя отменить.
Удалить
Отмена
Расходы
Доходы
Накопления
По месяцу
Год целиком
Лимит
Сохранить этот месяц
Применить на оставшиеся месяцы
Применить на весь год
Отмена
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=>`
`).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+=`
`;
});
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 `
${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+=`