buhanka-finance-app 1.2.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.
Files changed (2) hide show
  1. package/index.html +131 -47
  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.3';
227
- const SCHEMA=1; /* версия структуры данных; растёт при доработках */
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);
@@ -249,7 +255,7 @@ function buildStructure(){
249
255
  return [
250
256
  S("Выручка","value",1,'manual','Выручка'),S("Себестоимость","value",-1,'manual','Себестоимость'),
251
257
  S("Маржинальная прибыль","subtotal"),S("Прямые расходы","header"),
252
- S("Списания","value",-1,'manual','Списания'),S("Эквайринг","value",-1,'manual','Эквайринг'),
258
+ S("Списания","value",-1,'manual','Списания'),S("Эквайринг","value",-1,'ops','Эквайринг'),
253
259
  S("Аренда","value",-1,'fixed','Аренда'),S("Субаренда","value",1,'manual','Субаренда'),
254
260
  S("Расходные материалы","value",-1,'manual','Расходные материалы'),S("ФОТ","value",-1,'manual','ФОТ'),
255
261
  S("Уборщица","value",-1,'manual','Уборщица'),S("Вывоз мусора","value",-1,'fixed','Вывоз мусора'),
@@ -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
  }
@@ -371,10 +377,18 @@ function load(){
371
377
  }
372
378
  function schemaGuard(){
373
379
  const cur=Number(ST._schema||0);
374
- if(cur<SCHEMA){
375
- if((ST.operations||[]).length || bkCells(ST)) bkCreate('Перед обновлением файла до v'+APP_VERSION,true,true);
376
- ST._schema=SCHEMA; save();
380
+ if(cur>=SCHEMA) return;
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='Прочее'; });
377
386
  }
387
+ if(cur<2){ /* v3.4: «Эквайринг» считается из операций, выручка остаётся ручной (из 1С) */
388
+ [ (ST.structures||{}).general, (ST.structures||{}).point ].forEach(st=>(st||[]).forEach(r=>{
389
+ if(r&&r.role==='value'&&r.cat==='Эквайринг'&&(r.source==='manual'||!r.source)) r.source='ops'; }));
390
+ }
391
+ ST._schema=SCHEMA; save();
378
392
  }
