buhanka-finance-app 1.3.0 → 1.5.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.
Files changed (2) hide show
  1. package/index.html +138 -49
  2. 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.4';
227
- const SCHEMA=2; /* версия структуры данных; растёт при доработках */
231
+ const APP_VERSION='3.6';
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("Прочее (косвенное)","value",-1,'manual','Прочее косв'),S("Управляющий","value",-1,'manual','Управляющий'),
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
- if(bal[o.account]==null)return; bal[o.account]+= o.type==='Доход'?Math.abs(o.amount):-Math.abs(o.amount); });
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=>{ if(bal[o.account]==null)return; bal[o.account]+= o.type==='Доход'?Math.abs(o.amount):-Math.abs(o.amount); }); }
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 prevIdx=MONTHS.indexOf(mo)-1;
625
- const prev=prevIdx>=0 ? (ST.cashFact[MONTHS[prevIdx]]||{})[point] : null;
626
- let start = (prev==null||prev==='') ? 0 : Number(prev);
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&&cashAcc.has(o.account))
630
- .forEach(o=>{ if(o.type==='Доход')inc+=Math.abs(o.amount); else out+=Math.abs(o.amount); });
631
- return {start,inc,out,calc:start+inc-out};
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
- function accountBalance(acc){ return Number(acc.opening||0)+ST.operations.reduce((s,o)=> s+((o.account===acc.name&&isCashOp(o)&&inScope(o.month))?(o.type==='Доход'?Math.abs(o.amount):-Math.abs(o.amount)):0),0); }
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,16 +1181,16 @@ 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)==='cash'?'наличные':'банк'} · старт ${fmt(a.opening)}</div></div>`; });
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
 
1146
1188
  /* ── 2. наличные по точкам ── */
1147
1189
  const m=primaryMonth(), lk=isLocked(m);
1148
- html+=`<div class="panel"><div class="hd"><h3>Наличные по точкам · ${m}</h3><div class="spacer"></div>
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>Точка</th><th class="num">На начало</th><th class="num">Приход нал.</th><th class="num">Расход нал.</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,46 @@ 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
- html+=`<tr><td>${esc(p)}</td><td class="num muted">${fmt(c.start)}</td><td class="num pos">${c.inc?fmt(c.inc):'—'}</td>
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>`; });
1163
- const tD=nF?tF-tCf:null;
1164
- 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
- <td class="num">${fmt(tC)}</td><td class="num">${nF?fmt(tF):'—'}</td>
1166
- <td class="num ${tD==null?'':Math.abs(tD)<1?'pos':'neg'}">${tD==null?'—':(tD>0?'+':'')+fmt(tD)}</td></tr>
1167
- </tbody></table></div></div>
1168
- <p class="hint" style="padding:0 15px 12px;margin:0">На начало берётся факт предыдущего месяца. Приход и расход — операции по наличным счетам, привязанные к точке. Итоговое расхождение считается только по точкам, где факт заполнен${nF?` (сейчас ${nF} из ${ST.points.length})`:''}.</p></div>`;
1208
+ /* итог по точкам */
1209
+ const tD_pt = nF ? tF-tCf : null;
1210
+ html+=`<tr class="subtotal"><td>Итого по точкам</td><td class="num">${fmt(tS)}</td><td class="num">${fmt(tI)}</td>
1211
+ <td class="num">${fmt(tO)}</td><td class="num">${fmt(tC)}</td><td class="num">${nF?fmt(tF):'—'}</td>
1212
+ <td class="num ${tD_pt==null?'':Math.abs(tD_pt)<1?'pos':'neg'}">${tD_pt==null?'—':(tD_pt>0?'+':'')+fmt(tD_pt)}</td></tr>`;
1213
+
1214
+ const mi2=MONTHS.indexOf(m);
1215
+ const accFlow=(a)=>{ let inc=0,out=0;
1216
+ (ST.operations||[]).filter(o=>o.month===m&&isCashOp(o)).forEach(o=>{ const d=accDelta(o,a.name); if(d>0)inc+=d; else out-=d; });
1217
+ return {open:accountBalance(a,mi2-1), close:accountBalance(a,mi2), inc, out}; };
1218
+
1219
+ /* контрольная строка: счёт «наличные» целиком должен совпасть с суммой по точкам */
1220
+ ST.accounts.filter(a=>accType(a)==='cash').forEach(a=>{ const f=accFlow(a); const d=f.close-tC;
1221
+ html+=`<tr><td class="muted">Счёт «${esc(a.name)}» — всего</td><td class="num muted">${fmt(f.open)}</td>
1222
+ <td class="num pos">${f.inc?fmt(f.inc):'—'}</td><td class="num neg">${f.out?fmt(f.out):'—'}</td>
1223
+ <td class="num" style="font-weight:600">${fmt(f.close)}</td><td class="num muted small">сверка с точками</td>
1224
+ <td class="num ${Math.abs(d)<1?'pos':'neg'}">${Math.abs(d)<1?'сходится':(d>0?'+':'')+fmt(d)}</td></tr>`; });
1225
+
1226
+ /* карты — отдельные счета, в кассу точек не входят */
1227
+ const cards=ST.accounts.filter(a=>accType(a)==='card');
1228
+ if(cards.length){
1229
+ 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>`;
1230
+ cards.forEach(a=>{ const f=accFlow(a); const key='acc:'+a.name;
1231
+ const fv=(ST.cashFact[m]||{})[key]; const has=!(fv==null||fv==='');
1232
+ const diff=has?Number(fv)-f.close:null;
1233
+ html+=`<tr><td>${esc(a.name)}</td><td class="num muted">${fmt(f.open)}</td>
1234
+ <td class="num pos">${f.inc?fmt(f.inc):'—'}</td><td class="num neg">${f.out?fmt(f.out):'—'}</td>
1235
+ <td class="num" style="font-weight:600">${fmt(f.close)}</td>
1236
+ <td class="num"><input class="money" ${lk?'disabled':''} value="${has?fmtNum(fv):''}" placeholder="—" style="width:120px" onchange="cashFactSet('${m}','${key}',this.value)"></td>
1237
+ <td class="num ${diff==null?'muted':Math.abs(diff)<1?'pos':'neg'}">${diff==null?'—':(diff>0?'+':'')+fmt(diff)}</td></tr>`; });
1238
+ }
1239
+ html+=`</tbody></table></div></div>
1240
+ <p class="hint" style="padding:0 15px 12px;margin:0">Наличные — это деньги на точках: «На начало» в первом месяце учёта вводится вручную, дальше подставляется факт предыдущего месяца. Строка «Счёт "Наличные" — всего» сверяет остаток счёта с суммой по точкам: если сходится, вся касса разнесена по точкам. Карты — отдельные счета, в кассу точек не входят; перевод из кассы на карту уменьшает наличку точки.</p></div>`;
1169
1241
 
