buhanka-finance-app 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.html +124 -43
- package/package.json +1 -1
package/index.html
CHANGED
|
@@ -115,6 +115,11 @@ table.wide input.p:focus{border-color:var(--accent);background:var(--panel2)}
|
|
|
115
115
|
.filters .f{display:flex;flex-direction:column;gap:3px}
|
|
116
116
|
.filters .f span{font-size:10.5px;color:var(--muted2);text-transform:uppercase;letter-spacing:.05em}
|
|
117
117
|
.filters select,.filters input{padding:6px 9px;font-size:12.5px}
|
|
118
|
+
.modal .row{display:flex;gap:12px;align-items:flex-end}
|
|
119
|
+
.modal .row>.fld{flex:1 1 0;min-width:0;margin:0}
|
|
120
|
+
.modal .fld{display:block;margin-bottom:12px}
|
|
121
|
+
.modal .fld input,.modal .fld select{width:100%;box-sizing:border-box;height:38px}
|
|
122
|
+
.modal .fld textarea{width:100%;box-sizing:border-box}
|
|
118
123
|
.pager{display:flex;gap:6px;align-items:center;justify-content:flex-end;padding:10px 14px;color:var(--muted);font-size:12.5px}
|
|
119
124
|
.pager button{padding:4px 10px;font-size:12px}
|
|
120
125
|
/* Итоги во всех таблицах — заметнее */
|
|
@@ -223,8 +228,8 @@ tr.subtotal>td:first-child{color:var(--accent);box-shadow:inset 3px 0 0 var(--ac
|
|
|
223
228
|
|
|
224
229
|
<script>
|
|
225
230
|
/* ============================================================ БУХАНКА · Финансы v2 ============================================================ */
|
|
226
|
-
const APP_VERSION='3.
|
|
227
|
-
const SCHEMA=
|
|
231
|
+
const APP_VERSION='3.5';
|
|
232
|
+
const SCHEMA=3; /* версия структуры данных; растёт при доработках */
|
|
228
233
|
const STORE_KEY='buhanka_finance_v9';/* НЕ МЕНЯТЬ НИКОГДА — иначе данные «слетят» при обновлении файла */
|
|
229
234
|
const LEGACY_KEYS=['buhanka_finance_v8','buhanka_finance_v7','buhanka_finance_v6','buhanka_finance_v5'];
|
|
230
235
|
const AUTODL_KEY='buhanka_last_download';
|
|
@@ -233,6 +238,7 @@ const BACKUP_MAX=15; /* сколько автоснимков хра
|
|
|
233
238
|
const BACKUP_EVERY_MS=5*60*1000; /* не чаще одного автоснимка в 5 минут */
|
|
234
239
|
const MONTHS=["Январь","Февраль","Март","Апрель","Май","Июнь","Июль","Август","Сентябрь","Октябрь","Ноябрь","Декабрь"];
|
|
235
240
|
const MSHORT=["Янв","Фев","Мар","Апр","Май","Июн","Июл","Авг","Сен","Окт","Ноя","Дек"];
|
|
241
|
+
const MGEN=["января","февраля","марта","апреля","мая","июня","июля","августа","сентября","октября","ноября","декабря"];
|
|
236
242
|
const QUARTERS={"I квартал":[0,1,2],"II квартал":[3,4,5],"III квартал":[6,7,8],"IV квартал":[9,10,11]};
|
|
237
243
|
const YEAR=2026;
|
|
238
244
|
const uid=()=>Math.random().toString(36).slice(2,9);
|
|
@@ -258,7 +264,7 @@ function buildStructure(){
|
|
|
258
264
|
S("Налоги","value",-1,'tax','Налоги'),S("Процентные расходы","value",-1,'manual','Процентные расходы'),
|
|
259
265
|
S("Валовая прибыль","subtotal"),S("Косвенные расходы","header"),
|
|
260
266
|
S("Бухгалтерия","value",-1,'manual','Бухгалтерия'),S("Страховые взносы","value",-1,'manual','Страховые взносы'),
|
|
261
|
-
S("Прочее
|
|
267
|
+
S("Прочее","value",-1,'manual','Прочее'),S("Управляющий","value",-1,'manual','Управляющий'),
|
|
262
268
|
S("Чистая прибыль","subtotal",1,'manual',null,{grand:true}),
|
|
263
269
|
];
|
|
264
270
|
}
|
|
@@ -373,6 +379,11 @@ function schemaGuard(){
|
|
|
373
379
|
const cur=Number(ST._schema||0);
|
|
374
380
|
if(cur>=SCHEMA) return;
|
|
375
381
|
if((ST.operations||[]).length || bkCells(ST)) bkCreate('Перед обновлением файла до v'+APP_VERSION,true,true);
|
|
382
|
+
if(cur<3){ /* v3.5: «Прочее косв» сливается с «Прочее» */
|
|
383
|
+
[ (ST.structures||{}).general, (ST.structures||{}).point ].forEach(st=>(st||[]).forEach(r=>{
|
|
384
|
+
if(r&&r.cat==='Прочее косв'){ r.cat='Прочее'; if(/косвен/i.test(r.name||'')) r.name='Прочее'; } }));
|
|
385
|
+
(ST.operations||[]).forEach(o=>{ if(o.category==='Прочее косв') o.category='Прочее'; });
|
|
386
|
+
}
|
|
376
387
|
if(cur<2){ /* v3.4: «Эквайринг» считается из операций, выручка остаётся ручной (из 1С) */
|
|
377
388
|
[ (ST.structures||{}).general, (ST.structures||{}).point ].forEach(st=>(st||[]).forEach(r=>{
|
|
378
389
|
if(r&&r.role==='value'&&r.cat==='Эквайринг'&&(r.source==='manual'||!r.source)) r.source='ops'; }));
|
|
@@ -427,6 +438,7 @@ function migrate(){ ST.locks=ST.locks||{}; ST.cfData=ST.cfData||{}; ST.ui=ST.ui|
|
|
|
427
438
|
if(!ST.todoRules)ST.todoRules=buildTodoRules(); if(!ST.todoDone)ST.todoDone={}; if(!ST.todoOnce)ST.todoOnce={};
|
|
428
439
|
if(!ST.plan)ST.plan={}; if(!ST.cashFact)ST.cashFact={};
|
|
429
440
|
if(!ST.merchants)ST.merchants={}; if(!ST.accMap)ST.accMap={}; if(!ST.impSeen)ST.impSeen={};
|
|
441
|
+
if(!ST.cashOpen)ST.cashOpen={};
|
|
430
442
|
if(!ST.impRules||!ST.impRules.length)ST.impRules=defaultImpRules();
|
|
431
443
|
if(ST.startMonthIdx==null)ST.startMonthIdx=0;
|
|
432
444
|
if(!ST.ui.opsF)ST.ui.opsF={type:'',point:'',cat:'',acc:'',q:'',size:50,page:1};
|
|
@@ -436,7 +448,9 @@ function migrate(){ ST.locks=ST.locks||{}; ST.cfData=ST.cfData||{}; ST.ui=ST.ui|
|
|
|
436
448
|
(ST.shareholders||[]).forEach(sh=>{ delete sh.share; delete sh.fromIdx; delete sh.toIdx; });
|
|
437
449
|
(ST.keyRates||[]).forEach(k=>{ if(!k.date)k.date=`${YEAR}-${String((k.fromIdx||0)+1).padStart(2,'0')}-01`; });
|
|
438
450
|
(ST.shareholders||[]).forEach(s=>{ if(s.fromIdx==null)s.fromIdx=0; }); }
|
|
439
|
-
function accType(a){ return a.type||(/налич|касс/i.test(a.name)?'cash':'bank'); }
|
|
451
|
+
function accType(a){ return a.type||(/налич|касс/i.test(a.name)?'cash':/карт/i.test(a.name)?'card':'bank'); }
|
|
452
|
+
const ACC_TYPE_NAME={bank:'банк',cash:'наличные',card:'карта'};
|
|
453
|
+
function isCashLike(a){ const t=accType(a); return t==='cash'||t==='card'; }
|
|
440
454
|
|
|
441
455
|
/* ---------- period ---------- */
|
|
442
456
|
function periodMonths(period){ period=period||ST.ui.period; if(MONTHS.includes(period))return[period]; if(QUARTERS[period])return QUARTERS[period].map(i=>MONTHS[i]); return MONTHS.slice(); }
|
|
@@ -470,11 +484,11 @@ const PAGES=[
|
|
|
470
484
|
{id:'loans',grp:'Финансирование',name:'Займы и кредиты',ic:'₽'},
|
|
471
485
|
{id:'ops',grp:'Данные',name:'Операции',ic:'≡'},
|
|
472
486
|
{id:'fixed',grp:'Данные',name:'Постоянные расходы',ic:'⧉'},
|
|
473
|
-
{id:'import',grp:'Данные',name:'Импорт выписок',ic:'⤓'},
|
|
474
487
|
{id:'wcap',grp:'Данные',name:'Запасы',ic:'▧'},
|
|
475
488
|
{id:'sched',grp:'Данные',name:'Расписание и ФОТ',ic:'☷'},
|
|
476
489
|
{id:'recon',grp:'Данные',name:'Сверка PnL ↔ CF',ic:'⚖'},
|
|
477
490
|
{id:'settings',grp:'Система',name:'Конструктор',ic:'⚙'},
|
|
491
|
+
{id:'import',grp:'Система',name:'Импорт выписок',ic:'⤓'},
|
|
478
492
|
{id:'backup',grp:'Система',name:'Бэкапы',ic:'↺'},
|
|
479
493
|
];
|
|
480
494
|
let CUR='dash';
|
|
@@ -550,8 +564,12 @@ function opChannel(o){
|
|
|
550
564
|
const a=(ST.accounts||[]).find(x=>x.name===o.account);
|
|
551
565
|
return (a&&accType(a)==='cash')?'cash':'bank';
|
|
552
566
|
}
|
|
567
|
+
const FIN_BASE=['Получение займа','Возврат займа','Дивиденды','Прочее (фин)'];
|
|
568
|
+
const INV_BASE=['Покупка оборудования','Ремонт','Обеспечительные платежи','Прочее (инвест)'];
|
|
553
569
|
/* ── группировка статей по разделам CF ── */
|
|
554
570
|
function cfGroup(cat){
|
|
571
|
+
if(FIN_BASE.includes(cat)) return 'fin';
|
|
572
|
+
if(INV_BASE.includes(cat)) return 'inv';
|
|
555
573
|
const c=String(cat||'').toLowerCase();
|
|
556
574
|
if(/займ|кредит|дивиденд|взнос учред/.test(c)) return 'fin';
|
|
557
575
|
if(/оборудован|ремонт|обеспечительн|капвлож/.test(c)) return 'inv';
|
|
@@ -560,9 +578,11 @@ function cfGroup(cat){
|
|
|
560
578
|
function cfCats(){
|
|
561
579
|
const out={op:[],fin:[],inv:[]}, seen=new Set();
|
|
562
580
|
const push=c=>{ if(!c||seen.has(c))return; seen.add(c); out[cfGroup(c)].push(c); };
|
|
581
|
+
FIN_BASE.forEach(c=>{ if(!seen.has(c)){ seen.add(c); out.fin.push(c); } });
|
|
582
|
+
INV_BASE.forEach(c=>{ if(!seen.has(c)){ seen.add(c); out.inv.push(c); } });
|
|
563
583
|
(ST.structures.point||[]).forEach(r=>{ if(r.role==='value'&&(r.sign||-1)===-1) push(r.cat); });
|
|
564
584
|
(ST.fixed||[]).forEach(f=>push(f.category));
|
|
565
|
-
(ST.operations||[]).forEach(o=>{ if(o.category!=='Выручка') push(o.category); });
|
|
585
|
+
(ST.operations||[]).forEach(o=>{ if(o.category!=='Выручка'&&!isTransfer(o)) push(o.category); });
|
|
566
586
|
return out;
|
|
567
587
|
}
|
|
568
588
|
/* ── ручные правки прогноза: ST.plan[месяц][день][ключ] ── */
|
|
@@ -586,7 +606,7 @@ function cfDayMatrix(mi){
|
|
|
586
606
|
const mainAcc=((ST.accounts||[]).find(a=>accType(a)==='bank')||(ST.accounts||[])[0]||{}).name;
|
|
587
607
|
const bal={}; (ST.accounts||[]).forEach(a=>bal[a.name]=Number(a.opening||0));
|
|
588
608
|
(ST.operations||[]).filter(o=>isCashOp(o)&&MONTHS.indexOf(o.month)<mi&&inScope(o.month)).forEach(o=>{
|
|
589
|
-
|
|
609
|
+
Object.keys(bal).forEach(n=>{ bal[n]+=accDelta(o,n); }); });
|
|
590
610
|
const days=[];
|
|
591
611
|
for(let d=1; d<=N; d++){
|
|
592
612
|
const ds=`${YEAR}-${String(mi+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
|
|
@@ -594,6 +614,7 @@ function cfDayMatrix(mi){
|
|
|
594
614
|
const actual=d<=au;
|
|
595
615
|
const inc={bank:0,yandex:0,cash:0}, exp={}, fin={}, inv={};
|
|
596
616
|
ops.forEach(o=>{ const amt=Math.abs(o.amount), g=cfGroup(o.category);
|
|
617
|
+
if(isTransfer(o)) return; /* внутренний перевод — не приток и не отток */
|
|
597
618
|
if(g==='fin'){ fin[o.category]=(fin[o.category]||0)+(o.type==='Доход'?amt:-amt); return; }
|
|
598
619
|
if(g==='inv'){ inv[o.category]=(inv[o.category]||0)+(o.type==='Доход'?amt:-amt); return; }
|
|
599
620
|
if(o.type==='Доход'){ const ch=opChannel(o); inc[ch]=(inc[ch]||0)+amt; }
|
|
@@ -609,7 +630,7 @@ function cfDayMatrix(mi){
|
|
|
609
630
|
const finSum=cats.fin.reduce((t,c)=>t+(fin[c]||0),0);
|
|
610
631
|
const invSum=cats.inv.reduce((t,c)=>t+(inv[c]||0),0);
|
|
611
632
|
const open={}; accs.forEach(a=>open[a]=bal[a]);
|
|
612
|
-
if(actual){ ops.forEach(o=>{
|
|
633
|
+
if(actual){ ops.forEach(o=>{ Object.keys(bal).forEach(n=>{ bal[n]+=accDelta(o,n); }); }); }
|
|
613
634
|
else if(mainAcc!=null && bal[mainAcc]!=null){ bal[mainAcc]+= inSum-exSum+finSum+invSum; }
|
|
614
635
|
const close={}; accs.forEach(a=>close[a]=bal[a]);
|
|
615
636
|
const openTot=accs.reduce((t,a)=>t+open[a],0), closeTot=accs.reduce((t,a)=>t+close[a],0);
|
|
@@ -621,15 +642,22 @@ function cfDayMatrix(mi){
|
|
|
621
642
|
|
|
622
643
|
/* ── наличные по точкам (сверка кассы) ── */
|
|
623
644
|
function cashCalc(mo,point){
|
|
624
|
-
const
|
|
625
|
-
const prev=prevIdx>=
|
|
626
|
-
|
|
645
|
+
const mi=MONTHS.indexOf(mo), prevIdx=mi-1;
|
|
646
|
+
const prev=(prevIdx>=startIdx()) ? (ST.cashFact[MONTHS[prevIdx]]||{})[point] : null;
|
|
647
|
+
const openManual=(ST.cashOpen||{})[point];
|
|
648
|
+
let start = (prev!=null&&prev!=='') ? Number(prev)
|
|
649
|
+
: (openManual!=null&&openManual!=='') ? Number(openManual) : 0;
|
|
627
650
|
const cashAcc=new Set((ST.accounts||[]).filter(a=>accType(a)==='cash').map(a=>a.name));
|
|
628
651
|
let inc=0,out=0;
|
|
629
|
-
(ST.operations||[]).filter(o=>o.month===mo&&isCashOp(o)&&o.point===point
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
652
|
+
(ST.operations||[]).filter(o=>o.month===mo&&isCashOp(o)&&o.point===point).forEach(o=>{
|
|
653
|
+
if(isTransfer(o)){ if(cashAcc.has(o.account))out+=Math.abs(o.amount);
|
|
654
|
+
if(cashAcc.has(o.account2))inc+=Math.abs(o.amount); return; }
|
|
655
|
+
if(!cashAcc.has(o.account))return;
|
|
656
|
+
if(o.type==='Доход')inc+=Math.abs(o.amount); else out+=Math.abs(o.amount); });
|
|
657
|
+
return {start,inc,out,calc:start+inc-out, editable: mi===startIdx()||prev==null||prev===''};
|
|
658
|
+
}
|
|
659
|
+
function cashOpenSet(point,v){ ST.cashOpen=ST.cashOpen||{}; const n=parseNum(v);
|
|
660
|
+
if(n==null)delete ST.cashOpen[point]; else ST.cashOpen[point]=n; save(); render(); }
|
|
633
661
|
function cashFactSet(mo,point,v){ if(isLocked(mo)){toast('Месяц закрыт');render();return;}
|
|
634
662
|
ST.cashFact=ST.cashFact||{}; ST.cashFact[mo]=ST.cashFact[mo]||{};
|
|
635
663
|
const n=parseNum(v); if(n==null)delete ST.cashFact[mo][point]; else ST.cashFact[mo][point]=n; save(); render(); }
|
|
@@ -665,8 +693,22 @@ function computeCF(structKey,period){
|
|
|
665
693
|
/* ---------- accounts / cash ---------- */
|
|
666
694
|
function startIdx(){ return Math.max(0,Math.min(11,Number(ST.startMonthIdx||0))); }
|
|
667
695
|
function startMonth(){ return MONTHS[startIdx()]; }
|
|
696
|
+
function startMonthGen(){ return MGEN[startIdx()]; }
|
|
668
697
|
function inScope(mo){ return MONTHS.indexOf(mo)>=startIdx(); }
|
|
669
|
-
|
|
698
|
+
const isTransfer=o=>o.type==='Перевод';
|
|
699
|
+
function accDelta(o,name){ /* как операция двигает конкретный счёт */
|
|
700
|
+
if(!isCashOp(o)) return 0;
|
|
701
|
+
const a=Math.abs(o.amount||0);
|
|
702
|
+
if(isTransfer(o)) return (o.account===name?-a:0)+(o.account2===name?a:0);
|
|
703
|
+
if(o.account!==name) return 0;
|
|
704
|
+
return o.type==='Доход'?a:-a;
|
|
705
|
+
}
|
|
706
|
+
function accountBalance(acc,uptoIdx){
|
|
707
|
+
const lim=(uptoIdx==null)?11:uptoIdx;
|
|
708
|
+
return Number(acc.opening||0)+ST.operations.reduce((s,o)=>{
|
|
709
|
+
const mi=MONTHS.indexOf(o.month);
|
|
710
|
+
return (mi>=startIdx()&&mi<=lim)? s+accDelta(o,acc.name) : s; },0);
|
|
711
|
+
}
|
|
670
712
|
function totalOpening(){ return ST.accounts.reduce((s,a)=>s+Number(a.opening||0),0); }
|
|
671
713
|
function monthNet(mo){ const inflow=ST.operations.filter(o=>o.month===mo&&o.type==='Доход'&&isCashOp(o)).reduce((s,o)=>s+Math.abs(o.amount),0);
|
|
672
714
|
const outOps=ST.operations.filter(o=>o.month===mo&&o.type==='Расход'&&isCashOp(o)).reduce((s,o)=>s+Math.abs(o.amount),0);
|
|
@@ -1139,7 +1181,7 @@ function renderCF(C){
|
|
|
1139
1181
|
let tot=0;
|
|
1140
1182
|
ST.accounts.forEach(a=>{ const b=accountBalance(a); tot+=b;
|
|
1141
1183
|
html+=`<div class="card"><div class="k">${esc(a.name)}</div><div class="v" style="font-size:20px">${fmt(b)} ₽</div>
|
|
1142
|
-
<div class="d muted">${accType(a)
|
|
1184
|
+
<div class="d muted">${ACC_TYPE_NAME[accType(a)]||'банк'}</div></div>`; });
|
|
1143
1185
|
html+=`<div class="card" style="border-color:var(--accent)"><div class="k">Итого средств</div><div class="v">${fmt(tot)} ₽</div>
|
|
1144
1186
|
<div class="d muted">${ST.accounts.length} ${ST.accounts.length===1?'счёт':'счетов'}</div></div></div>`;
|
|
1145
1187
|
|
|
@@ -1148,7 +1190,7 @@ function renderCF(C){
|
|
|
1148
1190
|
html+=`<div class="panel"><div class="hd"><h3>Наличные по точкам · ${m}</h3><div class="spacer"></div>
|
|
1149
1191
|
${lk?'<span class="tag lock">🔒 закрыт</span>':'<span class="muted small">факт — то, что реально в кассе точки</span>'}</div>
|
|
1150
1192
|
<div class="bd" style="padding:0"><div class="tbl-wrap"><table>
|
|
1151
|
-
<thead><tr><th
|
|
1193
|
+
<thead><tr><th>Точка / счёт</th><th class="num" style="width:130px">На начало</th><th class="num">Приход нал.</th><th class="num">Расход нал.</th>
|
|
1152
1194
|
<th class="num">Расчёт</th><th class="num" style="width:140px">Факт в кассе</th><th class="num">Расхождение</th></tr></thead><tbody>`;
|
|
1153
1195
|
let tS=0,tI=0,tO=0,tC=0,tF=0,tCf=0,nF=0;
|
|
1154
1196
|
ST.points.forEach(p=>{ const c=cashCalc(m,p); const fv=(ST.cashFact[m]||{})[p];
|
|
@@ -1156,16 +1198,38 @@ function renderCF(C){
|
|
|
1156
1198
|
const diff=has?Number(fv)-c.calc:null;
|
|
1157
1199
|
tS+=c.start; tI+=c.inc; tO+=c.out; tC+=c.calc;
|
|
1158
1200
|
if(has){ tF+=Number(fv); tCf+=c.calc; nF++; }
|
|
1159
|
-
|
|
1201
|
+
const startCell = c.editable
|
|
1202
|
+
? `<input class="money" ${lk?'disabled':''} value="${(ST.cashOpen||{})[p]!=null?fmtNum(ST.cashOpen[p]):''}" placeholder="0" style="width:110px" onchange="cashOpenSet('${p}',this.value)">`
|
|
1203
|
+
: `<span class="muted">${fmt(c.start)}</span>`;
|
|
1204
|
+
html+=`<tr><td>${esc(p)}</td><td class="num">${startCell}</td><td class="num pos">${c.inc?fmt(c.inc):'—'}</td>
|
|
1160
1205
|
<td class="num neg">${c.out?fmt(c.out):'—'}</td><td class="num" style="font-weight:600">${fmt(c.calc)}</td>
|
|
1161
1206
|
<td class="num"><input class="money" ${lk?'disabled':''} value="${fv!=null&&fv!==''?fmtNum(fv):''}" placeholder="—" style="width:120px" onchange="cashFactSet('${m}','${p}',this.value)"></td>
|
|
1162
1207
|
<td class="num ${diff==null?'muted':Math.abs(diff)<1?'pos':'neg'}">${diff==null?'—':(diff>0?'+':'')+fmt(diff)}</td></tr>`; });
|
|
1208
|
+
const cashAccs=ST.accounts.filter(isCashLike);
|
|
1209
|
+
if(cashAccs.length){
|
|
1210
|
+
html+=`<tr class="header"><td colspan="7" style="color:var(--muted2);font-size:11px;text-transform:uppercase;letter-spacing:.05em;padding-top:14px">Наличные счета и карты</td></tr>`;
|
|
1211
|
+
const mi2=MONTHS.indexOf(m);
|
|
1212
|
+
cashAccs.forEach(a=>{
|
|
1213
|
+
const open=accountBalance(a,mi2-1), close=accountBalance(a,mi2);
|
|
1214
|
+
let inc=0,out=0;
|
|
1215
|
+
(ST.operations||[]).filter(o=>o.month===m&&isCashOp(o)).forEach(o=>{ const d=accDelta(o,a.name);
|
|
1216
|
+
if(d>0)inc+=d; else out-=d; });
|
|
1217
|
+
const key='acc:'+a.name; const fv=(ST.cashFact[m]||{})[key]; const has=!(fv==null||fv==='');
|
|
1218
|
+
const diff=has?Number(fv)-close:null;
|
|
1219
|
+
if(has){ tF+=Number(fv); tCf+=close; nF++; }
|
|
1220
|
+
tS+=open; tI+=inc; tO+=out; tC+=close;
|
|
1221
|
+
html+=`<tr><td class="muted">${esc(a.name)}</td><td class="num muted">${fmt(open)}</td>
|
|
1222
|
+
<td class="num pos">${inc?fmt(inc):'—'}</td><td class="num neg">${out?fmt(out):'—'}</td>
|
|
1223
|
+
<td class="num" style="font-weight:600">${fmt(close)}</td>
|
|
1224
|
+
<td class="num"><input class="money" ${lk?'disabled':''} value="${has?fmtNum(fv):''}" placeholder="—" style="width:120px" onchange="cashFactSet('${m}','${key}',this.value)"></td>
|
|
1225
|
+
<td class="num ${diff==null?'muted':Math.abs(diff)<1?'pos':'neg'}">${diff==null?'—':(diff>0?'+':'')+fmt(diff)}</td></tr>`; });
|
|
1226
|
+
}
|
|
1163
1227
|
const tD=nF?tF-tCf:null;
|
|
1164
1228
|
html+=`<tr class="subtotal"><td>Итого</td><td class="num">${fmt(tS)}</td><td class="num">${fmt(tI)}</td><td class="num">${fmt(tO)}</td>
|
|
1165
1229
|
<td class="num">${fmt(tC)}</td><td class="num">${nF?fmt(tF):'—'}</td>
|
|
1166
1230
|
<td class="num ${tD==null?'':Math.abs(tD)<1?'pos':'neg'}">${tD==null?'—':(tD>0?'+':'')+fmt(tD)}</td></tr>
|
|
1167
1231
|
</tbody></table></div></div>
|
|
1168
|
-
<p class="hint" style="padding:0 15px 12px;margin:0"
|
|
1232
|
+
<p class="hint" style="padding:0 15px 12px;margin:0">По точкам: «На начало» в первом месяце учёта вводится вручную, дальше подставляется факт предыдущего месяца. Приход и расход — наличные операции и переводы, привязанные к точке. Ниже — наличные счета и карты целиком, по данным всех операций и переводов. Расхождение считается только там, где заполнен факт${nF?` (заполнено ${nF})`:''}.</p></div>`;
|
|
1169
1233
|
|
|
1170
1234
|
/* ── 3. деньги по дням в разрезе статей ── */
|
|
1171
1235
|
if(agg){ html+=`<div class="note">Таблица по дням доступна при выборе конкретного месяца — сейчас выбран период «${P}».</div>`; }
|
|
@@ -1225,7 +1289,7 @@ function renderCF(C){
|
|
|
1225
1289
|
/* ── 4. ДДС по месяцам ── */
|
|
1226
1290
|
const cfM=MONTHS.map(mo=>computeCF('cf_direct',mo));
|
|
1227
1291
|
const struct=structOf('cf_direct');
|
|
1228
|
-
html+=`<div class="panel"><div class="hd"><h3
|
|
1292
|
+
html+=`<div class="panel"><div class="hd"><h3>CF по месяцам · ${YEAR}</h3><div class="spacer"></div>
|
|
1229
1293
|
<span class="muted small">строки — в Конструкторе</span></div>
|
|
1230
1294
|
<div class="bd" style="padding:0"><div class="tbl-wrap"><table class="wide" style="min-width:${250+13*110}px">
|
|
1231
1295
|
<colgroup><col style="width:250px">${MONTHS.map(()=>`<col style="width:110px">`).join('')}<col style="width:120px"></colgroup>
|
|
@@ -1415,6 +1479,7 @@ function renderOps(C){
|
|
|
1415
1479
|
|
|
1416
1480
|
const inflow=ops.filter(o=>o.type==='Доход').reduce((s,o)=>s+Math.abs(o.amount),0);
|
|
1417
1481
|
const outflow=ops.filter(o=>o.type==='Расход').reduce((s,o)=>s+Math.abs(o.amount),0);
|
|
1482
|
+
const trSum=ops.filter(isTransfer).reduce((s,o)=>s+Math.abs(o.amount),0);
|
|
1418
1483
|
const chSum={}; CHANNELS.forEach(([k])=>chSum[k]=0);
|
|
1419
1484
|
ops.filter(o=>o.type==='Доход'&&cfGroup(o.category)==='op').forEach(o=>{ const c=opChannel(o); chSum[c]=(chSum[c]||0)+Math.abs(o.amount); });
|
|
1420
1485
|
|
|
@@ -1422,15 +1487,15 @@ function renderOps(C){
|
|
|
1422
1487
|
if(lk)html+=`<div class="note">🔒 Период закрыт — операции только для просмотра.</div>`;
|
|
1423
1488
|
html+=`<div class="cards">
|
|
1424
1489
|
<div class="card"><div class="k">Поступления</div><div class="v pos">${fmt(inflow)} ₽</div>
|
|
1425
|
-
<div class="d muted"
|
|
1490
|
+
<div class="d muted">Безналичные ${fmt(chSum.bank)} · Яндекс Еда ${fmt(chSum.yandex)} · Наличные ${fmt(chSum.cash)}</div></div>
|
|
1426
1491
|
<div class="card"><div class="k">Выбытия</div><div class="v neg">${fmt(outflow)} ₽</div><div class="d muted">${ops.filter(o=>o.type==='Расход').length} операций</div></div>
|
|
1427
1492
|
<div class="card" style="border-color:var(--accent)"><div class="k">Итого за период</div><div class="v ${inflow-outflow>=0?'pos':'neg'}">${fmt(inflow-outflow)} ₽</div>
|
|
1428
|
-
<div class="d muted">${ops.length} из ${base.length}
|
|
1493
|
+
<div class="d muted">${ops.length} из ${base.length} операций${trSum?` · переводы ${fmt(trSum)}`:''}</div></div></div>`;
|
|
1429
1494
|
|
|
1430
1495
|
const sel=(cur,list,k)=>`<select onchange="opsFSet('${k}',this.value)"><option value="">— все —</option>${
|
|
1431
1496
|
list.map(v=>`<option ${cur===v?'selected':''}>${esc(v)}</option>`).join('')}</select>`;
|
|
1432
1497
|
html+=`<div class="panel"><div class="bd"><div class="filters">
|
|
1433
|
-
<label class="f"><span>Тип</span>${sel(f.type,['Доход','Расход'],'type')}</label>
|
|
1498
|
+
<label class="f"><span>Тип</span>${sel(f.type,['Доход','Расход','Перевод'],'type')}</label>
|
|
1434
1499
|
<label class="f"><span>Точка</span>${sel(f.point,['Общий',...ST.points],'point')}</label>
|
|
1435
1500
|
<label class="f"><span>Статья</span>${sel(f.cat,allCats,'cat')}</label>
|
|
1436
1501
|
<label class="f"><span>Счёт</span>${sel(f.acc,ST.accounts.map(a=>a.name),'acc')}</label>
|
|
@@ -1452,11 +1517,12 @@ function renderOps(C){
|
|
|
1452
1517
|
if(!slice.length)html+=`<tr><td colspan="9"><div class="empty">${base.length?'Ничего не найдено — измените фильтры':'Нет операций за '+P}</div></td></tr>`;
|
|
1453
1518
|
slice.forEach(o=>{ const emp=o.employeeId&&(ST.employees||[]).find(e=>e.id===o.employeeId);
|
|
1454
1519
|
const isRev=o.type==='Доход'&&cfGroup(o.category)==='op';
|
|
1520
|
+
const tr=isTransfer(o);
|
|
1455
1521
|
html+=`<tr><td class="small">${o.date}</td><td><span class="tag">${o.type}</span>${o.cash===false?' <span class="tag lock">неден.</span>':''}</td>
|
|
1456
1522
|
<td>${esc(o.category)}${emp?` <span class="muted small">· ${esc(emp.name)}</span>`:''}</td>
|
|
1457
1523
|
<td class="muted small">${isRev?CH_NAME[opChannel(o)]:''}</td>
|
|
1458
|
-
<td class="muted small">${esc(o.point||'')}</td><td class="muted small">${esc(o.account||'')}</td>
|
|
1459
|
-
<td class="num ${o.type==='Доход'?'pos':'neg'}">${o.type==='Доход'?'+':'−'}${fmt(Math.abs(o.amount))}</td>
|
|
1524
|
+
<td class="muted small">${esc(o.point||'')}</td><td class="muted small">${esc(o.account||'')}${tr?' → '+esc(o.account2||''):''}</td>
|
|
1525
|
+
<td class="num ${tr?'muted':o.type==='Доход'?'pos':'neg'}">${tr?'⇄ ':o.type==='Доход'?'+':'−'}${fmt(Math.abs(o.amount))}</td>
|
|
1460
1526
|
<td class="muted small">${esc(o.note||o.comment||'')}</td>
|
|
1461
1527
|
<td>${lk?'':`<div class="ctrls" style="opacity:1"><button class="icon" onclick="opModal('${o.id}')">✎</button><button class="icon" onclick="delOp('${o.id}')">✕</button></div>`}</td></tr>`; });
|
|
1462
1528
|
html+=`</tbody></table></div>`;
|
|
@@ -1478,22 +1544,34 @@ function opModal(id){ const ed=id?ST.operations.find(o=>o.id===id):null;
|
|
|
1478
1544
|
const cats=[...new Set([...ST.structures.general,...ST.structures.point].filter(r=>r.role==='value').map(r=>r.cat))];
|
|
1479
1545
|
openModal(ed?'Изменить операцию':'Новая операция',`
|
|
1480
1546
|
<div class="row"><label class="fld" style="flex:1">Дата<input type="date" id="op-date" value="${ed?ed.date:defaultOpDate()}"></label>
|
|
1481
|
-
<label class="fld" style="flex:1">Тип<select id="op-type"><option ${ed&&ed.type==='Доход'?'selected':''}>Доход</option><option ${ed&&ed.type==='Расход'?'selected':''}>Расход</option></select></label></div>
|
|
1482
|
-
<label class="fld">Статья<input id="op-cat" list="catlist" value="${ed?esc(ed.category):'Выручка'}"><datalist id="catlist">${cats.map(c=>`<option>${c}</option>`).join('')}</datalist></label>
|
|
1547
|
+
<label class="fld" style="flex:1">Тип<select id="op-type" onchange="opTypeSync()"><option ${ed&&ed.type==='Доход'?'selected':''}>Доход</option><option ${ed&&ed.type==='Расход'?'selected':''}>Расход</option><option ${ed&&ed.type==='Перевод'?'selected':''}>Перевод</option></select></label></div>
|
|
1548
|
+
<label class="fld" id="op-catwrap">Статья<input id="op-cat" list="catlist" value="${ed?esc(ed.category):'Выручка'}"><datalist id="catlist">${cats.map(c=>`<option>${c}</option>`).join('')}</datalist></label>
|
|
1483
1549
|
<div class="row"><label class="fld" style="flex:1">Точка<select id="op-point"><option>Общий</option>${ST.points.map(p=>`<option ${ed&&ed.point===p?'selected':''}>${p}</option>`).join('')}</select></label>
|
|
1484
|
-
<label class="fld" style="flex:1"
|
|
1550
|
+
<label class="fld" style="flex:1"><span id="op-acclbl">Счёт</span><select id="op-acc">${ST.accounts.map(a=>`<option ${ed&&ed.account===a.name?'selected':''}>${a.name}</option>`).join('')}</select></label></div>
|
|
1551
|
+
<label class="fld" id="op-acc2wrap">На счёт<select id="op-acc2">${ST.accounts.map(a=>`<option ${ed&&ed.account2===a.name?'selected':''}>${a.name}</option>`).join('')}</select></label>
|
|
1485
1552
|
<div class="row"><label class="fld" style="flex:1">Сумма, ₽<input id="op-amt" type="number" value="${ed?Math.abs(ed.amount):''}"></label>
|
|
1486
|
-
<label class="fld" style="flex:1">Канал выручки<select id="op-ch"><option value="">по счёту (авто)</option>${
|
|
1553
|
+
<label class="fld" style="flex:1" id="op-chwrap">Канал выручки<select id="op-ch"><option value="">по счёту (авто)</option>${
|
|
1487
1554
|
CHANNELS.map(([k,n])=>`<option value="${k}" ${ed&&ed.ch===k?'selected':''}>${n}</option>`).join('')}</select></label></div>
|
|
1488
|
-
<div class="row"><label class="fld" style="flex:1">Тип для CF<select id="op-cash"><option value="1" ${!ed||ed.cash!==false?'selected':''}>Денежный (касса)</option><option value="0" ${ed&&ed.cash===false?'selected':''}>Неденежный (только начисление)</option></select></label>
|
|
1555
|
+
<div class="row" id="op-cfrow"><label class="fld" style="flex:1">Тип для CF<select id="op-cash"><option value="1" ${!ed||ed.cash!==false?'selected':''}>Денежный (касса)</option><option value="0" ${ed&&ed.cash===false?'selected':''}>Неденежный (только начисление)</option></select></label>
|
|
1489
1556
|
<label class="fld" style="flex:1">Сотрудник (для ФОТ)<select id="op-emp"><option value="">—</option>${(ST.employees||[]).map(e=>`<option value="${e.id}" ${ed&&ed.employeeId===e.id?'selected':''}>${esc(e.name)}</option>`).join('')}</select></label></div>
|
|
1490
|
-
<label class="fld">Заметка<input id="op-note" value="${ed?esc(ed.note||ed.comment||''):''}" placeholder="комментарий к операции"></label
|
|
1491
|
-
<p class="hint">Канал нужен только для поступлений: он делит выручку на безнал / Яндекс Еду / наличные в CF. «По счёту» — наличный счёт даёт наличные, остальные — безнал.</p>`,
|
|
1557
|
+
<label class="fld">Заметка<input id="op-note" value="${ed?esc(ed.note||ed.comment||''):''}" placeholder="комментарий к операции"></label>`,
|
|
1492
1558
|
()=>{ const date=val('op-date'); const month=MONTHS[new Date(date).getMonth()]; if(isLocked(month)){toast('Месяц закрыт');return;}
|
|
1493
|
-
const
|
|
1494
|
-
|
|
1495
|
-
|
|
1559
|
+
const type=val('op-type');
|
|
1560
|
+
const rec={date,month,type,category:type==='Перевод'?'Перевод':val('op-cat'),point:val('op-point'),account:val('op-acc'),
|
|
1561
|
+
account2:type==='Перевод'?val('op-acc2'):null,
|
|
1562
|
+
amount:Math.abs(Number(val('op-amt'))||0),cash:type==='Перевод'?true:val('op-cash')==='1',
|
|
1563
|
+
employeeId:type==='Перевод'?null:(val('op-emp')||null),
|
|
1564
|
+
ch:type==='Перевод'?null:(val('op-ch')||null),note:val('op-note'),comment:val('op-note')};
|
|
1565
|
+
if(type==='Перевод'&&rec.account===rec.account2){ alert('Счёт списания и счёт зачисления совпадают'); return; }
|
|
1496
1566
|
if(ed)Object.assign(ed,rec); else ST.operations.push({id:uid(),...rec}); save(); render(); });
|
|
1567
|
+
opTypeSync();
|
|
1568
|
+
}
|
|
1569
|
+
function opTypeSync(){
|
|
1570
|
+
const t=(document.getElementById('op-type')||{}).value;
|
|
1571
|
+
const tr=t==='Перевод';
|
|
1572
|
+
const show=(id,on)=>{ const e=document.getElementById(id); if(e)e.style.display=on?'':'none'; };
|
|
1573
|
+
show('op-catwrap',!tr); show('op-acc2wrap',tr); show('op-chwrap',!tr); show('op-cfrow',!tr);
|
|
1574
|
+
const l=document.getElementById('op-acclbl'); if(l)l.textContent=tr?'Со счёта':'Счёт';
|
|
1497
1575
|
}
|
|
1498
1576
|
function delOp(id){ if(confirm('Удалить операцию?')){ ST.operations=ST.operations.filter(o=>o.id!==id); save(); render(); } }
|
|
1499
1577
|
|
|
@@ -1647,7 +1725,7 @@ function settStruct(){
|
|
|
1647
1725
|
return html;
|
|
1648
1726
|
}
|
|
1649
1727
|
function settAccounts(){
|
|
1650
|
-
let html=`<div class="note"
|
|
1728
|
+
let html=`<div class="note">Три типа счетов: <b>банковский</b>, <b>наличные</b> (физическая касса точек) и <b>карта</b>. Наличные и карты попадают в сверку кассы на «CF · прямой», но касса точек считается только по наличным — перевод из кассы на карту уменьшает наличку точки. Порядок стрелками ↑↓ действует везде.</div>`;
|
|
1651
1729
|
html+=`<div class="panel"><div class="hd"><h3>Начало учёта</h3></div><div class="bd">
|
|
1652
1730
|
<div class="row" style="align-items:center;gap:10px">
|
|
1653
1731
|
<label class="fld" style="max-width:220px">Учёт ведётся с месяца
|
|
@@ -1655,9 +1733,9 @@ function settAccounts(){
|
|
|
1655
1733
|
<p class="hint" style="margin:0;flex:1">Операции более ранних месяцев не влияют на остатки. В колонке «Остаток на начало» укажите деньги на конец последнего несведённого месяца — например, на 31 июля, если учёт начинается с августа.</p>
|
|
1656
1734
|
</div></div></div>`;
|
|
1657
1735
|
html+=`<div class="panel"><div class="hd"><h3>Счета</h3><div class="spacer"></div><button class="primary mini" onclick="accModal()">+ Счёт</button></div>
|
|
1658
|
-
<div class="bd" style="padding:0"><div class="tbl-wrap"><table><thead><tr><th>Счёт</th><th>Тип</th><th class="num">Остаток на начало ${
|
|
1659
|
-
ST.accounts.forEach((a,i)=>html+=`<tr><td>${esc(a.name)}</td><td><span class="tag">${accType(a)
|
|
1660
|
-
<td><div class="ctrls" style="opacity:1;justify-content:flex-start;gap:4px"><button class="icon" ${i===0?'disabled':''} onclick="accMove(${i},-1)" title="Выше">↑</button><button class="icon" ${i===ST.accounts.length-1?'disabled':''} onclick="accMove(${i},1)" title="Ниже">↓</button><button class="icon" onclick="accModal(${i})">✎</button><button class="icon" onclick="accDel(${i})">✕</button></div></td></tr>`);
|
|
1736
|
+
<div class="bd" style="padding:0"><div class="tbl-wrap"><table><thead><tr><th>Счёт</th><th>Тип</th><th class="num">Остаток на начало ${startMonthGen()}</th><th class="num">Текущий остаток</th><th style="width:150px;min-width:150px"></th></tr></thead><tbody>`;
|
|
1737
|
+
ST.accounts.forEach((a,i)=>html+=`<tr><td>${esc(a.name)}</td><td><span class="tag">${ACC_TYPE_NAME[accType(a)]||'банк'}</span></td><td class="num">${fmt(a.opening)}</td><td class="num">${fmt(accountBalance(a))}</td>
|
|
1738
|
+
<td><div class="ctrls" style="opacity:1;justify-content:flex-start;gap:4px;flex-wrap:nowrap;white-space:nowrap"><button class="icon" ${i===0?'disabled':''} onclick="accMove(${i},-1)" title="Выше">↑</button><button class="icon" ${i===ST.accounts.length-1?'disabled':''} onclick="accMove(${i},1)" title="Ниже">↓</button><button class="icon" onclick="accModal(${i})">✎</button><button class="icon" onclick="accDel(${i})">✕</button></div></td></tr>`);
|
|
1661
1739
|
html+=`</tbody></table></div></div></div>`;
|
|
1662
1740
|
return html;
|
|
1663
1741
|
}
|
|
@@ -1761,10 +1839,13 @@ function rowSource(scope,id){ const r=structOf(scope).find(x=>x.id===id); if(!r)
|
|
|
1761
1839
|
function accModal(i){ const ed=(i!=null)?ST.accounts[i]:null; const t=ed?accType(ed):'bank';
|
|
1762
1840
|
openModal(ed?'Изменить счёт':'Новый счёт',`
|
|
1763
1841
|
<label class="fld">Название<input id="ac-name" value="${ed?esc(ed.name):''}" placeholder="Сбер / Наличные / Точка"></label>
|
|
1764
|
-
<label class="fld">Тип<select id="ac-type"><option value="bank" ${t==='bank'?'selected':''}>Банковский счёт</option><option value="cash" ${t==='cash'?'selected':''}>Наличные (касса)</option></select></label>
|
|
1765
|
-
<label class="fld">Остаток на начало
|
|
1842
|
+
<label class="fld">Тип<select id="ac-type"><option value="bank" ${t==='bank'?'selected':''}>Банковский счёт</option><option value="cash" ${t==='cash'?'selected':''}>Наличные (касса точек)</option><option value="card" ${t==='card'?'selected':''}>Карта</option></select></label>
|
|
1843
|
+
<label class="fld">Остаток на начало ${startMonthGen()} ${YEAR}, ₽<input id="ac-open" type="number" step="0.01" value="${ed?ed.opening:0}"></label>
|
|
1844
|
+
<p class="hint">Деньги на счёте на утро первого дня месяца, с которого ведётся учёт. Меняется в «Начало учёта» выше.</p>`,
|
|
1766
1845
|
()=>{ const name=val('ac-name').trim(); const opening=Number(val('ac-open'))||0; const type=val('ac-type'); if(!name)return;
|
|
1767
|
-
if(ed){ const old=ed.name; ed.name=name; ed.opening=opening; ed.type=type;
|
|
1846
|
+
if(ed){ const old=ed.name; ed.name=name; ed.opening=opening; ed.type=type;
|
|
1847
|
+
if(old!==name){ ST.operations.forEach(o=>{ if(o.account===old)o.account=name; if(o.account2===old)o.account2=name; });
|
|
1848
|
+
Object.keys(ST.accMap||{}).forEach(k=>{ if(ST.accMap[k]===old)ST.accMap[k]=name; }); } }
|
|
1768
1849
|
else ST.accounts.push({id:uid(),name,opening,type}); save(); render(); });
|
|
1769
1850
|
}
|
|
1770
1851
|
function setStartMonth(v){ const i=Math.max(0,Math.min(11,Number(v)||0));
|