379
393
  function legacyFound(){ const out=[];
380
394
  LEGACY_KEYS.forEach(k=>{ try{ const v=localStorage.getItem(k); if(v) out.push({key:k,size:v.length}); }catch(e){} });
@@ -424,6 +438,7 @@ function migrate(){ ST.locks=ST.locks||{}; ST.cfData=ST.cfData||{}; ST.ui=ST.ui|
424
438
  if(!ST.todoRules)ST.todoRules=buildTodoRules(); if(!ST.todoDone)ST.todoDone={}; if(!ST.todoOnce)ST.todoOnce={};
425
439
  if(!ST.plan)ST.plan={}; if(!ST.cashFact)ST.cashFact={};
426
440
  if(!ST.merchants)ST.merchants={}; if(!ST.accMap)ST.accMap={}; if(!ST.impSeen)ST.impSeen={};
441
+ if(!ST.cashOpen)ST.cashOpen={};
427
442
  if(!ST.impRules||!ST.impRules.length)ST.impRules=defaultImpRules();
428
443
  if(ST.startMonthIdx==null)ST.startMonthIdx=0;
429
444
  if(!ST.ui.opsF)ST.ui.opsF={type:'',point:'',cat:'',acc:'',q:'',size:50,page:1};
@@ -433,7 +448,9 @@ function migrate(){ ST.locks=ST.locks||{}; ST.cfData=ST.cfData||{}; ST.ui=ST.ui|
433
448
  (ST.shareholders||[]).forEach(sh=>{ delete sh.share; delete sh.fromIdx; delete sh.toIdx; });
434
449
  (ST.keyRates||[]).forEach(k=>{ if(!k.date)k.date=`${YEAR}-${String((k.fromIdx||0)+1).padStart(2,'0')}-01`; });
435
450
  (ST.shareholders||[]).forEach(s=>{ if(s.fromIdx==null)s.fromIdx=0; }); }
436
- 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'; }
437
454
 
438
455
  /* ---------- period ---------- */
439
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(); }
@@ -467,11 +484,11 @@ const PAGES=[
467
484
  {id:'loans',grp:'Финансирование',name:'Займы и кредиты',ic:'₽'},
468
485
  {id:'ops',grp:'Данные',name:'Операции',ic:'≡'},
469
486
  {id:'fixed',grp:'Данные',name:'Постоянные расходы',ic:'⧉'},
470
- {id:'import',grp:'Данные',name:'Импорт выписок',ic:'⤓'},
471
487
  {id:'wcap',grp:'Данные',name:'Запасы',ic:'▧'},
472
488
  {id:'sched',grp:'Данные',name:'Расписание и ФОТ',ic:'☷'},
473
489
  {id:'recon',grp:'Данные',name:'Сверка PnL ↔ CF',ic:'⚖'},
474
490
  {id:'settings',grp:'Система',name:'Конструктор',ic:'⚙'},
491
+ {id:'import',grp:'Система',name:'Импорт выписок',ic:'⤓'},
475
492
  {id:'backup',grp:'Система',name:'Бэкапы',ic:'↺'},
476
493
  ];
477
494
  let CUR='dash';
@@ -547,8 +564,12 @@ function opChannel(o){
547
564
  const a=(ST.accounts||[]).find(x=>x.name===o.account);
548
565
  return (a&&accType(a)==='cash')?'cash':'bank';
549
566
  }
567
+ const FIN_BASE=['Получение займа','Возврат займа','Дивиденды','Прочее (фин)'];
568
+ const INV_BASE=['Покупка оборудования','Ремонт','Обеспечительные платежи','Прочее (инвест)'];
550
569
  /* ── группировка статей по разделам CF ── */
551
570
  function cfGroup(cat){
571
+ if(FIN_BASE.includes(cat)) return 'fin';
572
+ if(INV_BASE.includes(cat)) return 'inv';
552
573
  const c=String(cat||'').toLowerCase();
553
574
  if(/займ|кредит|дивиденд|взнос учред/.test(c)) return 'fin';
554
575
  if(/оборудован|ремонт|обеспечительн|капвлож/.test(c)) return 'inv';
@@ -557,9 +578,11 @@ function cfGroup(cat){
557
578
  function cfCats(){
558
579
  const out={op:[],fin:[],inv:[]}, seen=new Set();
559
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); } });
560
583
  (ST.structures.point||[]).forEach(r=>{ if(r.role==='value'&&(r.sign||-1)===-1) push(r.cat); });
561
584
  (ST.fixed||[]).forEach(f=>push(f.category));
562
- (ST.operations||[]).forEach(o=>{ if(o.category!=='Выручка') push(o.category); });
585
+ (ST.operations||[]).forEach(o=>{ if(o.category!=='Выручка'&&!isTransfer(o)) push(o.category); });
563
586
  return out;
564
587
  }
565
588
  /* ── ручные правки прогноза: ST.plan[месяц][день][ключ] ── */
@@ -583,7 +606,7 @@ function cfDayMatrix(mi){
583
606
  const mainAcc=((ST.accounts||[]).find(a=>accType(a)==='bank')||(ST.accounts||[])[0]||{}).name;
584
607
  const bal={}; (ST.accounts||[]).forEach(a=>bal[a.name]=Number(a.opening||0));
585
608
  (ST.operations||[]).filter(o=>isCashOp(o)&&MONTHS.indexOf(o.month)<mi&&inScope(o.month)).forEach(o=>{
586
- 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); }); });
587
610
  const days=[];