1170
1242
  /* ── 3. деньги по дням в разрезе статей ── */
1171
1243
  if(agg){ html+=`<div class="note">Таблица по дням доступна при выборе конкретного месяца — сейчас выбран период «${P}».</div>`; }
@@ -1225,7 +1297,7 @@ function renderCF(C){
1225
1297
  /* ── 4. ДДС по месяцам ── */
1226
1298
  const cfM=MONTHS.map(mo=>computeCF('cf_direct',mo));
1227
1299
  const struct=structOf('cf_direct');
1228
- html+=`<div class="panel"><div class="hd"><h3>ДДС по месяцам · ${YEAR}</h3><div class="spacer"></div>
1300
+ html+=`<div class="panel"><div class="hd"><h3>CF по месяцам · ${YEAR}</h3><div class="spacer"></div>
1229
1301
  <span class="muted small">строки — в Конструкторе</span></div>
1230
1302
  <div class="bd" style="padding:0"><div class="tbl-wrap"><table class="wide" style="min-width:${250+13*110}px">
1231
1303
  <colgroup><col style="width:250px">${MONTHS.map(()=>`<col style="width:110px">`).join('')}<col style="width:120px"></colgroup>
@@ -1415,6 +1487,7 @@ function renderOps(C){
1415
1487
 
1416
1488
  const inflow=ops.filter(o=>o.type==='Доход').reduce((s,o)=>s+Math.abs(o.amount),0);
1417
1489
  const outflow=ops.filter(o=>o.type==='Расход').reduce((s,o)=>s+Math.abs(o.amount),0);
1490
+ const trSum=ops.filter(isTransfer).reduce((s,o)=>s+Math.abs(o.amount),0);
1418
1491
  const chSum={}; CHANNELS.forEach(([k])=>chSum[k]=0);
1419
1492
  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
1493
 
@@ -1422,15 +1495,15 @@ function renderOps(C){
1422
1495
  if(lk)html+=`<div class="note">🔒 Период закрыт — операции только для просмотра.</div>`;
1423
1496
  html+=`<div class="cards">
1424
1497
  <div class="card"><div class="k">Поступления</div><div class="v pos">${fmt(inflow)} ₽</div>
1425
- <div class="d muted">безнал ${fmt(chSum.bank)} · Яндекс ${fmt(chSum.yandex)} · нал ${fmt(chSum.cash)}</div></div>
1498
+ <div class="d muted">Безналичные ${fmt(chSum.bank)} · Яндекс Еда ${fmt(chSum.yandex)} · Наличные ${fmt(chSum.cash)}</div></div>
1426
1499
  <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
1500
  <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} операций</div></div></div>`;
1501
+ <div class="d muted">${ops.length} из ${base.length} операций${trSum?` · переводы ${fmt(trSum)}`:''}</div></div></div>`;
1429
1502
 
1430
1503
  const sel=(cur,list,k)=>`<select onchange="opsFSet('${k}',this.value)"><option value="">— все —</option>${
1431
1504
  list.map(v=>`<option ${cur===v?'selected':''}>${esc(v)}</option>`).join('')}</select>`;
1432
1505
  html+=`<div class="panel"><div class="bd"><div class="filters">
1433
- <label class="f"><span>Тип</span>${sel(f.type,['Доход','Расход'],'type')}</label>
1506
+ <label class="f"><span>Тип</span>${sel(f.type,['Доход','Расход','Перевод'],'type')}</label>
1434
1507
  <label class="f"><span>Точка</span>${sel(f.point,['Общий',...ST.points],'point')}</label>
1435
1508
  <label class="f"><span>Статья</span>${sel(f.cat,allCats,'cat')}</label>
1436
1509
  <label class="f"><span>Счёт</span>${sel(f.acc,ST.accounts.map(a=>a.name),'acc')}</label>
@@ -1452,11 +1525,12 @@ function renderOps(C){
1452
1525
  if(!slice.length)html+=`<tr><td colspan="9"><div class="empty">${base.length?'Ничего не найдено — измените фильтры':'Нет операций за '+P}</div></td></tr>`;
1453
1526
  slice.forEach(o=>{ const emp=o.employeeId&&(ST.employees||[]).find(e=>e.id===o.employeeId);
1454
1527
  const isRev=o.type==='Доход'&&cfGroup(o.category)==='op';
1528
+ const tr=isTransfer(o);
1455
1529
  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
1530
  <td>${esc(o.category)}${emp?` <span class="muted small">· ${esc(emp.name)}</span>`:''}</td>
1457
1531
  <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>
1532
+ <td class="muted small">${esc(o.point||'')}</td><td class="muted small">${esc(o.account||'')}${tr?' → '+esc(o.account2||''):''}</td>
1533
+ <td class="num ${tr?'muted':o.type==='Доход'?'pos':'neg'}">${tr?'⇄ ':o.type==='Доход'?'+':'−'}${fmt(Math.abs(o.amount))}</td>
1460
1534
  <td class="muted small">${esc(o.note||o.comment||'')}</td>
1461
1535
  <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
1536
  html+=`</tbody></table></div>`;
@@ -1478,22 +1552,34 @@ function opModal(id){ const ed=id?ST.operations.find(o=>o.id===id):null;
1478
1552
  const cats=[...new Set([...ST.structures.general,...ST.structures.point].filter(r=>r.role==='value').map(r=>r.cat))];
1479
1553
  openModal(ed?'Изменить операцию':'Новая операция',`
1480
1554
  <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>
1555
+ <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>
1556
+ <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
1557
  <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">Счёт<select id="op-acc">${ST.accounts.map(a=>`<option ${ed&&ed.account===a.name?'selected':''}>${a.name}</option>`).join('')}</select></label></div>
1558
+ <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>
1559
+ <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
1560
  <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>${
1561
+ <label class="fld" style="flex:1" id="op-chwrap">Канал выручки<select id="op-ch"><option value="">по счёту (авто)</option>${
1487
1562
  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>
1563
+ <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
1564
  <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>`,
1565
+ <label class="fld">Заметка<input id="op-note" value="${ed?esc(ed.note||ed.comment||''):''}" placeholder="комментарий к операции"></label>`,
1492
1566
  ()=>{ const date=val('op-date'); const month=MONTHS[new Date(date).getMonth()]; if(isLocked(month)){toast('Месяц закрыт');return;}
1493
- const rec={date,month,type:val('op-type'),category:val('op-cat'),point:val('op-point'),account:val('op-acc'),
1494
- amount:Number(val('op-amt'))||0,cash:val('op-cash')==='1',employeeId:val('op-emp')||null,
1495
- ch:val('op-ch')||null,note:val('op-note'),comment:val('op-note')};
1567
+ const type=val('op-type');
1568
+ const rec={date,month,type,category:type==='Перевод'?'Перевод':val('op-cat'),point:val('op-point'),account:val('op-acc'),
1569
+ account2:type==='Перевод'?val('op-acc2'):null,
1570
+ amount:Math.abs(Number(val('op-amt'))||0),cash:type==='Перевод'?true:val('op-cash')==='1',
1571
+ employeeId:type==='Перевод'?null:(val('op-emp')||null),
1572
+ ch:type==='Перевод'?null:(val('op-ch')||null),note:val('op-note'),comment:val('op-note')};
1573
+ if(type==='Перевод'&&rec.account===rec.account2){ alert('Счёт списания и счёт зачисления совпадают'); return; }
1496
1574
  if(ed)Object.assign(ed,rec); else ST.operations.push({id:uid(),...rec}); save(); render(); });
1575
+ opTypeSync();
1576
+ }
1577
+ function opTypeSync(){
1578
+ const t=(document.getElementById('op-type')||{}).value;
1579
+ const tr=t==='Перевод';
1580
+ const show=(id,on)=>{ const e=document.getElementById(id); if(e)e.style.display=on?'':'none'; };
1581
+ show('op-catwrap',!tr); show('op-acc2wrap',tr); show('op-chwrap',!tr); show('op-cfrow',!tr);
1582
+ const l=document.getElementById('op-acclbl'); if(l)l.textContent=tr?'Со счёта':'Счёт';
1497
1583
  }
1498
1584
  function delOp(id){ if(confirm('Удалить операцию?')){ ST.operations=ST.operations.filter(o=>o.id!==id); save(); render(); } }
1499
1585
 
@@ -1647,7 +1733,7 @@ function settStruct(){
1647
1733
  return html;
1648
1734
  }
1649
1735
  function settAccounts(){
1650
- let html=`<div class="note">Счета делятся на <b>банковские</b> и <b>наличные</b>. Порядок стрелками ↑↓ — в этом же порядке счета идут в списках, карточках и таблице по дням.</div>`;
1736
+ let html=`<div class="note">Три типа счетов: <b>банковский</b>, <b>наличные</b> (физическая касса точек) и <b>карта</b>. Наличные и карты попадают в сверку кассы на «CF · прямой», но касса точек считается только по наличным — перевод из кассы на карту уменьшает наличку точки. Порядок стрелками ↑↓ действует везде.</div>`;
1651
1737
  html+=`<div class="panel"><div class="hd"><h3>Начало учёта</h3></div><div class="bd">
1652
1738
  <div class="row" style="align-items:center;gap:10px">
1653
1739
  <label class="fld" style="max-width:220px">Учёт ведётся с месяца
@@ -1655,9 +1741,9 @@ function settAccounts(){
1655
1741
  <p class="hint" style="margin:0;flex:1">Операции более ранних месяцев не влияют на остатки. В колонке «Остаток на начало» укажите деньги на конец последнего несведённого месяца — например, на 31 июля, если учёт начинается с августа.</p>
1656
1742
  </div></div></div>`;
1657
1743
  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">Остаток на начало ${startMonth().toLowerCase()}</th><th class="num">Текущий остаток</th><th style="width:110px"></th></tr></thead><tbody>`;
1659
- ST.accounts.forEach((a,i)=>html+=`<tr><td>${esc(a.name)}</td><td><span class="tag">${accType(a)==='cash'?'наличные':'банк'}</span></td><td class="num">${fmt(a.opening)}</td><td class="num">${fmt(accountBalance(a))}</td>
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>`);
1744
+ <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>`;
1745
+ 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>
1746
+ <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
1747
  html+=`</tbody></table></div></div></div>`;
1662
1748
  return html;
1663
1749
  }
@@ -1761,10 +1847,13 @@ function rowSource(scope,id){ const r=structOf(scope).find(x=>x.id===id); if(!r)
1761
1847
  function accModal(i){ const ed=(i!=null)?ST.accounts[i]:null; const t=ed?accType(ed):'bank';
1762
1848
  openModal(ed?'Изменить счёт':'Новый счёт',`
1763
1849
  <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">Остаток на начало года, ₽<input id="ac-open" type="number" value="${ed?ed.opening:0}"></label>`,
1850
+ <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>
1851
+ <label class="fld">Остаток на начало ${startMonthGen()} ${YEAR}, ₽<input id="ac-open" type="number" step="0.01" value="${ed?ed.opening:0}"></label>
1852
+ <p class="hint">Деньги на счёте на утро первого дня месяца, с которого ведётся учёт. Меняется в «Начало учёта» выше.</p>`,
1766
1853
  ()=>{ 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; if(old!==name)ST.operations.forEach(o=>{if(o.account===old)o.account=name;}); }
1854
+ if(ed){ const old=ed.name; ed.name=name; ed.opening=opening; ed.type=type;
1855
+ if(old!==name){ ST.operations.forEach(o=>{ if(o.account===old)o.account=name; if(o.account2===old)o.account2=name; });
1856
+ Object.keys(ST.accMap||{}).forEach(k=>{ if(ST.accMap[k]===old)ST.accMap[k]=name; }); } }
1768
1857
  else ST.accounts.push({id:uid(),name,opening,type}); save(); render(); });
1769
1858
  }
1770
1859
  function setStartMonth(v){ const i=Math.max(0,Math.min(11,Number(v)||0));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buhanka-finance-app",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Buhanka bakery finance dashboard — single-file web app",
5
5
  "license": "MIT",
6
6
  "files": [