588
611
  for(let d=1; d<=N; d++){
589
612
  const ds=`${YEAR}-${String(mi+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
@@ -591,6 +614,7 @@ function cfDayMatrix(mi){
591
614
  const actual=d<=au;
592
615
  const inc={bank:0,yandex:0,cash:0}, exp={}, fin={}, inv={};
593
616
  ops.forEach(o=>{ const amt=Math.abs(o.amount), g=cfGroup(o.category);
617
+ if(isTransfer(o)) return; /* внутренний перевод — не приток и не отток */
594
618
  if(g==='fin'){ fin[o.category]=(fin[o.category]||0)+(o.type==='Доход'?amt:-amt); return; }
595
619
  if(g==='inv'){ inv[o.category]=(inv[o.category]||0)+(o.type==='Доход'?amt:-amt); return; }
596
620
  if(o.type==='Доход'){ const ch=opChannel(o); inc[ch]=(inc[ch]||0)+amt; }
@@ -606,7 +630,7 @@ function cfDayMatrix(mi){
606
630
  const finSum=cats.fin.reduce((t,c)=>t+(fin[c]||0),0);
607
631
  const invSum=cats.inv.reduce((t,c)=>t+(inv[c]||0),0);
608
632
  const open={}; accs.forEach(a=>open[a]=bal[a]);
609
- 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); }); }); }
610
634
  else if(mainAcc!=null && bal[mainAcc]!=null){ bal[mainAcc]+= inSum-exSum+finSum+invSum; }
611
635
  const close={}; accs.forEach(a=>close[a]=bal[a]);
612
636
  const openTot=accs.reduce((t,a)=>t+open[a],0), closeTot=accs.reduce((t,a)=>t+close[a],0);
@@ -618,15 +642,22 @@ function cfDayMatrix(mi){
618
642
 
619
643
  /* ── наличные по точкам (сверка кассы) ── */
620
644
  function cashCalc(mo,point){
621
- const prevIdx=MONTHS.indexOf(mo)-1;
622
- const prev=prevIdx>=0 ? (ST.cashFact[MONTHS[prevIdx]]||{})[point] : null;
623
- 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;
624
650
  const cashAcc=new Set((ST.accounts||[]).filter(a=>accType(a)==='cash').map(a=>a.name));
625
651
  let inc=0,out=0;
626
- (ST.operations||[]).filter(o=>o.month===mo&&isCashOp(o)&&o.point===point&&cashAcc.has(o.account))
627
- .forEach(o=>{ if(o.type==='Доход')inc+=Math.abs(o.amount); else out+=Math.abs(o.amount); });
628
- return {start,inc,out,calc:start+inc-out};
629
- }
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(); }
630
661
  function cashFactSet(mo,point,v){ if(isLocked(mo)){toast('Месяц закрыт');render();return;}
631
662
  ST.cashFact=ST.cashFact||{}; ST.cashFact[mo]=ST.cashFact[mo]||{};
632
663
  const n=parseNum(v); if(n==null)delete ST.cashFact[mo][point]; else ST.cashFact[mo][point]=n; save(); render(); }
@@ -662,8 +693,22 @@ function computeCF(structKey,period){
662
693
  /* ---------- accounts / cash ---------- */
663
694
  function startIdx(){ return Math.max(0,Math.min(11,Number(ST.startMonthIdx||0))); }
664
695
  function startMonth(){ return MONTHS[startIdx()]; }
696
+ function startMonthGen(){ return MGEN[startIdx()]; }
665
697
  function inScope(mo){ return MONTHS.indexOf(mo)>=startIdx(); }
666
- 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
+ }
667
712
  function totalOpening(){ return ST.accounts.reduce((s,a)=>s+Number(a.opening||0),0); }
668
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);
669
714
  const outOps=ST.operations.filter(o=>o.month===mo&&o.type==='Расход'&&isCashOp(o)).reduce((s,o)=>s+Math.abs(o.amount),0);
@@ -1136,7 +1181,7 @@ function renderCF(C){
1136
1181
  let tot=0;
1137
1182
  ST.accounts.forEach(a=>{ const b=accountBalance(a); tot+=b;
1138
1183
  html+=`<div class="card"><div class="k">${esc(a.name)}</div><div class="v" style="font-size:20px">${fmt(b)} ₽</div>
1139
- <div class="d muted">${accType(a)==='cash'?'наличные':'банк'} · старт ${fmt(a.opening)}</div></div>`; });
1184
+ <div class="d muted">${ACC_TYPE_NAME[accType(a)]||'банк'}</div></div>`; });
1140
1185
  html+=`<div class="card" style="border-color:var(--accent)"><div class="k">Итого средств</div><div class="v">${fmt(tot)} ₽</div>
1141
1186
  <div class="d muted">${ST.accounts.length} ${ST.accounts.length===1?'счёт':'счетов'}</div></div></div>`;
1142
1187
 
@@ -1145,7 +1190,7 @@ function renderCF(C){
1145
1190
  html+=`<div class="panel"><div class="hd"><h3>Наличные по точкам · ${m}</h3><div class="spacer"></div>
1146
1191
  ${lk?'<span class="tag lock">🔒 закрыт</span>':'<span class="muted small">факт — то, что реально в кассе точки</span>'}</div>
1147
1192
  <div class="bd" style="padding:0"><div class="tbl-wrap"><table>
1148
- <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>
1149
1194
  <th class="num">Расчёт</th><th class="num" style="width:140px">Факт в кассе</th><th class="num">Расхождение</th></tr></thead><tbody>`;
1150
1195
  let tS=0,tI=0,tO=0,tC=0,tF=0,tCf=0,nF=0;
1151
1196
  ST.points.forEach(p=>{ const c=cashCalc(m,p); const fv=(ST.cashFact[m]||{})[p];
@@ -1153,16 +1198,38 @@ function renderCF(C){
1153
1198
  const diff=has?Number(fv)-c.calc:null;
1154
1199
  tS+=c.start; tI+=c.inc; tO+=c.out; tC+=c.calc;
1155
1200
  if(has){ tF+=Number(fv); tCf+=c.calc; nF++; }
1156
- 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>
1157
1205
  <td class="num neg">${c.out?fmt(c.out):'—'}</td><td class="num" style="font-weight:600">${fmt(c.calc)}</td>
1158
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>
1159
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
+ }
1160
1227
  const tD=nF?tF-tCf:null;
1161
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>
1162
1229
  <td class="num">${fmt(tC)}</td><td class="num">${nF?fmt(tF):'—'}</td>
1163
1230
  <td class="num ${tD==null?'':Math.abs(tD)<1?'pos':'neg'}">${tD==null?'—':(tD>0?'+':'')+fmt(tD)}</td></tr>
1164
1231
  </tbody></table></div></div>
1165
- <p class="hint" style="padding:0 15px 12px;margin:0">На начало берётся факт предыдущего месяца. Приход и расход — операции по наличным счетам, привязанные к точке. Итоговое расхождение считается только по точкам, где факт заполнен${nF?` (сейчас ${nF} из ${ST.points.length})`:''}.</p></div>`;
1232
+ <p class="hint" style="padding:0 15px 12px;margin:0">По точкам: «На начало» в первом месяце учёта вводится вручную, дальше подставляется факт предыдущего месяца. Приход и расход — наличные операции и переводы, привязанные к точке. Ниже — наличные счета и карты целиком, по данным всех операций и переводов. Расхождение считается только там, где заполнен факт${nF?` (заполнено ${nF})`:''}.</p></div>`;
1166
1233
 
1167
1234
  /* ── 3. деньги по дням в разрезе статей ── */
1168
1235
  if(agg){ html+=`<div class="note">Таблица по дням доступна при выборе конкретного месяца — сейчас выбран период «${P}».</div>`; }
@@ -1222,7 +1289,7 @@ function renderCF(C){
1222
1289
  /* ── 4. ДДС по месяцам ── */
1223
1290
  const cfM=MONTHS.map(mo=>computeCF('cf_direct',mo));
1224
1291
  const struct=structOf('cf_direct');
1225
- html+=`<div class="panel"><div class="hd"><h3>ДДС по месяцам · ${YEAR}</h3><div class="spacer"></div>
1292
+ html+=`<div class="panel"><div class="hd"><h3>CF по месяцам · ${YEAR}</h3><div class="spacer"></div>
1226
1293
  <span class="muted small">строки — в Конструкторе</span></div>
1227
1294
  <div class="bd" style="padding:0"><div class="tbl-wrap"><table class="wide" style="min-width:${250+13*110}px">
1228
1295
  <colgroup><col style="width:250px">${MONTHS.map(()=>`<col style="width:110px">`).join('')}<col style="width:120px"></colgroup>
@@ -1412,6 +1479,7 @@ function renderOps(C){
1412
1479
 
1413
1480
  const inflow=ops.filter(o=>o.type==='Доход').reduce((s,o)=>s+Math.abs(o.amount),0);
1414
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);
1415
1483
  const chSum={}; CHANNELS.forEach(([k])=>chSum[k]=0);
1416
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); });
1417
1485
 
@@ -1419,15 +1487,15 @@ function renderOps(C){
1419
1487
  if(lk)html+=`<div class="note">🔒 Период закрыт — операции только для просмотра.</div>`;
1420
1488
  html+=`<div class="cards">
1421
1489
  <div class="card"><div class="k">Поступления</div><div class="v pos">${fmt(inflow)} ₽</div>
1422
- <div class="d muted">безнал ${fmt(chSum.bank)} · Яндекс ${fmt(chSum.yandex)} · нал ${fmt(chSum.cash)}</div></div>
1490
+ <div class="d muted">Безналичные ${fmt(chSum.bank)} · Яндекс Еда ${fmt(chSum.yandex)} · Наличные ${fmt(chSum.cash)}</div></div>
1423
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>
1424
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>
1425
- <div class="d muted">${ops.length} из ${base.length} операций</div></div></div>`;
1493
+ <div class="d muted">${ops.length} из ${base.length} операций${trSum?` · переводы ${fmt(trSum)}`:''}</div></div></div>`;
1426
1494
 
1427
1495
  const sel=(cur,list,k)=>`<select onchange="opsFSet('${k}',this.value)"><option value="">— все —</option>${
1428
1496
  list.map(v=>`<option ${cur===v?'selected':''}>${esc(v)}</option>`).join('')}</select>`;
1429
1497
  html+=`<div class="panel"><div class="bd"><div class="filters">
1430
- <label class="f"><span>Тип</span>${sel(f.type,['Доход','Расход'],'type')}</label>
1498
+ <label class="f"><span>Тип</span>${sel(f.type,['Доход','Расход','Перевод'],'type')}</label>
1431
1499
  <label class="f"><span>Точка</span>${sel(f.point,['Общий',...ST.points],'point')}</label>
1432
1500
  <label class="f"><span>Статья</span>${sel(f.cat,allCats,'cat')}</label>
1433
1501
  <label class="f"><span>Счёт</span>${sel(f.acc,ST.accounts.map(a=>a.name),'acc')}</label>
@@ -1449,11 +1517,12 @@ function renderOps(C){
1449
1517
  if(!slice.length)html+=`<tr><td colspan="9"><div class="empty">${base.length?'Ничего не найдено — измените фильтры':'Нет операций за '+P}</div></td></tr>`;
1450
1518
  slice.forEach(o=>{ const emp=o.employeeId&&(ST.employees||[]).find(e=>e.id===o.employeeId);
1451
1519
  const isRev=o.type==='Доход'&&cfGroup(o.category)==='op';
1520
+ const tr=isTransfer(o);
1452
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>
1453
1522
  <td>${esc(o.category)}${emp?` <span class="muted small">· ${esc(emp.name)}</span>`:''}</td>
1454
1523
  <td class="muted small">${isRev?CH_NAME[opChannel(o)]:''}</td>
1455
- <td class="muted small">${esc(o.point||'')}</td><td class="muted small">${esc(o.account||'')}</td>
1456
- <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>
1457
1526
  <td class="muted small">${esc(o.note||o.comment||'')}</td>
1458
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>`; });
1459
1528
  html+=`</tbody></table></div>`;
@@ -1475,22 +1544,34 @@ function opModal(id){ const ed=id?ST.operations.find(o=>o.id===id):null;
1475
1544
  const cats=[...new Set([...ST.structures.general,...ST.structures.point].filter(r=>r.role==='value').map(r=>r.cat))];
1476
1545
  openModal(ed?'Изменить операцию':'Новая операция',`
1477
1546
  <div class="row"><label class="fld" style="flex:1">Дата<input type="date" id="op-date" value="${ed?ed.date:defaultOpDate()}"></label>
1478
- <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>
1479
- <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>
1480
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>
1481
- <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>
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>
1482
1552
  <div class="row"><label class="fld" style="flex:1">Сумма, ₽<input id="op-amt" type="number" value="${ed?Math.abs(ed.amount):''}"></label>
1483
- <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>${
1484
1554
  CHANNELS.map(([k,n])=>`<option value="${k}" ${ed&&ed.ch===k?'selected':''}>${n}</option>`).join('')}</select></label></div>
1485
- <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>
1486
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>
1487
- <label class="fld">Заметка<input id="op-note" value="${ed?esc(ed.note||ed.comment||''):''}" placeholder="комментарий к операции"></label>
1488
- <p class="hint">Канал нужен только для поступлений: он делит выручку на безнал / Яндекс Еду / наличные в CF. «По счёту» — наличный счёт даёт наличные, остальные — безнал.</p>`,
1557
+ <label class="fld">Заметка<input id="op-note" value="${ed?esc(ed.note||ed.comment||''):''}" placeholder="комментарий к операции"></label>`,
1489
1558
  ()=>{ const date=val('op-date'); const month=MONTHS[new Date(date).getMonth()]; if(isLocked(month)){toast('Месяц закрыт');return;}
1490
- const rec={date,month,type:val('op-type'),category:val('op-cat'),point:val('op-point'),account:val('op-acc'),
1491
- amount:Number(val('op-amt'))||0,cash:val('op-cash')==='1',employeeId:val('op-emp')||null,
1492
- ch:val('op-ch')||null,note:val('op-note'),comment:val('op-note')};
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; }
1493
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?'Со счёта':'Счёт';
1494
1575
  }
1495
1576
  function delOp(id){ if(confirm('Удалить операцию?')){ ST.operations=ST.operations.filter(o=>o.id!==id); save(); render(); } }
1496
1577
 
@@ -1644,7 +1725,7 @@ function settStruct(){
1644
1725
  return html;
1645
1726
  }
1646
1727
  function settAccounts(){
1647
- let html=`<div class="note">Счета делятся на <b>банковские</b> и <b>наличные</b>. Порядок стрелками ↑↓ — в этом же порядке счета идут в списках, карточках и таблице по дням.</div>`;
1728
+ let html=`<div class="note">Три типа счетов: <b>банковский</b>, <b>наличные</b> (физическая касса точек) и <b>карта</b>. Наличные и карты попадают в сверку кассы на «CF · прямой», но касса точек считается только по наличным — перевод из кассы на карту уменьшает наличку точки. Порядок стрелками ↑↓ действует везде.</div>`;
1648
1729
  html+=`<div class="panel"><div class="hd"><h3>Начало учёта</h3></div><div class="bd">
1649
1730
  <div class="row" style="align-items:center;gap:10px">
1650
1731
  <label class="fld" style="max-width:220px">Учёт ведётся с месяца
@@ -1652,9 +1733,9 @@ function settAccounts(){
1652
1733
  <p class="hint" style="margin:0;flex:1">Операции более ранних месяцев не влияют на остатки. В колонке «Остаток на начало» укажите деньги на конец последнего несведённого месяца — например, на 31 июля, если учёт начинается с августа.</p>
1653
1734
  </div></div></div>`;
1654
1735
  html+=`<div class="panel"><div class="hd"><h3>Счета</h3><div class="spacer"></div><button class="primary mini" onclick="accModal()">+ Счёт</button></div>
1655
- <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>`;
1656
- 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>
1657
- <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>`);
1658
1739
  html+=`</tbody></table></div></div></div>`;
1659
1740
  return html;
1660
1741
  }
@@ -1758,10 +1839,13 @@ function rowSource(scope,id){ const r=structOf(scope).find(x=>x.id===id); if(!r)
1758
1839
  function accModal(i){ const ed=(i!=null)?ST.accounts[i]:null; const t=ed?accType(ed):'bank';
1759
1840
  openModal(ed?'Изменить счёт':'Новый счёт',`
1760
1841
  <label class="fld">Название<input id="ac-name" value="${ed?esc(ed.name):''}" placeholder="Сбер / Наличные / Точка"></label>
1761
- <label class="fld">Тип<select id="ac-type"><option value="bank" ${t==='bank'?'selected':''}>Банковский счёт</option><option value="cash" ${t==='cash'?'selected':''}>Наличные (касса)</option></select></label>
1762
- <label class="fld">Остаток на начало года, ₽<input id="ac-open" type="number" value="${ed?ed.opening:0}"></label>`,
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>`,
1763
1845
  ()=>{ const name=val('ac-name').trim(); const opening=Number(val('ac-open'))||0; const type=val('ac-type'); if(!name)return;
1764
- 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;}); }
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; }); } }
1765
1849
  else ST.accounts.push({id:uid(),name,opening,type}); save(); render(); });
1766
1850
  }
1767
1851
  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.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Buhanka bakery finance dashboard — single-file web app",
5
5
  "license": "MIT",
6
6
  "files": [