buhanka-finance-app 1.0.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 +1816 -0
- package/package.json +8 -0
package/index.html
ADDED
|
@@ -0,0 +1,1816 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="ru">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Буханка · Финансы</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root{
|
|
9
|
+
--bg:#0f1115; --panel:#171a21; --panel2:#1e222b; --line:#2a2f3a; --line2:#353b48;
|
|
10
|
+
--text:#e7eaf0; --muted:#9aa3b2; --muted2:#6b7280;
|
|
11
|
+
--accent:#e8a33d; --accent2:#c9852a; --accentSoft:rgba(232,163,61,.14);
|
|
12
|
+
--green:#3fb27f; --red:#e0685f; --blue:#5b9bd5;
|
|
13
|
+
--pos:#54c793; --neg:#e97b72;
|
|
14
|
+
--subbg:#1e222b; --grandbg:rgba(232,163,61,.13);
|
|
15
|
+
--radius:10px; --shadow:0 4px 18px rgba(0,0,0,.35);
|
|
16
|
+
}
|
|
17
|
+
body.light{
|
|
18
|
+
--bg:#eef0f4; --panel:#ffffff; --panel2:#f3f5f8; --line:#e4e7ec; --line2:#d3d8e0;
|
|
19
|
+
--text:#1c2230; --muted:#5c6577; --muted2:#98a0af;
|
|
20
|
+
--accent:#d9891f; --accent2:#bd7414; --accentSoft:rgba(217,137,31,.12);
|
|
21
|
+
--green:#1f9d63; --red:#cf4b41; --blue:#3a7cc0;
|
|
22
|
+
--pos:#1f9d63; --neg:#cf4b41;
|
|
23
|
+
--subbg:#eef1f6; --grandbg:rgba(217,137,31,.12);
|
|
24
|
+
--shadow:0 6px 22px rgba(30,40,70,.12);
|
|
25
|
+
}
|
|
26
|
+
*{box-sizing:border-box}
|
|
27
|
+
html,body{margin:0;padding:0;height:100%}
|
|
28
|
+
body{background:var(--bg);color:var(--text);font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;transition:background .2s,color .2s}
|
|
29
|
+
.app{display:flex;min-height:100vh}
|
|
30
|
+
.side{width:236px;flex:none;background:var(--panel);border-right:1px solid var(--line);display:flex;flex-direction:column;position:sticky;top:0;height:100vh}
|
|
31
|
+
.brand{display:flex;align-items:center;gap:10px;padding:18px 18px 14px;border-bottom:1px solid var(--line)}
|
|
32
|
+
.brand .logo{width:34px;height:34px;border-radius:9px;background:linear-gradient(135deg,var(--accent),var(--accent2));display:flex;align-items:center;justify-content:center;font-size:19px}
|
|
33
|
+
.brand b{font-size:15px}.brand small{display:block;color:var(--muted);font-size:11px;font-weight:400}
|
|
34
|
+
.nav{padding:10px 8px;overflow:auto;flex:1}
|
|
35
|
+
.nav .grp{color:var(--muted2);font-size:10.5px;text-transform:uppercase;letter-spacing:.06em;margin:14px 12px 6px}
|
|
36
|
+
.nav a{display:flex;align-items:center;gap:10px;padding:9px 12px;border-radius:8px;color:var(--muted);cursor:pointer;text-decoration:none;font-size:13.5px}
|
|
37
|
+
.nav a:hover{background:var(--panel2);color:var(--text)}
|
|
38
|
+
.nav a.active{background:var(--accent);color:#fff;font-weight:600}
|
|
39
|
+
.nav a .ic{width:18px;text-align:center}
|
|
40
|
+
.side .foot{padding:10px 12px;border-top:1px solid var(--line);color:var(--muted2);font-size:11px;display:flex;gap:6px;flex-wrap:wrap}
|
|
41
|
+
.side .foot button{font-size:11px;padding:5px 8px}
|
|
42
|
+
.main{flex:1;min-width:0;display:flex;flex-direction:column}
|
|
43
|
+
.hdr{position:sticky;top:0;z-index:6;background:var(--panel)}
|
|
44
|
+
.top{display:flex;align-items:center;gap:14px;padding:14px 24px 12px;border-bottom:1px solid var(--line)}
|
|
45
|
+
.top h1{font-size:17px;margin:0}.top .sub{color:var(--muted);font-size:12px;margin-top:2px}
|
|
46
|
+
.spacer{flex:1}
|
|
47
|
+
.periodbar{display:flex;align-items:center;gap:12px;padding:9px 24px;border-bottom:1px solid var(--line);overflow-x:auto}
|
|
48
|
+
.seg{display:inline-flex;background:var(--panel2);border:1px solid var(--line2);border-radius:9px;padding:3px;gap:2px;flex:none}
|
|
49
|
+
.seg button{border:none;background:transparent;color:var(--muted);padding:5px 13px;border-radius:7px;font-size:12.5px}
|
|
50
|
+
.seg button.on{background:var(--accent);color:#fff;font-weight:600}
|
|
51
|
+
.chips{display:flex;gap:5px;align-items:center;flex:none}
|
|
52
|
+
.chip{padding:5px 11px;border-radius:8px;border:1px solid var(--line2);background:var(--panel2);color:var(--muted);font-size:12.5px;cursor:pointer;white-space:nowrap}
|
|
53
|
+
.chip:hover{border-color:var(--accent);color:var(--text)}
|
|
54
|
+
.chip.on{background:var(--accent);color:#fff;border-color:var(--accent);font-weight:600}
|
|
55
|
+
.chip.locked{border-color:var(--accent2)}
|
|
56
|
+
.qsep{width:1px;height:16px;background:var(--line2);margin:0 2px;flex:none}
|
|
57
|
+
.cloudtag{font-size:12px;padding:5px 11px;border-radius:8px;border:1px solid var(--line2);background:var(--panel2);color:var(--muted);cursor:pointer;white-space:nowrap}
|
|
58
|
+
.cloudtag.ok{border-color:var(--green);color:var(--green)}
|
|
59
|
+
.cloudtag.warn{border-color:var(--accent);color:var(--accent)}
|
|
60
|
+
.cloudtag.bad{border-color:var(--red);color:var(--red)}
|
|
61
|
+
.grid2{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:8px 18px}
|
|
62
|
+
.kv{display:flex;justify-content:space-between;gap:10px;padding:6px 0;border-bottom:1px dashed var(--line)}
|
|
63
|
+
.content{padding:22px 24px 60px;max-width:1460px;width:100%}
|
|
64
|
+
select,input,button,textarea{font-family:inherit;font-size:13px;color:var(--text);background:var(--panel2);border:1px solid var(--line2);border-radius:8px;padding:7px 10px;outline:none}
|
|
65
|
+
select:focus,input:focus,textarea:focus{border-color:var(--accent)}
|
|
66
|
+
button{cursor:pointer;background:var(--panel2)}button:hover{border-color:var(--accent)}
|
|
67
|
+
button.primary{background:var(--accent);color:#fff;border-color:var(--accent);font-weight:600}
|
|
68
|
+
button.primary:hover{background:var(--accent2)}
|
|
69
|
+
button.ghost{background:transparent;border-color:var(--line2)}
|
|
70
|
+
button.mini{padding:4px 9px;font-size:12px;border-radius:7px}
|
|
71
|
+
button.icon{padding:4px 8px;background:transparent;border:1px solid var(--line);color:var(--muted)}
|
|
72
|
+
button.icon:hover{color:var(--text);border-color:var(--accent)}
|
|
73
|
+
button:disabled{opacity:.4;cursor:not-allowed}
|
|
74
|
+
input.money{text-align:right;padding:6px 7px}
|
|
75
|
+
label.fld{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--muted)}
|
|
76
|
+
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
|
|
77
|
+
.pill{background:var(--panel2);border:1px solid var(--line2);border-radius:20px;padding:5px 13px;font-size:12.5px;color:var(--muted);cursor:pointer}
|
|
78
|
+
.pill.on{background:var(--accent);color:#fff;border-color:var(--accent);font-weight:600}
|
|
79
|
+
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px;margin-bottom:22px}
|
|
80
|
+
.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:16px}
|
|
81
|
+
.card .k{color:var(--muted);font-size:12px}
|
|
82
|
+
.card .v{font-size:23px;font-weight:700;margin-top:6px;font-variant-numeric:tabular-nums}
|
|
83
|
+
.card .d{font-size:12px;margin-top:4px}
|
|
84
|
+
.panel{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:22px;overflow:hidden}
|
|
85
|
+
.panel .hd{display:flex;align-items:center;gap:10px;padding:13px 16px;border-bottom:1px solid var(--line)}
|
|
86
|
+
.panel .hd h3{margin:0;font-size:14px}
|
|
87
|
+
.panel .bd{padding:16px}
|
|
88
|
+
table{border-collapse:collapse;width:100%}
|
|
89
|
+
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line)}
|
|
90
|
+
th{color:var(--muted);font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.03em;position:sticky;top:0;background:var(--panel);z-index:1}
|
|
91
|
+
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}
|
|
92
|
+
tr:hover td{background:rgba(125,125,125,.05)}
|
|
93
|
+
.pos{color:var(--pos)}.neg{color:var(--neg)}
|
|
94
|
+
.cf-fill-pos{background:rgba(63,178,127,.18)!important;font-weight:600}
|
|
95
|
+
.cf-fill-neg{background:rgba(224,104,95,.18)!important;font-weight:600}
|
|
96
|
+
.tbl-wrap{overflow:auto}
|
|
97
|
+
/* Итоги во всех таблицах — заметнее */
|
|
98
|
+
tr.subtotal>td{font-weight:800;background:var(--subbg);border-top:1px solid var(--line2);border-bottom:1px solid var(--line2)}
|
|
99
|
+
tr.subtotal>td:first-child{color:var(--accent);box-shadow:inset 3px 0 0 var(--accent)}
|
|
100
|
+
/* Проверки */
|
|
101
|
+
.chk-item{display:flex;gap:12px;align-items:flex-start;padding:12px 15px;border:1px solid var(--line);border-left-width:4px;border-radius:10px;margin-bottom:9px;background:var(--panel)}
|
|
102
|
+
.chk-item.err{border-left-color:var(--neg)}
|
|
103
|
+
.chk-item.warn{border-left-color:var(--accent)}
|
|
104
|
+
.chk-item.ok{border-left-color:var(--green);opacity:.82}
|
|
105
|
+
.chk-item .ic{font-size:16px;flex:none;line-height:1.4}
|
|
106
|
+
.chk-item .t{font-weight:700;font-size:13.5px}
|
|
107
|
+
.chk-item .d{color:var(--muted);font-size:12.5px;margin-top:3px}
|
|
108
|
+
/* ---- statement tables (PnL/CF) ---- */
|
|
109
|
+
.pnl{table-layout:fixed}
|
|
110
|
+
.pnl td,.pnl th{padding:7px 14px}
|
|
111
|
+
.pnl th{padding-top:9px;padding-bottom:9px;white-space:nowrap}
|
|
112
|
+
.pnl td.num,.pnl th.num{text-align:right;font-variant-numeric:tabular-nums}
|
|
113
|
+
.pnl .colpct{width:96px}.pnl .colmoney{width:140px}.pnl .coldelta{width:150px}
|
|
114
|
+
.pnl tr:hover td{background:rgba(125,125,125,.04)}
|
|
115
|
+
.pnl tr.header td{font-weight:700;color:var(--accent);background:transparent;font-size:11px;text-transform:uppercase;letter-spacing:.05em;padding-top:15px;padding-bottom:5px;border-bottom:1px solid var(--line2)}
|
|
116
|
+
.pnl tr.value td.nmcell{padding-left:24px}
|
|
117
|
+
.pnl tr.subtotal td{font-weight:700;background:var(--subbg);border-top:1px solid var(--line2);border-bottom:1px solid var(--line2)}
|
|
118
|
+
.pnl tr.subtotal td.nmcell{box-shadow:inset 3px 0 0 var(--accent)}
|
|
119
|
+
.pnl tr.grand td{font-weight:800;background:var(--grandbg);border-top:2px solid var(--accent);border-bottom:2px solid var(--accent);font-size:15px}
|
|
120
|
+
.pnl tr.grand td.nmcell{box-shadow:inset 4px 0 0 var(--accent)}
|
|
121
|
+
.pnl tr.spacer td{height:6px;padding:0;border:none;background:transparent}
|
|
122
|
+
.pnl .rowname{display:flex;align-items:center;gap:8px}
|
|
123
|
+
.pnl .rowname input{background:transparent;border:1px solid transparent;padding:3px 6px;width:100%;min-width:110px;color:inherit;font-weight:inherit;font-size:inherit}
|
|
124
|
+
.pnl .rowname input:hover{border-color:var(--line2)}
|
|
125
|
+
.pnl .rowname input:focus{border-color:var(--accent);background:var(--panel2)}
|
|
126
|
+
.pnl input.money{background:transparent;border:1px solid transparent;text-align:right;width:100%;padding:3px 0;font-variant-numeric:tabular-nums;color:inherit}
|
|
127
|
+
.pnl input.money:hover{border-color:var(--line2)}
|
|
128
|
+
.pnl input.money:focus{border-color:var(--accent);background:var(--panel2)}
|
|
129
|
+
.pnl .pctcell{color:var(--muted);font-size:12px}
|
|
130
|
+
.pnl .ctrls{display:flex;gap:2px;justify-content:flex-end}
|
|
131
|
+
.pnl .ctrls button{padding:2px 6px;font-size:11px;background:transparent;border:1px solid var(--line);color:var(--muted);border-radius:5px}
|
|
132
|
+
.badge{font-size:9.5px;padding:1px 6px;border-radius:10px;border:1px solid var(--line2);color:var(--muted);text-transform:uppercase;letter-spacing:.03em;white-space:nowrap}
|
|
133
|
+
.badge.ops{color:var(--blue);border-color:var(--blue)}.badge.fix{color:var(--accent);border-color:var(--accent)}
|
|
134
|
+
.badge.sch{color:var(--green);border-color:var(--green)}.badge.pnl{color:var(--blue);border-color:var(--blue)}
|
|
135
|
+
.tag{font-size:11px;padding:2px 9px;border-radius:12px;background:var(--panel2);border:1px solid var(--line2);color:var(--muted)}
|
|
136
|
+
.tag.lock{color:var(--accent);border-color:var(--accent)}
|
|
137
|
+
.muted{color:var(--muted)}.small{font-size:12px}
|
|
138
|
+
.hint{color:var(--muted);font-size:12px;margin:0 0 14px}
|
|
139
|
+
.note{background:var(--accentSoft);border:1px solid var(--line2);border-left:3px solid var(--accent);border-radius:8px;padding:9px 13px;font-size:12.5px;color:var(--muted);margin-bottom:14px}
|
|
140
|
+
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:22px}
|
|
141
|
+
@media(max-width:1000px){.grid2{grid-template-columns:1fr}}
|
|
142
|
+
.modal-bg{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:50}
|
|
143
|
+
.modal{background:var(--panel);border:1px solid var(--line2);border-radius:12px;box-shadow:var(--shadow);width:min(520px,92vw);max-height:88vh;overflow:auto}
|
|
144
|
+
.modal .mh{display:flex;align-items:center;padding:15px 18px;border-bottom:1px solid var(--line)}
|
|
145
|
+
.modal .mh h3{margin:0;font-size:15px}
|
|
146
|
+
.modal .mb{padding:18px;display:grid;gap:12px}
|
|
147
|
+
.modal .mf{padding:14px 18px;border-top:1px solid var(--line);display:flex;justify-content:flex-end;gap:10px}
|
|
148
|
+
.bar{height:8px;border-radius:5px;background:var(--panel2);overflow:hidden;margin-top:8px}.bar>i{display:block;height:100%;background:var(--accent)}
|
|
149
|
+
.empty{color:var(--muted2);text-align:center;padding:30px;font-size:13px}
|
|
150
|
+
.legend{display:flex;gap:14px;flex-wrap:wrap;color:var(--muted);font-size:11.5px;margin-top:10px}
|
|
151
|
+
.legend span{display:inline-flex;align-items:center;gap:5px}.sw{width:10px;height:10px;border-radius:3px;display:inline-block}
|
|
152
|
+
.lockgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px}
|
|
153
|
+
.lockcell{border:1px solid var(--line2);border-radius:8px;padding:10px 12px;display:flex;align-items:center;justify-content:space-between;background:var(--panel2)}
|
|
154
|
+
.lockcell.on{border-color:var(--accent);background:var(--accentSoft)}
|
|
155
|
+
.chartbox{width:100%}
|
|
156
|
+
.todoitem{display:flex;align-items:center;gap:13px;padding:13px 15px;border:1px solid var(--line);border-radius:11px;background:var(--panel);margin-bottom:9px;transition:.15s;cursor:pointer}
|
|
157
|
+
.todoitem:hover{border-color:var(--accent)}
|
|
158
|
+
.todoitem.done{opacity:.62;background:var(--panel2)}
|
|
159
|
+
.todoitem .chk{width:23px;height:23px;border-radius:7px;border:2px solid var(--line2);flex:none;display:flex;align-items:center;justify-content:center;font-size:14px;color:transparent}
|
|
160
|
+
.todoitem.done .chk{background:var(--green);border-color:var(--green);color:#fff}
|
|
161
|
+
.todoitem .ttl{flex:1;font-weight:600;font-size:14px}
|
|
162
|
+
.todoitem.done .ttl{text-decoration:line-through;font-weight:400}
|
|
163
|
+
.todoitem .dl{font-size:11px;color:#fff;background:var(--accent);border-radius:10px;padding:2px 9px;white-space:nowrap}
|
|
164
|
+
.todoitem .fq{font-size:11px;color:var(--muted);border:1px solid var(--line2);border-radius:10px;padding:1px 8px;white-space:nowrap}
|
|
165
|
+
.todoitem .once{font-size:10px;color:var(--blue);border:1px solid var(--blue);border-radius:10px;padding:1px 7px}
|
|
166
|
+
.todobar{height:10px;border-radius:6px;background:var(--panel2);overflow:hidden;margin-top:10px}
|
|
167
|
+
.todobar>i{display:block;height:100%;background:linear-gradient(90deg,var(--accent),var(--green));transition:width .3s}
|
|
168
|
+
.daychip{cursor:pointer;border-radius:11px;padding:11px 12px;min-width:94px;text-align:center;border:1px solid var(--line2);background:var(--panel2)}
|
|
169
|
+
.daychip.on{border-color:var(--accent);background:var(--accentSoft)}
|
|
170
|
+
.daychip .ring{width:34px;height:34px;border-radius:50%;margin:6px auto 0;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700}
|
|
171
|
+
.toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:var(--accent);color:#fff;padding:10px 18px;border-radius:8px;font-weight:600;box-shadow:var(--shadow);z-index:80;opacity:0;transition:.25s}
|
|
172
|
+
.toast.show{opacity:1}
|
|
173
|
+
</style>
|
|
174
|
+
</head>
|
|
175
|
+
<body>
|
|
176
|
+
<noscript><div style="padding:30px;font:16px sans-serif">Включите JavaScript в браузере — приложение построено на нём. Откройте файл в Chrome, Safari, Edge или Firefox.</div></noscript>
|
|
177
|
+
<div class="app">
|
|
178
|
+
<aside class="side">
|
|
179
|
+
<div class="brand"><div class="logo">🥖</div><div><b>Буханка</b><small>Управление финансами</small></div></div>
|
|
180
|
+
<nav class="nav" id="nav"></nav>
|
|
181
|
+
<div class="foot">
|
|
182
|
+
<button class="ghost mini" onclick="toggleTheme()" id="themeBtn">◐ Тема</button>
|
|
183
|
+
<button class="ghost mini" onclick="exportData()">↓ Экспорт</button>
|
|
184
|
+
<button class="ghost mini" onclick="importData()">↑ Импорт</button>
|
|
185
|
+
<button class="ghost mini" onclick="go('backup')">↺ Бэкапы</button>
|
|
186
|
+
<span id="verTag" style="align-self:center"></span>
|
|
187
|
+
<button class="ghost mini" onclick="resetData()">Сброс</button>
|
|
188
|
+
</div>
|
|
189
|
+
</aside>
|
|
190
|
+
<main class="main">
|
|
191
|
+
<header class="hdr">
|
|
192
|
+
<div class="top"><div><h1 id="pageTitle">Дашборд</h1><div class="sub" id="pageSub"></div></div><div class="spacer"></div><span class="cloudtag" id="cloudTag" onclick="cloudSync()" title="Состояние синхронизации">…</span></div>
|
|
193
|
+
<div class="periodbar" id="periodbar"></div>
|
|
194
|
+
</header>
|
|
195
|
+
<div class="content" id="content"></div>
|
|
196
|
+
</main>
|
|
197
|
+
</div>
|
|
198
|
+
<div id="modalRoot"></div>
|
|
199
|
+
<div class="toast" id="toast"></div>
|
|
200
|
+
|
|
201
|
+
<script>
|
|
202
|
+
/* ============================================================ БУХАНКА · Финансы v2 ============================================================ */
|
|
203
|
+
const APP_VERSION='3.0';
|
|
204
|
+
const SCHEMA=1; /* версия структуры данных; растёт при доработках */
|
|
205
|
+
const STORE_KEY='buhanka_finance_v9';/* НЕ МЕНЯТЬ НИКОГДА — иначе данные «слетят» при обновлении файла */
|
|
206
|
+
const LEGACY_KEYS=['buhanka_finance_v8','buhanka_finance_v7','buhanka_finance_v6','buhanka_finance_v5'];
|
|
207
|
+
const AUTODL_KEY='buhanka_last_download';
|
|
208
|
+
const BACKUP_KEY='buhanka_backups_v1';
|
|
209
|
+
const BACKUP_MAX=15; /* сколько автоснимков хранить */
|
|
210
|
+
const BACKUP_EVERY_MS=5*60*1000; /* не чаще одного автоснимка в 5 минут */
|
|
211
|
+
const MONTHS=["Январь","Февраль","Март","Апрель","Май","Июнь","Июль","Август","Сентябрь","Октябрь","Ноябрь","Декабрь"];
|
|
212
|
+
const MSHORT=["Янв","Фев","Мар","Апр","Май","Июн","Июл","Авг","Сен","Окт","Ноя","Дек"];
|
|
213
|
+
const QUARTERS={"I квартал":[0,1,2],"II квартал":[3,4,5],"III квартал":[6,7,8],"IV квартал":[9,10,11]};
|
|
214
|
+
const YEAR=2026;
|
|
215
|
+
const uid=()=>Math.random().toString(36).slice(2,9);
|
|
216
|
+
const fmt=(n)=> (n===''||n==null||isNaN(n))?'—':Number(Math.round(n)).toLocaleString('ru-RU');
|
|
217
|
+
const fmtNum=(n)=> (n===''||n==null||isNaN(n))?'':Number(n).toLocaleString('ru-RU',{maximumFractionDigits:2});
|
|
218
|
+
const parseNum=(s)=>{ if(s==null)return null; s=String(s).replace(/[\s ]/g,'').replace(',','.'); if(s==='')return null; const n=Number(s); return isNaN(n)?null:n; };
|
|
219
|
+
const pct=(n)=> (n===''||n==null||!isFinite(n))?'':(n*100).toFixed(1)+'%';
|
|
220
|
+
const safe=(a,b)=> b? a/b : NaN;
|
|
221
|
+
const daysInMonth=(mi)=> new Date(YEAR,mi+1,0).getDate();
|
|
222
|
+
|
|
223
|
+
/* ---------- SEED ---------- */
|
|
224
|
+
function buildStructure(){
|
|
225
|
+
const S=(name,role,sign,source,cat,extra)=>Object.assign({id:uid(),name,role,sign:sign||-1,source:source||'manual',cat:cat||name},extra||{});
|
|
226
|
+
return [
|
|
227
|
+
S("Выручка","value",1,'manual','Выручка'),S("Себестоимость","value",-1,'manual','Себестоимость'),
|
|
228
|
+
S("Маржинальная прибыль","subtotal"),S("Прямые расходы","header"),
|
|
229
|
+
S("Списания","value",-1,'manual','Списания'),S("Эквайринг","value",-1,'manual','Эквайринг'),
|
|
230
|
+
S("Аренда","value",-1,'fixed','Аренда'),S("Субаренда","value",1,'manual','Субаренда'),
|
|
231
|
+
S("Расходные материалы","value",-1,'manual','Расходные материалы'),S("ФОТ","value",-1,'manual','ФОТ'),
|
|
232
|
+
S("Уборщица","value",-1,'manual','Уборщица'),S("Вывоз мусора","value",-1,'fixed','Вывоз мусора'),
|
|
233
|
+
S("Коммунальные платежи","value",-1,'manual','Коммунальные платежи'),S("Связь (интернет/телефон)","value",-1,'fixed','Интернет'),
|
|
234
|
+
S("Музыка","value",-1,'fixed','Музыка'),S("Прочее","value",-1,'manual','Прочее'),
|
|
235
|
+
S("Налоги","value",-1,'tax','Налоги'),S("Процентные расходы","value",-1,'manual','Процентные расходы'),
|
|
236
|
+
S("Валовая прибыль","subtotal"),S("Косвенные расходы","header"),
|
|
237
|
+
S("Бухгалтерия","value",-1,'manual','Бухгалтерия'),S("Страховые взносы","value",-1,'manual','Страховые взносы'),
|
|
238
|
+
S("Прочее (косвенное)","value",-1,'manual','Прочее косв'),S("Управляющий","value",-1,'manual','Управляющий'),
|
|
239
|
+
S("Чистая прибыль","subtotal",1,'manual',null,{grand:true}),
|
|
240
|
+
];
|
|
241
|
+
}
|
|
242
|
+
function buildCFDirect(){
|
|
243
|
+
const R=(name,role,sign,source,cat,extra)=>Object.assign({id:uid(),name,role,sign:sign||-1,source:source||'manual',cat:cat||'',},extra||{});
|
|
244
|
+
return [
|
|
245
|
+
R("Операционная деятельность","header"),
|
|
246
|
+
R("Поступления (выручка)","value",1,'income'),
|
|
247
|
+
R("ФОТ","value",-1,'schedule'),
|
|
248
|
+
R("Аренда","value",-1,'fixed','Аренда'),
|
|
249
|
+
R("Прочие постоянные расходы","value",-1,'fixed_rest'),
|
|
250
|
+
R("Налоги","value",-1,'ops_cat','Налоги'),
|
|
251
|
+
R("Прочие операционные платежи","value",-1,'ops_rest'),
|
|
252
|
+
R("Поток от операционной деятельности","subtotal"),
|
|
253
|
+
R("Финансовая деятельность","header"),
|
|
254
|
+
R("Займы (получение/возврат)","value",1,'manual'),
|
|
255
|
+
R("Дивиденды","value",-1,'manual'),
|
|
256
|
+
R("Поток от финансовой деятельности","subtotal"),
|
|
257
|
+
R("Инвестиционная деятельность","header"),
|
|
258
|
+
R("Оборудование и ремонт","value",-1,'manual'),
|
|
259
|
+
R("Поток от инвестиционной деятельности","subtotal"),
|
|
260
|
+
R("Чистый денежный поток","grand"),
|
|
261
|
+
];
|
|
262
|
+
}
|
|
263
|
+
function buildCFIndirect(){
|
|
264
|
+
const R=(name,role,sign,source,extra)=>Object.assign({id:uid(),name,role,sign:sign||1,source:source||'manual'},extra||{});
|
|
265
|
+
return [
|
|
266
|
+
R("Операционная деятельность","header"),
|
|
267
|
+
R("Чистая прибыль (из PnL)","value",1,'pnl'),
|
|
268
|
+
R("+ Амортизация","value",1,'manual'),
|
|
269
|
+
R("− Δ Дебиторская задолженность","value",-1,'manual'),
|
|
270
|
+
R("− Δ Запасы","value",-1,'manual'),
|
|
271
|
+
R("+ Δ Кредиторская задолженность","value",1,'manual'),
|
|
272
|
+
R("Поток от операционной деятельности","subtotal"),
|
|
273
|
+
R("Финансовая деятельность","header"),
|
|
274
|
+
R("± Займы","value",1,'manual'),
|
|
275
|
+
R("− Дивиденды","value",-1,'manual'),
|
|
276
|
+
R("Поток от финансовой деятельности","subtotal"),
|
|
277
|
+
R("Инвестиционная деятельность","header"),
|
|
278
|
+
R("− Капвложения","value",-1,'manual'),
|
|
279
|
+
R("Поток от инвестиционной деятельности","subtotal"),
|
|
280
|
+
R("ИТОГО денежный поток","grand"),
|
|
281
|
+
];
|
|
282
|
+
}
|
|
283
|
+
function buildTodoRules(){
|
|
284
|
+
const R=(title,freq,extra)=>Object.assign({id:uid(),title,freq,active:true},extra||{});
|
|
285
|
+
return [
|
|
286
|
+
R("Заполнить списания","daily"),
|
|
287
|
+
R("Заполнить остатки (запасы)","daily"),
|
|
288
|
+
R("Проверить инвентаризацию","daily"),
|
|
289
|
+
R("Занести операции по точкам","daily"),
|
|
290
|
+
R("Оплатить поставки","weekly",{weekday:1,time:"18:00"}),
|
|
291
|
+
R("Оплатить поставки","weekly",{weekday:5,time:"12:00"}),
|
|
292
|
+
R("Выплатить зарплату (за 1–16)","monthly",{day:16}),
|
|
293
|
+
R("Выплатить зарплату (остаток за прошлый месяц)","monthly",{day:1}),
|
|
294
|
+
R("Выплатить тело и проценты по кредитам","monthly",{day:1}),
|
|
295
|
+
R("Оплатить аренду","monthly",{day:10}),
|
|
296
|
+
R("Проверить поступившие счета и оплатить","monthly",{day:10}),
|
|
297
|
+
];
|
|
298
|
+
}
|
|
299
|
+
function seed(){
|
|
300
|
+
const points=["Монаховой 90к3","Куприна 38к1","Куприна 30к1","Куприна 7к2","Монаховой 84к3","Скандинавский бульвар"];
|
|
301
|
+
const structures={ general:buildStructure(), point:buildStructure() };
|
|
302
|
+
const cfStructures={ direct:buildCFDirect(), indirect:buildCFIndirect() };
|
|
303
|
+
const idByCat=(s)=>{const m={};s.forEach(r=>{if(r.role==='value')m[r.cat]=r.id;});return m;};
|
|
304
|
+
const gCat=idByCat(structures.general), pCat=idByCat(structures.point);
|
|
305
|
+
const idByCatX=(s)=>{const m={};s.forEach(r=>{if(r.role==='value')m[r.cat]=r.id;});return m;};
|
|
306
|
+
const fx=(point,category,amount,payDay,comment)=>({id:uid(),point,category,amount,payDay,comment,active:true});
|
|
307
|
+
const fixed=[
|
|
308
|
+
fx("Монаховой 90к3","Аренда",165375,"10","Оплата по договору"),fx("Монаховой 90к3","Вывоз мусора",3588,"15","После счёта"),
|
|
309
|
+
fx("Монаховой 90к3","Музыка",1100,"24","Счёт вперёд"),fx("Монаховой 90к3","Интернет",3900,"3","После счёта"),
|
|
310
|
+
fx("Куприна 38к1","Аренда",200000,"25","Оплата по договору"),fx("Куприна 38к1","Вывоз мусора",3109,"15","После счёта"),
|
|
311
|
+
fx("Куприна 38к1","Музыка",1100,"24","Счёт вперёд"),fx("Куприна 38к1","Интернет",1790.8,"5","После счёта"),
|
|
312
|
+
fx("Куприна 30к1","Аренда",156000,"10","Оплата по договору"),fx("Куприна 30к1","Вывоз мусора",2125,"15","После счёта"),
|
|
313
|
+
fx("Куприна 30к1","Музыка",1100,"24","Счёт вперёд"),fx("Куприна 30к1","Интернет",2354,"—","Оплачен год"),
|
|
314
|
+
fx("Куприна 7к2","Аренда",185000,"10","Оплата по договору"),fx("Монаховой 84к3","Аренда",214000,"10","Оплата по договору"),
|
|
315
|
+
];
|
|
316
|
+
const accounts=[{id:uid(),name:"Сбер",opening:161144,type:'bank'},{id:uid(),name:"Сбер 2",opening:50000,type:'bank'},{id:uid(),name:"Точка",opening:30000,type:'bank'},{id:uid(),name:"Наличные",opening:24000,type:'cash'}];
|
|
317
|
+
const employees=[];
|
|
318
|
+
const keyRates=[{id:uid(),date:'2026-01-01',rate:16}];
|
|
319
|
+
const loans=[]; const shareholders=[]; const capShares={};
|
|
320
|
+
const taxConfig={}; points.forEach(p=> taxConfig[p]={mode:'revenue'});
|
|
321
|
+
const data={"Общий":{}}; points.forEach(p=>data[p]={});
|
|
322
|
+
return { points, structures, cfStructures, data, fixed, operations:[],
|
|
323
|
+
timesheet:{}, payAdj:{}, audits:{}, invOpening:{}, checks:{}, inventory:{}, accounts, locks:{}, cfData:{},
|
|
324
|
+
keyRates, loans, shareholders, capShares, employees, taxConfig, taxData:{}, todoRules:buildTodoRules(), todoDone:{}, todoOnce:{},
|
|
325
|
+
ui:{period:"Январь",pnlScope:"Общий",builderScope:"Общий",pnlEdit:false,settingsTab:'struct',theme:"dark"} };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/* ---------- persistence ---------- */
|
|
329
|
+
let ST;
|
|
330
|
+
let STORAGE_OK=true;
|
|
331
|
+
let BOOT_NOTE='', BOOT_FRESH=false;
|
|
332
|
+
function load(){
|
|
333
|
+
/* 1. обычный путь */
|
|
334
|
+
try{ const raw=localStorage.getItem(STORE_KEY); if(raw){ ST=JSON.parse(raw); return; } }
|
|
335
|
+
catch(e){ STORAGE_OK=false; }
|
|
336
|
+
/* 2. основного хранилища нет (сбой, чистка ключа, новая версия файла) — поднимаем свежую копию */
|
|
337
|
+
try{
|
|
338
|
+
const bks=JSON.parse(localStorage.getItem(BACKUP_KEY)||'[]');
|
|
339
|
+
if(bks.length){
|
|
340
|
+
const b=bks[0]; ST=JSON.parse(b.payload);
|
|
341
|
+
BOOT_NOTE='Основные данные не найдены — автоматически восстановлено из копии от '+(b.when||'');
|
|
342
|
+
saveLocal(); return;
|
|
343
|
+
}
|
|
344
|
+
}catch(e){}
|
|
345
|
+
/* 3. копий тоже нет — но, возможно, есть данные прошлых версий файла */
|
|
346
|
+
try{ if(LEGACY_KEYS.some(k=>localStorage.getItem(k))) BOOT_NOTE='LEGACY'; }catch(e){}
|
|
347
|
+
ST=seed(); ST._ts=0; BOOT_FRESH=true; saveLocal();
|
|
348
|
+
}
|
|
349
|
+
function schemaGuard(){
|
|
350
|
+
const cur=Number(ST._schema||0);
|
|
351
|
+
if(cur<SCHEMA){
|
|
352
|
+
if((ST.operations||[]).length || bkCells(ST)) bkCreate('Перед обновлением файла до v'+APP_VERSION,true,true);
|
|
353
|
+
ST._schema=SCHEMA; save();
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
function legacyFound(){ const out=[];
|
|
357
|
+
LEGACY_KEYS.forEach(k=>{ try{ const v=localStorage.getItem(k); if(v) out.push({key:k,size:v.length}); }catch(e){} });
|
|
358
|
+
return out; }
|
|
359
|
+
function legacyLoad(key){
|
|
360
|
+
let raw; try{ raw=localStorage.getItem(key); }catch(e){ return; }
|
|
361
|
+
if(!raw) return;
|
|
362
|
+
if(!confirm('Загрузить данные из хранилища «'+key+'»?\n\nТекущее состояние сначала сохранится копией.')) return;
|
|
363
|
+
bkCreate('Перед загрузкой из '+key,true,true);
|
|
364
|
+
let next; try{ next=JSON.parse(raw); }catch(e){ alert('Данные повреждены'); return; }
|
|
365
|
+
if(!next || !Array.isArray(next.points)){ alert('Это не похоже на данные Буханки'); return; }
|
|
366
|
+
ST=next; migrate(); schemaGuard(); save(); render(); toast('Загружено из '+key);
|
|
367
|
+
}
|
|
368
|
+
function saveLocal(){ if(!STORAGE_OK)return; try{ localStorage.setItem(STORE_KEY,JSON.stringify(ST)); BK_DIRTY=true; }catch(e){ STORAGE_OK=false; } }
|
|
369
|
+
function save(){ ST._ts=Date.now(); LAST_EDIT=ST._ts; saveLocal(); cloudSchedule(); }
|
|
370
|
+
function resetData(){ if(confirm('Сбросить все данные к пустому шаблону?\n\nТекущее состояние сохранится в разделе «Бэкапы» — его можно будет вернуть.')){bkCreate('Перед сбросом',true,true);ST=seed();save();render();toast('Сброшено · копия в «Бэкапах»');} }
|
|
371
|
+
function exportData(tag){
|
|
372
|
+
const b=new Blob([JSON.stringify(ST,null,2)],{type:'application/json'});
|
|
373
|
+
const a=document.createElement('a'); a.href=URL.createObjectURL(b);
|
|
374
|
+
a.download='buhanka-data-'+(tag||new Date().toISOString().slice(0,10))+'.json'; a.click();
|
|
375
|
+
try{ localStorage.setItem(AUTODL_KEY, dlDay()); }catch(e){}
|
|
376
|
+
}
|
|
377
|
+
function dlDay(){ const d=new Date(), p=x=>String(x).padStart(2,'0'); return d.getFullYear()+'-'+p(d.getMonth()+1)+'-'+p(d.getDate()); }
|
|
378
|
+
function lastDownload(){ try{ return localStorage.getItem(AUTODL_KEY)||''; }catch(e){ return ''; } }
|
|
379
|
+
function autoDownload(){
|
|
380
|
+
const today=dlDay();
|
|
381
|
+
if(lastDownload()===today) return;
|
|
382
|
+
if(!(ST.operations||[]).length && !bkCells(ST)) return; /* пустой файл не качаем */
|
|
383
|
+
exportData(today);
|
|
384
|
+
toast('Дневной бэкап сохранён в «Загрузки»');
|
|
385
|
+
}
|
|
386
|
+
function importData(){ const i=document.createElement('input');i.type='file';i.accept='.json';
|
|
387
|
+
i.onchange=e=>{const f=e.target.files[0];const r=new FileReader();r.onload=()=>{ let next; try{ next=JSON.parse(r.result); }catch(err){ alert('Файл не читается'); return; }
|
|
388
|
+
if(!next||!Array.isArray(next.points)||!next.structures){ alert('Это не файл данных Буханки'); return; }
|
|
389
|
+
bkCreate('Перед импортом файла',true,true); ST=next; migrate(); schemaGuard(); save(); render(); toast('Загружено'); };r.readAsText(f);};i.click(); }
|
|
390
|
+
function toast(t){const el=document.getElementById('toast');el.textContent=t;el.classList.add('show');setTimeout(()=>el.classList.remove('show'),1600);}
|
|
391
|
+
function toggleTheme(){ ST.ui.theme= ST.ui.theme==='light'?'dark':'light'; applyTheme(); save(); }
|
|
392
|
+
function applyTheme(){ document.body.classList.toggle('light',ST.ui.theme==='light'); document.getElementById('themeBtn').textContent= ST.ui.theme==='light'?'◑ Тёмная':'◐ Светлая'; }
|
|
393
|
+
function migrate(){ ST.locks=ST.locks||{}; ST.cfData=ST.cfData||{}; ST.ui=ST.ui||{};
|
|
394
|
+
if(!ST.cfStructures)ST.cfStructures={direct:buildCFDirect(),indirect:buildCFIndirect()};
|
|
395
|
+
if(!ST.accounts){ const o=(ST.cf&&ST.cf.opening)||{"Сбер":0}; ST.accounts=Object.entries(o).map(([n,v])=>({id:uid(),name:n,opening:v})); }
|
|
396
|
+
if(!ST.ui.period)ST.ui.period='Январь'; if(!ST.ui.pnlScope)ST.ui.pnlScope='Общий'; if(!ST.ui.builderScope)ST.ui.builderScope='Общий';
|
|
397
|
+
if(ST.ui.pnlEdit===undefined)ST.ui.pnlEdit=false; if(!ST.ui.theme)ST.ui.theme='dark'; if(!ST.ui.settingsTab)ST.ui.settingsTab='struct';
|
|
398
|
+
if(ST.accounts)ST.accounts.forEach(a=>{ if(!a.type)a.type=/налич|касс/i.test(a.name)?'cash':'bank'; });
|
|
399
|
+
if(!ST.keyRates)ST.keyRates=[{id:uid(),fromIdx:0,rate:16}]; if(!ST.loans)ST.loans=[]; if(!ST.shareholders)ST.shareholders=[]; if(!ST.employees)ST.employees=[];
|
|
400
|
+
if(!ST.timesheet)ST.timesheet={}; if(!ST.payAdj)ST.payAdj={}; if(!ST.audits)ST.audits={}; if(!ST.invOpening)ST.invOpening={}; if(!ST.checks)ST.checks={}; if(!ST.inventory)ST.inventory={};
|
|
401
|
+
if(!ST.todoRules)ST.todoRules=buildTodoRules(); if(!ST.todoDone)ST.todoDone={}; if(!ST.todoOnce)ST.todoOnce={};
|
|
402
|
+
if(!ST.taxConfig){ ST.taxConfig={}; (ST.points||[]).forEach(p=>ST.taxConfig[p]={mode:'revenue'}); } if(!ST.taxData)ST.taxData={};
|
|
403
|
+
if(ST.structures){[ST.structures.general,ST.structures.point].forEach(st=>(st||[]).forEach(r=>{ if(r&&r.role==='value'&&r.cat==='Налоги'&&r.source==='manual')r.source='tax'; }));}
|
|
404
|
+
if(!ST.capShares){ ST.capShares={}; (ST.shareholders||[]).forEach(sh=>{ for(let mi=0;mi<12;mi++){ if(mi>=(sh.fromIdx||0)&&(sh.toIdx==null||mi<=sh.toIdx)){ ST.capShares[mi]=ST.capShares[mi]||{}; ST.capShares[mi][sh.id]=Number(sh.share||0); } } }); }
|
|
405
|
+
(ST.shareholders||[]).forEach(sh=>{ delete sh.share; delete sh.fromIdx; delete sh.toIdx; });
|
|
406
|
+
(ST.keyRates||[]).forEach(k=>{ if(!k.date)k.date=`${YEAR}-${String((k.fromIdx||0)+1).padStart(2,'0')}-01`; });
|
|
407
|
+
(ST.shareholders||[]).forEach(s=>{ if(s.fromIdx==null)s.fromIdx=0; }); }
|
|
408
|
+
function accType(a){ return a.type||(/налич|касс/i.test(a.name)?'cash':'bank'); }
|
|
409
|
+
|
|
410
|
+
/* ---------- period ---------- */
|
|
411
|
+
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(); }
|
|
412
|
+
function isAggregate(period){ return !MONTHS.includes(period||ST.ui.period); }
|
|
413
|
+
function primaryMonth(){ return periodMonths()[0]; }
|
|
414
|
+
function periodModeOf(p){ p=p||ST.ui.period; return MONTHS.includes(p)?'month':(QUARTERS[p]?'quarter':'year'); }
|
|
415
|
+
function quarterOf(m){ const i=MONTHS.indexOf(m); return Object.keys(QUARTERS).find(q=>QUARTERS[q].includes(i)); }
|
|
416
|
+
function setPeriod(p){ ST.ui.period=p; save(); render(); }
|
|
417
|
+
function setMode(mode){ const P=ST.ui.period;
|
|
418
|
+
if(mode==='month') ST.ui.period=MONTHS.includes(P)?P:(QUARTERS[P]?MONTHS[QUARTERS[P][0]]:'Январь');
|
|
419
|
+
else if(mode==='quarter') ST.ui.period=QUARTERS[P]?P:(MONTHS.includes(P)?quarterOf(P):'I квартал');
|
|
420
|
+
else ST.ui.period='Год'; save(); render(); }
|
|
421
|
+
|
|
422
|
+
/* ---------- locks (управление только в Конструкторе; квартал/год закрываются автоматически) ---------- */
|
|
423
|
+
function isLocked(month){ return !!(ST.locks&&ST.locks[month]); }
|
|
424
|
+
function periodLocked(period){ const mm=periodMonths(period); return mm.length>0 && mm.every(isLocked); }
|
|
425
|
+
function toggleLockMonth(m){ if(ST.locks[m])delete ST.locks[m]; else ST.locks[m]=true; save(); render(); }
|
|
426
|
+
|
|
427
|
+
/* ---------- navigation ---------- */
|
|
428
|
+
const PAGES=[
|
|
429
|
+
{id:'dash',grp:'Обзор',name:'Дашборд',ic:'▦'},
|
|
430
|
+
{id:'capital',grp:'Обзор',name:'Долг и капитал',ic:'◈'},
|
|
431
|
+
{id:'audit',grp:'Обзор',name:'Аудит точек',ic:'★'},
|
|
432
|
+
{id:'todo',grp:'Обзор',name:'Задачи / регламент',ic:'✔'},
|
|
433
|
+
{id:'checks',grp:'Обзор',name:'Проверки',ic:'⚑'},
|
|
434
|
+
{id:'pnl',grp:'Отчётность',name:'PnL',ic:'▤'},
|
|
435
|
+
{id:'points',grp:'Отчётность',name:'Сравнение точек',ic:'▥'},
|
|
436
|
+
{id:'cf',grp:'Отчётность',name:'CF · прямой',ic:'⇄'},
|
|
437
|
+
{id:'cfi',grp:'Отчётность',name:'CF · обратный',ic:'⇆'},
|
|
438
|
+
{id:'taxes',grp:'Отчётность',name:'Налоги',ic:'%'},
|
|
439
|
+
{id:'loans',grp:'Финансирование',name:'Займы и кредиты',ic:'₽'},
|
|
440
|
+
{id:'ops',grp:'Данные',name:'Операции',ic:'≡'},
|
|
441
|
+
{id:'fixed',grp:'Данные',name:'Постоянные расходы',ic:'⧉'},
|
|
442
|
+
{id:'wcap',grp:'Данные',name:'Запасы',ic:'▧'},
|
|
443
|
+
{id:'sched',grp:'Данные',name:'Расписание и ФОТ',ic:'☷'},
|
|
444
|
+
{id:'recon',grp:'Данные',name:'Сверка PnL ↔ CF',ic:'⚖'},
|
|
445
|
+
{id:'settings',grp:'Система',name:'Конструктор',ic:'⚙'},
|
|
446
|
+
{id:'backup',grp:'Система',name:'Бэкапы',ic:'↺'},
|
|
447
|
+
];
|
|
448
|
+
let CUR='dash';
|
|
449
|
+
function buildNav(){ const nav=document.getElementById('nav');let html='',last=null;
|
|
450
|
+
PAGES.forEach(p=>{ if(p.grp!==last){html+=`<div class="grp">${p.grp}</div>`;last=p.grp;} html+=`<a data-id="${p.id}" onclick="go('${p.id}')"><span class="ic">${p.ic}</span>${p.name}</a>`; });
|
|
451
|
+
nav.innerHTML=html; }
|
|
452
|
+
function go(id){CUR=id;render();}
|
|
453
|
+
|
|
454
|
+
/* ---------- PnL calc ---------- */
|
|
455
|
+
function structOf(scope){ if(scope==='Общий')return ST.structures.general; if(scope==='cf_direct')return ST.cfStructures.direct; if(scope==='cf_indirect')return ST.cfStructures.indirect; return ST.structures.point; }
|
|
456
|
+
function opsSum(month,category,scope){ return ST.operations.filter(o=>o.month===month&&o.category===category&&(scope==='Общий'||o.point===scope)).reduce((s,o)=>s+(o.type==='Доход'?Math.abs(o.amount):-Math.abs(o.amount)),0); }
|
|
457
|
+
function fixedSum(category,scope){ return ST.fixed.filter(f=>f.active&&f.category===category&&(scope==='Общий'||f.point===scope)).reduce((s,f)=>s+Number(f.amount||0),0); }
|
|
458
|
+
function empById(id){ return (ST.employees||[]).find(e=>e.id===id); }
|
|
459
|
+
function primaryPoint(e){ return (e&&e.points&&e.points[0])||null; }
|
|
460
|
+
function empDaysAt(month,empId,point){ const d=ST.timesheet[month]&&ST.timesheet[month][point]&&ST.timesheet[month][point][empId]; return d?Object.values(d).reduce((s,x)=>s+Number(x||0),0):0; }
|
|
461
|
+
function empAdjSum(month,empId){ const a=(ST.payAdj[month]&&ST.payAdj[month][empId])||{}; let s=Number(a.bonus1||0)+Number(a.bonus2||0)-Number(a.penalty1||0)-Number(a.penalty2||0); const e=empById(empId); if(e&&e.isManager)s+=Number(e.managerBonus||0); return s; }
|
|
462
|
+
function empAccrued(month,empId){ let s=0; ST.points.forEach(p=>{ s+=empDaysAt(month,empId,p); }); return s+empAdjSum(month,empId); }
|
|
463
|
+
function empPaid(month,empId){ return ST.operations.filter(o=>o.month===month&&o.employeeId===empId&&o.type==='Расход').reduce((s,o)=>s+Math.abs(o.amount),0); }
|
|
464
|
+
function empDaysHalf(month,empId,firstHalf){ let s=0; ST.points.forEach(p=>{ const d=ST.timesheet[month]&&ST.timesheet[month][p]&&ST.timesheet[month][p][empId]; if(d)Object.entries(d).forEach(([day,a])=>{ if((Number(day)<=16)===firstHalf)s+=Number(a||0); }); }); return s; }
|
|
465
|
+
function schedSum(month,scope){ if(!ST.timesheet[month])return 0; let t=0;
|
|
466
|
+
if(scope==='Общий'){ (ST.employees||[]).forEach(e=>t+=empAccrued(month,e.id)); return t; }
|
|
467
|
+
const pt=ST.timesheet[month][scope]||{}; Object.keys(pt).forEach(id=>{ t+=Object.values(pt[id]).reduce((s,x)=>s+Number(x||0),0); });
|
|
468
|
+
(ST.employees||[]).forEach(e=>{ if(primaryPoint(e)===scope)t+=empAdjSum(month,e.id); }); return t; }
|
|
469
|
+
/* ---- налоги: патент (равномерно) + фактический по доле выручки ---- */
|
|
470
|
+
const TAX_PCT_DEFAULT=3;
|
|
471
|
+
function taxCfg(p){ return (ST.taxConfig&&ST.taxConfig[p])||{mode:'revenue'}; }
|
|
472
|
+
function monthlyPatent(p){ const c=taxCfg(p); if(c.mode!=='patent')return 0; const a=Number(c.patentAmount||0); return c.patentBasis==='year'? a/12 : a/3; }
|
|
473
|
+
function taxPctOf(mo){ const t=ST.taxData&&ST.taxData[mo]; return (t&&t.pct!=null&&t.pct!=='')?Number(t.pct):TAX_PCT_DEFAULT; }
|
|
474
|
+
function taxActualOf(mo){ const t=ST.taxData&&ST.taxData[mo]; return (t&&t.actual!=null&&t.actual!=='')?Number(t.actual):null; }
|
|
475
|
+
function revTaxPoints(){ return ST.points.filter(p=>taxCfg(p).mode!=='patent'); }
|
|
476
|
+
function sumRevTaxRev(mo){ return revTaxPoints().reduce((s,p)=>s+Math.max(0,revFactOf(p,mo)),0); }
|
|
477
|
+
function taxPlanPoint(p,mo){ const c=taxCfg(p); if(c.mode==='patent')return monthlyPatent(p); return Math.max(0,revFactOf(p,mo))*taxPctOf(mo)/100; }
|
|
478
|
+
function taxFactPoint(p,mo){ const c=taxCfg(p); if(c.mode==='patent')return monthlyPatent(p); const act=taxActualOf(mo); if(act==null)return taxPlanPoint(p,mo); const sr=sumRevTaxRev(mo); return sr? act*Math.max(0,revFactOf(p,mo))/sr : 0; }
|
|
479
|
+
function taxFact(scope,mo){ if(scope==='Общий')return ST.points.reduce((s,p)=>s+taxFactPoint(p,mo),0); return taxFactPoint(scope,mo); }
|
|
480
|
+
function taxPlan(scope,mo){ if(scope==='Общий')return ST.points.reduce((s,p)=>s+taxPlanPoint(p,mo),0); return taxPlanPoint(scope,mo); }
|
|
481
|
+
function cellVal(scope,month,rowId,field){ return ST.data[scope]?.[month]?.[rowId]?.[field]; }
|
|
482
|
+
function monthActive(scope,month){
|
|
483
|
+
if(ST.data[scope]&&ST.data[scope][month]&&Object.keys(ST.data[scope][month]).length)return true;
|
|
484
|
+
if(ST.operations.some(o=>o.month===month&&(scope==='Общий'||o.point===scope)))return true;
|
|
485
|
+
const sm=ST.timesheet[month]; if(sm){ if(scope==='Общий'){ if(Object.values(sm).some(pt=>Object.keys(pt).length))return true; } else if(sm[scope]&&Object.keys(sm[scope]).length)return true; }
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
function rowFactMonth(scope,month,row){
|
|
489
|
+
if(row.source==='ops'){ const v=opsSum(month,row.cat,scope); return v!==0?v:(cellVal(scope,month,row.id,'fact')||0); }
|
|
490
|
+
if(row.source==='fixed'){ if(!monthActive(scope,month))return cellVal(scope,month,row.id,'fact')||0; return -fixedSum(row.cat,scope); }
|
|
491
|
+
if(row.source==='schedule'){ const v=schedSum(month,scope); return v?-v:(cellVal(scope,month,row.id,'fact')||0); }
|
|
492
|
+
if(row.source==='tax'){ if(!monthActive(scope,month))return 0; return -taxFact(scope,month); }
|
|
493
|
+
return cellVal(scope,month,row.id,'fact')||0;
|
|
494
|
+
}
|
|
495
|
+
function rowFact(scope,months,row){ return months.reduce((s,mo)=>s+rowFactMonth(scope,mo,row),0); }
|
|
496
|
+
function rowBudget(scope,months,row){ return months.reduce((s,mo)=>s+(cellVal(scope,mo,row.id,'budget')||0),0); }
|
|
497
|
+
function computePnL(scope,period){
|
|
498
|
+
const months=periodMonths(period); const struct=structOf(scope); let runF=0,runB=0; const out=[];
|
|
499
|
+
struct.forEach(row=>{
|
|
500
|
+
if(row.role==='value'){ const f=rowFact(scope,months,row),b=rowBudget(scope,months,row); runF+=f;runB+=b; out.push({row,fact:f,budget:b}); }
|
|
501
|
+
else if(row.role==='subtotal'){ out.push({row,fact:runF,budget:runB,isSub:true}); }
|
|
502
|
+
else out.push({row});
|
|
503
|
+
});
|
|
504
|
+
return out;
|
|
505
|
+
}
|
|
506
|
+
function revFactOf(scope,period){ const r=structOf(scope).find(x=>x.role==='value'&&x.sign===1); return r?rowFact(scope,periodMonths(period),r):0; }
|
|
507
|
+
function revBudgetOf(scope,period){ const r=structOf(scope).find(x=>x.role==='value'&&x.sign===1); return r?rowBudget(scope,periodMonths(period),r):0; }
|
|
508
|
+
function netProfit(scope,period){ const c=computePnL(scope,period); const g=[...c].reverse().find(x=>x.isSub); return g?g.fact:0; }
|
|
509
|
+
|
|
510
|
+
/* ---------- CF calc ---------- */
|
|
511
|
+
const isCashOp=o=>o.cash!==false;
|
|
512
|
+
function activeMonths(period){ return periodMonths(period).filter(mo=>monthActive('Общий',mo)); }
|
|
513
|
+
function incomeSum(period){ const mm=periodMonths(period); return ST.operations.filter(o=>mm.includes(o.month)&&o.type==='Доход'&&isCashOp(o)).reduce((s,o)=>s+Math.abs(o.amount),0); }
|
|
514
|
+
function expenseCatSum(period,cat){ const mm=periodMonths(period); return ST.operations.filter(o=>mm.includes(o.month)&&o.type==='Расход'&&o.category===cat&&isCashOp(o)).reduce((s,o)=>s+Math.abs(o.amount),0); }
|
|
515
|
+
function cfManual(period,rowId){ return (ST.cfData[period]&&ST.cfData[period][rowId])||0; }
|
|
516
|
+
function cfMagnitude(structKey,row,period){
|
|
517
|
+
const am=activeMonths(period).length; const src=row.source;
|
|
518
|
+
if(src==='income') return incomeSum(period);
|
|
519
|
+
if(src==='schedule') return periodMonths(period).reduce((s,mo)=>s+schedSum(mo,'Общий'),0);
|
|
520
|
+
if(src==='fixed') return fixedSum(row.cat,'Общий')*am;
|
|
521
|
+
if(src==='fixed_rest'){ const used=new Set(structOf(structKey).filter(r=>r.source==='fixed').map(r=>r.cat));
|
|
522
|
+
const rest=ST.fixed.filter(f=>f.active&&!used.has(f.category)).reduce((s,f)=>s+Number(f.amount||0),0); return rest*am; }
|
|
523
|
+
if(src==='ops_cat') return expenseCatSum(period,row.cat);
|
|
524
|
+
if(src==='ops_rest'){ const used=new Set(structOf(structKey).filter(r=>r.source==='ops_cat').map(r=>r.cat)); used.add('ФОТ');
|
|
525
|
+
const mm=periodMonths(period); return ST.operations.filter(o=>mm.includes(o.month)&&o.type==='Расход'&&isCashOp(o)&&!used.has(o.category)).reduce((s,o)=>s+Math.abs(o.amount),0); }
|
|
526
|
+
if(src==='pnl') return netProfit('Общий',period);
|
|
527
|
+
if(src==='manual') return cfManual(period,row.id);
|
|
528
|
+
return 0;
|
|
529
|
+
}
|
|
530
|
+
function computeCF(structKey,period){
|
|
531
|
+
const struct=structOf(structKey); let sec=0,total=0; const out=[];
|
|
532
|
+
struct.forEach(row=>{
|
|
533
|
+
if(row.role==='value'){ const mag=cfMagnitude(structKey,row,period); const v=(row.sign||1)*mag; sec+=v; total+=v; out.push({row,val:v,mag}); }
|
|
534
|
+
else if(row.role==='subtotal'){ out.push({row,val:sec,isSub:true}); sec=0; }
|
|
535
|
+
else if(row.role==='grand'){ out.push({row,val:total,isGrand:true}); }
|
|
536
|
+
else { out.push({row}); sec=0; }
|
|
537
|
+
});
|
|
538
|
+
return out;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/* ---------- accounts / cash ---------- */
|
|
542
|
+
function accountBalance(acc){ return Number(acc.opening||0)+ST.operations.reduce((s,o)=> s+((o.account===acc.name&&isCashOp(o))?(o.type==='Доход'?Math.abs(o.amount):-Math.abs(o.amount)):0),0); }
|
|
543
|
+
function totalOpening(){ return ST.accounts.reduce((s,a)=>s+Number(a.opening||0),0); }
|
|
544
|
+
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);
|
|
545
|
+
const outOps=ST.operations.filter(o=>o.month===mo&&o.type==='Расход'&&isCashOp(o)).reduce((s,o)=>s+Math.abs(o.amount),0);
|
|
546
|
+
const fx=monthActive('Общий',mo)?ST.fixed.filter(f=>f.active).reduce((s,f)=>s+Number(f.amount||0),0):0;
|
|
547
|
+
const fot=schedSum(mo,'Общий'); return inflow-outOps-fx-fot; }
|
|
548
|
+
function openingForMonth(mi){ let b=totalOpening(); for(let i=0;i<mi;i++) b+=monthNet(MONTHS[i]); return b; }
|
|
549
|
+
function dailyCF(mi){
|
|
550
|
+
const N=daysInMonth(mi); const today=new Date(); const curY=today.getFullYear(), curM=today.getMonth(), curD=today.getDate();
|
|
551
|
+
let actualUntil; if(YEAR<curY||(YEAR===curY&&mi<curM))actualUntil=N; else if(YEAR===curY&&mi===curM)actualUntil=curD; else actualUntil=0;
|
|
552
|
+
const opening=openingForMonth(mi); let run=0; const rows=[];
|
|
553
|
+
for(let d=1;d<=N;d++){
|
|
554
|
+
const ds=`${YEAR}-${String(mi+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
|
|
555
|
+
const dayOps=ST.operations.filter(o=>o.date===ds&&isCashOp(o));
|
|
556
|
+
const inflow=dayOps.filter(o=>o.type==='Доход').reduce((s,o)=>s+Math.abs(o.amount),0);
|
|
557
|
+
const outActual=dayOps.filter(o=>o.type==='Расход').reduce((s,o)=>s+Math.abs(o.amount),0);
|
|
558
|
+
const isActual=d<=actualUntil;
|
|
559
|
+
const plannedFixed=isActual?0:ST.fixed.filter(f=>f.active&&parseInt(f.payDay)===d).reduce((s,f)=>s+Number(f.amount||0),0);
|
|
560
|
+
const outflow=outActual+plannedFixed;
|
|
561
|
+
const netv=inflow-outflow; run+=netv;
|
|
562
|
+
rows.push({d,ds,inflow,outflow,net:netv,bal:opening+run,actual:isActual});
|
|
563
|
+
}
|
|
564
|
+
return {N,opening,actualUntil,rows};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/* ---------- SVG charts ---------- */
|
|
568
|
+
function fmtAxis(v){ const a=Math.abs(v); if(a>=1e6)return (v/1e6).toFixed(a>=1e7?0:1).replace('.',',').replace(',0','')+' млн'; if(a>=1e3)return Math.round(v/1e3)+' тыс'; return String(Math.round(v)); }
|
|
569
|
+
function niceNum(x,round){ if(!x||!isFinite(x))return 1; const e=Math.floor(Math.log10(Math.abs(x))); const f=Math.abs(x)/Math.pow(10,e); let nf; if(round)nf=f<1.5?1:f<3?2:f<7?5:10; else nf=f<=1?1:f<=2?2:f<=5?5:10; return nf*Math.pow(10,e); }
|
|
570
|
+
function axisScale(mn,mx){ mn=Math.min(0,mn||0); mx=Math.max(0,mx||0); if(mn===0&&mx===0)mx=1; let step=niceNum((mx-mn)/2,true)||1; let a=Math.floor(mn/step)*step,b=Math.ceil(mx/step)*step; let g=0; while((b-a)/step>4&&g++<6){ step*=2; a=Math.floor(mn/step)*step; b=Math.ceil(mx/step)*step; } const ticks=[]; for(let v=a;v<=b+step*1e-6;v+=step)ticks.push(Math.round(v*1e4)/1e4); return {min:a,max:b,ticks}; }
|
|
571
|
+
function svgLine(vals,labels,actualUntil,opts){ opts=opts||{}; const fmtV=opts.fmt||fmtAxis;
|
|
572
|
+
const w=900,h=230,pl=74,pr=18,pt=28,pb=30; const n=vals.length; if(!n)return'';
|
|
573
|
+
const sc=axisScale(Math.min(...vals),Math.max(...vals)); const min=sc.min,max=sc.max,rng=(max-min)||1;
|
|
574
|
+
const X=i=>pl+i*(w-pl-pr)/Math.max(n-1,1); const Y=v=>pt+(max-v)/rng*(h-pt-pb);
|
|
575
|
+
const pts=vals.map((v,i)=>[X(i),Y(v)]);
|
|
576
|
+
const actPts=pts.slice(0,Math.max(actualUntil,1)); const fcPts=pts.slice(Math.max(actualUntil-1,0));
|
|
577
|
+
const poly=a=>a.map(p=>p[0].toFixed(1)+','+p[1].toFixed(1)).join(' '); const y0=Y(0);
|
|
578
|
+
let ticks=''; sc.ticks.forEach(v=>{ const y=Y(v); ticks+=`<line x1="${pl}" y1="${y}" x2="${w-pr}" y2="${y}" stroke="var(--line)" stroke-width="1"/><text x="6" y="${y+4}" fill="var(--muted)" font-size="12">${fmtV(v)}</text>`; });
|
|
579
|
+
const step=Math.ceil(n/13); let dots='',vlab='';
|
|
580
|
+
vals.forEach((v,i)=>{ dots+=`<circle cx="${X(i).toFixed(1)}" cy="${Y(v).toFixed(1)}" r="2.6" fill="var(--accent)"/>`; if(i%step===0)vlab+=`<text x="${X(i).toFixed(1)}" y="${(Y(v)-9).toFixed(1)}" fill="var(--text)" font-size="11" font-weight="600" text-anchor="middle">${fmtV(v)}</text>`; });
|
|
581
|
+
let lab=''; labels.forEach((l,i)=>{ if(i%step===0)lab+=`<text x="${X(i)}" y="${h-8}" fill="var(--muted)" font-size="11" text-anchor="middle">${l}</text>`; });
|
|
582
|
+
return `<svg viewBox="0 0 ${w} ${h}" class="chartbox" preserveAspectRatio="xMidYMid meet">${ticks}
|
|
583
|
+
<line x1="${pl}" y1="${y0}" x2="${w-pr}" y2="${y0}" stroke="var(--line2)" stroke-width="1"/>
|
|
584
|
+
<polyline points="${poly(actPts)}" fill="none" stroke="var(--accent)" stroke-width="2.5"/>
|
|
585
|
+
${actualUntil<n?`<polyline points="${poly(fcPts)}" fill="none" stroke="var(--accent)" stroke-width="2.5" stroke-dasharray="5 4" opacity=".7"/>`:''}
|
|
586
|
+
${dots}${vlab}${lab}</svg>`;
|
|
587
|
+
}
|
|
588
|
+
function svgBars(vals,labels,opts){ opts=opts||{}; const fmtV=opts.fmt||fmtAxis;
|
|
589
|
+
const w=900,h=230,pl=74,pr=14,pt=28,pb=30; const n=vals.length; if(!n)return'';
|
|
590
|
+
const sc=axisScale(Math.min(...vals),Math.max(...vals)); const min=sc.min,max=sc.max,rng=(max-min)||1;
|
|
591
|
+
const bw=(w-pl-pr)/n*0.62; const X=i=>pl+(i+0.5)*(w-pl-pr)/n; const Y=v=>pt+(max-v)/rng*(h-pt-pb); const y0=Y(0);
|
|
592
|
+
const step=Math.ceil(n/13); let bars='',vlab='';
|
|
593
|
+
vals.forEach((v,i)=>{ const x=X(i)-bw/2; const y=Y(Math.max(v,0)); const hh=Math.abs(Y(v)-y0);
|
|
594
|
+
bars+=`<rect x="${x.toFixed(1)}" y="${(v>=0?y:y0).toFixed(1)}" width="${bw.toFixed(1)}" height="${Math.max(hh,1).toFixed(1)}" rx="3" fill="${v>=0?'var(--green)':'var(--red)'}"/>`;
|
|
595
|
+
if(v&&i%step===0)vlab+=`<text x="${X(i).toFixed(1)}" y="${(v>=0?Y(v)-6:Y(v)+14).toFixed(1)}" fill="var(--text)" font-size="11" font-weight="600" text-anchor="middle">${fmtV(v)}</text>`; });
|
|
596
|
+
let ticks=''; sc.ticks.forEach(v=>{ const y=Y(v); ticks+=`<line x1="${pl}" y1="${y}" x2="${w-pr}" y2="${y}" stroke="var(--line)"/><text x="6" y="${y+4}" fill="var(--muted)" font-size="12">${fmtV(v)}</text>`; });
|
|
597
|
+
let lab=''; labels.forEach((l,i)=>{ if(i%step===0)lab+=`<text x="${X(i)}" y="${h-8}" fill="var(--muted)" font-size="11" text-anchor="middle">${l}</text>`; });
|
|
598
|
+
return `<svg viewBox="0 0 ${w} ${h}" class="chartbox">${ticks}<line x1="${pl}" y1="${y0}" x2="${w-pr}" y2="${y0}" stroke="var(--line2)"/>${bars}${vlab}${lab}</svg>`;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/* ============================================================ RENDER ============================================================ */
|
|
602
|
+
function render(){
|
|
603
|
+
applyTheme(); buildNav();
|
|
604
|
+
document.querySelectorAll('#nav a').forEach(a=>a.classList.toggle('active',a.dataset.id===CUR));
|
|
605
|
+
renderPeriodBar();
|
|
606
|
+
document.getElementById('pageTitle').textContent=PAGES.find(p=>p.id===CUR).name;
|
|
607
|
+
({dash:renderDash,capital:renderCapital,audit:renderAudit,todo:renderTodo,checks:renderChecks,pnl:renderPnL,points:renderPoints,cf:renderCF,cfi:renderCFI,recon:renderRecon,taxes:renderTaxes,wcap:renderWcap,loans:renderLoans,ops:renderOps,fixed:renderFixed,sched:renderSched,settings:renderSettings,backup:renderBackup}[CUR])(document.getElementById('content'));
|
|
608
|
+
}
|
|
609
|
+
function renderPeriodBar(){
|
|
610
|
+
const P=ST.ui.period, mode=periodModeOf(P); let chips='';
|
|
611
|
+
if(mode==='month'){ MONTHS.forEach((m,i)=>{ chips+=`<span class="chip ${m===P?'on':''} ${isLocked(m)?'locked':''}" onclick="setPeriod('${m}')">${MSHORT[i]}${isLocked(m)?' 🔒':''}</span>`; if(i%3===2&&i<11)chips+=`<span class="qsep"></span>`; }); }
|
|
612
|
+
else if(mode==='quarter'){ Object.keys(QUARTERS).forEach(q=>{ const lk=periodLocked(q); chips+=`<span class="chip ${q===P?'on':''} ${lk?'locked':''}" onclick="setPeriod('${q}')">${q.split(' ')[0]} кв${lk?' 🔒':''}</span>`; }); }
|
|
613
|
+
else { const lk=periodLocked('Год'); chips+=`<span class="chip ${P==='Год'?'on':''} ${lk?'locked':''}" onclick="setPeriod('Год')">2026 год${lk?' 🔒':''}</span>`; }
|
|
614
|
+
document.getElementById('periodbar').innerHTML=`
|
|
615
|
+
<div class="seg"><button class="${mode==='month'?'on':''}" onclick="setMode('month')">Месяц</button>
|
|
616
|
+
<button class="${mode==='quarter'?'on':''}" onclick="setMode('quarter')">Квартал</button>
|
|
617
|
+
<button class="${mode==='year'?'on':''}" onclick="setMode('year')">Год</button></div>
|
|
618
|
+
<div class="chips">${chips}</div><div class="spacer"></div>
|
|
619
|
+
${periodLocked(P)?'<span class="tag lock">🔒 период закрыт</span>':''}`;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/* ---------- ДАШБОРД ---------- */
|
|
623
|
+
function factByCatOf(scope,P){ const m={}; computePnL(scope,P).forEach(x=>{ if(x.row.role==='value')m[x.row.cat]=(m[x.row.cat]||0)+x.fact; }); return m; }
|
|
624
|
+
function renderDash(C){
|
|
625
|
+
const P=ST.ui.period, mm=periodMonths(P), idx=periodEndIdx(P);
|
|
626
|
+
const rev=revFactOf('Общий',P); const comp=computePnL('Общий',P);
|
|
627
|
+
const marg=comp.find(x=>x.isSub)?.fact||0; const net=netProfit('Общий',P);
|
|
628
|
+
const fc=factByCatOf('Общий',P); const ebitda=net-(fc['Процентные расходы']||0)-(fc['Налоги']||0);
|
|
629
|
+
const cash=ST.accounts.reduce((s,a)=>s+accountBalance(a),0);
|
|
630
|
+
const debt=ST.loans.reduce((s,l)=>s+loanBalanceAt(l,idx),0);
|
|
631
|
+
document.getElementById('pageSub').textContent=`${P} · инвесторский срез · ${ST.points.length} точек`;
|
|
632
|
+
const nc=net>=0?'pos':'neg';
|
|
633
|
+
const chk=checksSummary(P); const prob=chk.err+chk.warn;
|
|
634
|
+
let html='';
|
|
635
|
+
if(prob)html+=`<div class="note" style="border-left-color:${chk.err?'var(--neg)':'var(--accent)'};display:flex;align-items:center;gap:12px;margin-bottom:16px"><span>${chk.err?'⛔':'⚠️'} <b>Проверки:</b> ${chk.err?chk.err+' ошибок · ':''}${chk.warn} предупреждений — что-то не сошлось между блоками.</span><button class="mini ghost" onclick="go('checks')" style="margin-left:auto">Открыть проверки</button></div>`;
|
|
636
|
+
html+=`<div class="cards">
|
|
637
|
+
<div class="card"><div class="k">Выручка</div><div class="v">${fmt(rev)} ₽</div><div class="d muted">${P}</div></div>
|
|
638
|
+
<div class="card"><div class="k">EBITDA</div><div class="v">${fmt(ebitda)} ₽</div><div class="d muted">${pct(safe(ebitda,rev))} от выручки</div></div>
|
|
639
|
+
<div class="card"><div class="k">Чистая прибыль</div><div class="v ${nc}">${fmt(net)} ₽</div><div class="d ${nc}">рентабельность ${pct(safe(net,rev))}</div></div>
|
|
640
|
+
<div class="card"><div class="k">Маржинальность</div><div class="v">${pct(safe(marg,rev))}</div><div class="d muted">марж. прибыль ${fmt(marg)}</div></div>
|
|
641
|
+
<div class="card"><div class="k">Деньги на счетах</div><div class="v">${fmt(cash)} ₽</div><div class="d muted">${ST.accounts.length} счёта</div></div>
|
|
642
|
+
<div class="card"><div class="k">Долговая нагрузка</div><div class="v">${fmt(debt)} ₽</div><div class="d muted">${ST.loans.length} кредита</div></div></div>`;
|
|
643
|
+
const revM=MONTHS.map(m=>revFactOf('Общий',m)); const netM=MONTHS.map(m=>netProfit('Общий',m));
|
|
644
|
+
const margM=MONTHS.map((m,i)=> revM[i]? netM[i]/revM[i]*100 : 0);
|
|
645
|
+
const active=MONTHS.filter(mo=>monthActive('Общий',mo)).length;
|
|
646
|
+
const nets=MONTHS.map(monthNet); let bb=totalOpening(); const bals=MONTHS.map((mo,i)=>{ bb+=nets[i]; return bb; });
|
|
647
|
+
html+=`<div class="panel"><div class="hd"><h3>Выручка по месяцам</h3></div><div class="bd">${svgBars(revM,MSHORT)}</div></div>`;
|
|
648
|
+
html+=`<div class="panel"><div class="hd"><h3>Чистая прибыль по месяцам</h3></div><div class="bd">${svgBars(netM,MSHORT)}</div></div>`;
|
|
649
|
+
html+=`<div class="panel"><div class="hd"><h3>Рентабельность по чистой прибыли, %</h3></div><div class="bd">${svgLine(margM,MSHORT,active,{fmt:v=>Math.round(v)+'%'})}</div></div>`;
|
|
650
|
+
html+=`<div class="panel"><div class="hd"><h3>Денежный поток по месяцам</h3></div><div class="bd">${svgBars(nets,MSHORT)}</div></div>`;
|
|
651
|
+
html+=`<div class="panel"><div class="hd"><h3>Остаток на счетах (нарастающим)</h3></div><div class="bd">${svgLine(bals,MSHORT,active)}</div></div>`;
|
|
652
|
+
const auditP=(p)=>{ const vs=periodMonths(P).map(mo=>ST.audits[mo]&&ST.audits[mo][p]).filter(v=>v!=null); return vs.length?(vs.reduce((s,x)=>s+x,0)/vs.length):null; };
|
|
653
|
+
html+=`<div class="panel"><div class="hd"><h3>Результат по точкам · ${P}</h3><div class="spacer"></div><button class="mini ghost" onclick="go('audit')">Аудит</button></div><div class="bd" style="padding:0"><div class="tbl-wrap"><table>
|
|
654
|
+
<thead><tr><th>Точка</th><th class="num">Выручка</th><th class="num">Чистая приб.</th><th class="num">Рентаб.</th><th class="num">Оценка</th></tr></thead><tbody>`;
|
|
655
|
+
let totR=0,totN=0; const qs=[];
|
|
656
|
+
ST.points.forEach(p=>{ const r=revFactOf(p,P); const nt=netProfit(p,P); const has=r||nt; const q=auditP(p); totR+=r; totN+=nt; if(q!=null)qs.push(q);
|
|
657
|
+
html+=`<tr><td>${p}</td><td class="num">${has?fmt(r):'—'}</td><td class="num ${nt>=0?'pos':'neg'}">${has?fmt(nt):'—'}</td><td class="num muted">${has?pct(safe(nt,r)):'—'}</td><td class="num ${q==null?'muted':(q>=3.5?'pos':q<2.5?'neg':'')}">${q!=null?q.toFixed(1):'—'}</td></tr>`; });
|
|
658
|
+
const qAvg=qs.length?qs.reduce((s,x)=>s+x,0)/qs.length:null;
|
|
659
|
+
html+=`<tr class="subtotal"><td>Итого</td><td class="num">${fmt(totR)}</td><td class="num ${totN>=0?'pos':'neg'}">${fmt(totN)}</td><td class="num">${pct(safe(totN,totR))}</td><td class="num">${qAvg!=null?qAvg.toFixed(1):'—'}</td></tr>`;
|
|
660
|
+
html+=`</tbody></table></div></div></div>`;
|
|
661
|
+
const avgCM=MONTHS.map(mo=>{ let r=0,c=0; ST.points.forEach(p=>{ r+=revFactOf(p,mo); c+=(ST.checks[mo]&&ST.checks[mo][p])||0; }); return c? r/c:0; });
|
|
662
|
+
const chkCM=MONTHS.map(mo=> ST.points.reduce((s,p)=>s+((ST.checks[mo]&&ST.checks[mo][p])||0),0));
|
|
663
|
+
html+=`<div class="panel"><div class="hd"><h3>Количество чеков по месяцам</h3></div><div class="bd">${svgBars(chkCM,MSHORT,{fmt:v=>fmt(v)})}</div></div>`;
|
|
664
|
+
html+=`<div class="panel"><div class="hd"><h3>Средний чек по месяцам</h3></div><div class="bd">${svgLine(avgCM,MSHORT,active,{fmt:v=>fmt(v)+' ₽'})}</div></div>`;
|
|
665
|
+
html+=`<div class="panel"><div class="hd"><h3>Чеки и средний чек по точкам · ${P}</h3><div class="spacer"></div><button class="mini ghost" onclick="go('points')">Ввод</button></div><div class="bd" style="padding:0"><div class="tbl-wrap"><table>
|
|
666
|
+
<thead><tr><th>Точка</th><th class="num">Чеки</th><th class="num">Средний чек</th></tr></thead><tbody>`;
|
|
667
|
+
let tc=0,tr=0; ST.points.forEach(p=>{ const c=checksP(p,P); const r=revFactOf(p,P); tc+=c; tr+=r; html+=`<tr><td>${p}</td><td class="num">${c?fmt(c):'—'}</td><td class="num">${c?fmt(r/c):'—'}</td></tr>`; });
|
|
668
|
+
html+=`<tr class="subtotal"><td>Итого</td><td class="num">${fmt(tc)}</td><td class="num">${tc?fmt(tr/tc):'—'}</td></tr></tbody></table></div></div></div>`;
|
|
669
|
+
C.innerHTML=html;
|
|
670
|
+
}
|
|
671
|
+
function shareOf(shId,mi){ return (ST.capShares&&ST.capShares[mi]&&ST.capShares[mi][shId])||0; }
|
|
672
|
+
function capTotalAt(mi){ return ST.shareholders.reduce((s,sh)=>s+shareOf(sh.id,mi),0); }
|
|
673
|
+
function renderCapital(C){
|
|
674
|
+
const P=ST.ui.period, idx=periodEndIdx(P);
|
|
675
|
+
document.getElementById('pageSub').textContent=`${P} · долг и капитал`;
|
|
676
|
+
const debt=ST.loans.reduce((s,l)=>s+loanBalanceAt(l,idx),0);
|
|
677
|
+
const intY=ST.loans.reduce((s,l)=>s+loanSum(l,MONTHS.slice(0,idx+1),'interest'),0);
|
|
678
|
+
const prinY=ST.loans.reduce((s,l)=>s+loanSum(l,MONTHS.slice(0,idx+1),'principalPay'),0);
|
|
679
|
+
const divPer=dividendsSum(P); const divYear=dividendsSum('Год'); const netYear=netProfit('Общий','Год');
|
|
680
|
+
const cash=ST.accounts.reduce((s,a)=>s+accountBalance(a),0);
|
|
681
|
+
const remInt=ST.loans.reduce((s,l)=>{ const sch=loanSchedule(l); let x=0; for(let m=idx+1;m<12;m++)x+=sch[m].interest; return s+x; },0);
|
|
682
|
+
let html=`<div class="cards">
|
|
683
|
+
<div class="card"><div class="k">Общий долг (остаток тела)</div><div class="v">${fmt(debt)} ₽</div><div class="d muted">на конец ${MSHORT[idx]}</div></div>
|
|
684
|
+
<div class="card"><div class="k">Осталось выплатить тела</div><div class="v">${fmt(debt)} ₽</div><div class="d muted">до конца срока</div></div>
|
|
685
|
+
<div class="card"><div class="k">Осталось процентов</div><div class="v neg">${fmt(remInt)} ₽</div><div class="d muted">будущие проценты</div></div>
|
|
686
|
+
<div class="card"><div class="k">Чистый долг</div><div class="v">${fmt(debt-cash)} ₽</div><div class="d muted">долг − деньги</div></div>
|
|
687
|
+
<div class="card"><div class="k">Погашено тела (год)</div><div class="v">${fmt(prinY)} ₽</div><div class="d muted">проценты ${fmt(intY)}</div></div>
|
|
688
|
+
<div class="card"><div class="k">Дивиденды (год)</div><div class="v">${fmt(divYear)} ₽</div><div class="d muted">${pct(safe(divYear,netYear))} от ЧП</div></div></div>`;
|
|
689
|
+
const capTot=capTotalAt(idx);
|
|
690
|
+
html+=`<div class="panel"><div class="hd"><h3>Акционеры и дивиденды · доли на ${MSHORT[idx]}</h3><div class="spacer"></div><button class="mini ghost" onclick="go('settings');setSettingsTab('capital')">Изменить доли</button></div>
|
|
691
|
+
<div class="bd" style="padding:0"><div class="tbl-wrap"><table>
|
|
692
|
+
<thead><tr><th>Акционер</th><th class="num">Доля</th><th class="num">Дивиденды (${P})</th><th class="num">Дивиденды (год)</th></tr></thead><tbody>`;
|
|
693
|
+
if(!ST.shareholders.length)html+=`<tr><td colspan="4"><div class="empty">Нет акционеров</div></td></tr>`;
|
|
694
|
+
ST.shareholders.forEach(sh=>{ const s=shareOf(sh.id,idx); html+=`<tr><td>${esc(sh.name)}</td><td class="num">${s}%</td><td class="num">${fmt(divPer*s/100)}</td><td class="num">${fmt(divYear*s/100)}</td></tr>`; });
|
|
695
|
+
html+=`<tr class="subtotal"><td>Итого</td><td class="num ${Math.abs(capTot-100)>0.01?'neg':''}">${capTot}%${Math.abs(capTot-100)>0.01?' ⚠':''}</td><td class="num">${fmt(divPer)}</td><td class="num">${fmt(divYear)}</td></tr>
|
|
696
|
+
</tbody></table></div></div></div>`;
|
|
697
|
+
html+=`<div class="panel"><div class="hd"><h3>Кредитный портфель</h3><div class="spacer"></div><button class="mini ghost" onclick="go('loans')">К займам</button></div>
|
|
698
|
+
<div class="bd" style="padding:0"><div class="tbl-wrap"><table>
|
|
699
|
+
<thead><tr><th>Кредит</th><th class="num">Тело</th><th>Ставка</th><th class="num">Остаток</th><th class="num">% упл. (год)</th><th class="num">Тело упл. (год)</th></tr></thead><tbody>`;
|
|
700
|
+
if(!ST.loans.length)html+=`<tr><td colspan="6"><div class="empty">Нет кредитов</div></td></tr>`;
|
|
701
|
+
ST.loans.forEach(l=>{ const ym=MONTHS.slice(0,idx+1); html+=`<tr><td class="small">${esc(l.name)}</td><td class="num">${fmt(l.principal)}</td><td>${keyRateAt(idx)+l.spread}%</td><td class="num">${fmt(loanBalanceAt(l,idx))}</td><td class="num neg">${fmt(loanSum(l,ym,'interest'))}</td><td class="num">${fmt(loanSum(l,ym,'principalPay'))}</td></tr>`; });
|
|
702
|
+
html+=`<tr class="subtotal"><td>Итого</td><td class="num"></td><td></td><td class="num">${fmt(debt)}</td><td class="num neg">${fmt(intY)}</td><td class="num">${fmt(prinY)}</td></tr>
|
|
703
|
+
</tbody></table></div></div></div>`;
|
|
704
|
+
C.innerHTML=html;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/* ---------- Проверки (сверка зависимостей) ---------- */
|
|
708
|
+
function runChecks(P){
|
|
709
|
+
const out=[]; const mm=periodMonths(P); const idx=periodEndIdx(P); const pm=primaryMonth();
|
|
710
|
+
const add=(level,title,detail,page)=>out.push({level,title,detail,page});
|
|
711
|
+
// 1. Постоянные расходы без строки-источника в PnL
|
|
712
|
+
const fixRows=new Set([...ST.structures.point,...ST.structures.general].filter(r=>r.role==='value'&&r.source==='fixed').map(r=>r.cat));
|
|
713
|
+
const miss=[...new Set(ST.fixed.filter(f=>f.active).map(f=>f.category))].filter(c=>!fixRows.has(c));
|
|
714
|
+
if(miss.length) add('err','Постоянные расходы не заведены в PnL',`Статьи «${miss.join(', ')}» есть в реестре, но в PnL нет строки с источником «пост.» — они не попадут в отчёт. Добавьте строку в Конструктор → Структура (источник «Из постоянных расходов»).`,'settings');
|
|
715
|
+
else add('ok','Постоянные расходы подтянуты в PnL','Все активные статьи постоянных расходов имеют строку-источник «пост.» в PnL и тянутся автоматически.','fixed');
|
|
716
|
+
// 2. Операции без статьи в PnL (не разобранные)
|
|
717
|
+
const pnlCats=new Set(ST.structures.general.filter(r=>r.role==='value').map(r=>r.cat));
|
|
718
|
+
const outside=ST.operations.filter(o=>mm.includes(o.month)&&isCashOp(o)&&!pnlCats.has(o.category)&&o.reconStatus!=='nonPnL'&&o.reconStatus!=='inPnL');
|
|
719
|
+
if(outside.length) add('warn','Операции без статьи в PnL',`${outside.length} операц. за период не отражены в PnL и не разобраны. Откройте «Сверка PnL ↔ CF» и примите решение по каждой.`,'recon');
|
|
720
|
+
else add('ok','Операции сверены с PnL','Все денежные операции периода отражены в PnL либо помечены как разобранные.','recon');
|
|
721
|
+
// 3. Общий = сумма точек
|
|
722
|
+
const revG=revFactOf('Общий',P), revPts=ST.points.reduce((s,p)=>s+revFactOf(p,P),0);
|
|
723
|
+
const netG=netProfit('Общий',P), netPts=ST.points.reduce((s,p)=>s+netProfit(p,P),0);
|
|
724
|
+
if(Math.abs(revG-revPts)>1||Math.abs(netG-netPts)>1) add('warn','«Общий» PnL ≠ сумма точек',`Выручка: Общий ${fmt(revG)} vs точки ${fmt(revPts)} (Δ ${fmt(revG-revPts)}). Чистая: Общий ${fmt(netG)} vs точки ${fmt(netPts)} (Δ ${fmt(netG-netPts)}). Проверьте данные.`,'points');
|
|
725
|
+
else add('ok','«Общий» PnL сходится с суммой точек','Выручка и чистая прибыль «Общего» равны сумме по точкам.','points');
|
|
726
|
+
// 4. ФОТ начислено vs выплачено
|
|
727
|
+
const fotAcc=mm.reduce((s,mo)=>s+schedSum(mo,'Общий'),0);
|
|
728
|
+
const fotPaid=mm.reduce((s,mo)=>ST.employees.reduce((a,e)=>a+empPaid(mo,e.id),s),0);
|
|
729
|
+
if(fotAcc&&Math.abs(fotAcc-fotPaid)>1) add('warn','ФОТ: начислено ≠ выплачено',`Начислено ${fmt(fotAcc)}, проведено по CF ${fmt(fotPaid)} (Δ ${fmt(fotAcc-fotPaid)}). Проверьте выплаты и привязку операций к сотрудникам в «Расписание и ФОТ».`,'sched');
|
|
730
|
+
else if(fotAcc) add('ok','ФОТ сходится (начислено = выплачено)','Начисленный по табелю ФОТ совпадает с проведёнными выплатами.','sched');
|
|
731
|
+
// 5. Доли акционеров = 100%
|
|
732
|
+
const shSum=capTotalAt(idx);
|
|
733
|
+
if(ST.shareholders.length&&Math.abs(shSum-100)>0.01) add('warn','Доли акционеров ≠ 100%',`Сумма долей на ${MSHORT[idx]} = ${shSum}%. Поправьте помесячно в Конструктор → Капитал.`,'capital');
|
|
734
|
+
// 6. Запасы: расхождение факта и расчёта
|
|
735
|
+
let invOther=0; ST.points.forEach(p=>{ const inv=ST.inventory[p]||{}; mm.forEach(mo=>{ const mi2=MONTHS.indexOf(mo),N=daysInMonth(mi2); let prev=Number(ST.invOpening[p]||0); for(let d=1;d<=N;d++){ const ds=`${YEAR}-${String(mi2+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`; const r=inv[ds]; if(r){ const start=r.start!=null?r.start:prev; const calc=start+Number(r.purchase||0)-Number(r.cogs||0)-Number(r.writeoff||0); if(r.end!=null){ invOther+=Math.abs(r.end-calc); prev=r.end; } else prev=calc; } } }); });
|
|
736
|
+
if(invOther>0.5) add('warn','Запасы: факт не сходится с расчётом',`Суммарное «Прочее» (расхождение факта и расчёта остатков) за период — ${fmt(invOther)} ₽. Проверьте инвентаризацию на вкладке «Запасы».`,'wcap');
|
|
737
|
+
// 7. Чеки не заполнены там, где есть выручка
|
|
738
|
+
const missChk=ST.points.filter(p=>{ const r=revFactOf(p,pm); const c=(ST.checks[pm]&&ST.checks[pm][p])||0; return r>0&&!c; });
|
|
739
|
+
if(missChk.length) add('warn','Не заполнено количество чеков',`За ${pm} нет чеков по точкам: ${missChk.join(', ')} — средний чек не посчитается. Заполните на «Сравнение точек».`,'points');
|
|
740
|
+
// 8. Налоги: большая задолженность
|
|
741
|
+
let taxLiab=0; for(let i=0;i<=idx;i++){ const mo=MONTHS[i]; const fc=factByCatOf('Общий',mo); taxLiab+=Math.abs(fc['Налоги']||0)-expenseCatSum(mo,'Налоги'); }
|
|
742
|
+
if(Math.abs(taxLiab)>1) add('warn','Налоги: есть задолженность/переплата',`Накопленная разница начислено−уплачено по налогам — ${fmt(taxLiab)} ₽. Детали на вкладке «Налоги».`,'taxes');
|
|
743
|
+
return out;
|
|
744
|
+
}
|
|
745
|
+
function checksSummary(P){ const r=runChecks(P); return {err:r.filter(x=>x.level==='err').length, warn:r.filter(x=>x.level==='warn').length, ok:r.filter(x=>x.level==='ok').length, list:r}; }
|
|
746
|
+
function renderChecks(C){
|
|
747
|
+
const P=ST.ui.period; document.getElementById('pageSub').textContent=`${P} · автопроверка взаимосвязей`;
|
|
748
|
+
const {err,warn,ok,list}=checksSummary(P); const problems=err+warn;
|
|
749
|
+
let html=`<div class="cards">
|
|
750
|
+
<div class="card" style="border-color:${problems?'var(--accent)':'var(--green)'}"><div class="k">Статус отчётности</div><div class="v ${problems?'':'pos'}">${problems?problems+' на проверку':'Всё сходится'}</div><div class="d muted">${P}</div></div>
|
|
751
|
+
<div class="card"><div class="k">Ошибки</div><div class="v ${err?'neg':''}">${err}</div><div class="d muted">критично для отчёта</div></div>
|
|
752
|
+
<div class="card"><div class="k">Предупреждения</div><div class="v">${warn}</div><div class="d muted">проверить вручную</div></div>
|
|
753
|
+
<div class="card"><div class="k">Успешно</div><div class="v pos">${ok}</div><div class="d muted">сходится</div></div></div>`;
|
|
754
|
+
html+=`<div class="note">Здесь автоматически сверяются взаимозависимые блоки. Красное — что-то не попадёт в отчёт (исправить обязательно), жёлтое — стоит проверить руками, зелёное — сходится. Нажмите «Открыть» чтобы перейти туда, где чинить.</div>`;
|
|
755
|
+
const order={err:0,warn:1,ok:2}; list.sort((a,b)=>order[a.level]-order[b.level]);
|
|
756
|
+
list.forEach(c=>{ const ic=c.level==='err'?'⛔':c.level==='warn'?'⚠️':'✅';
|
|
757
|
+
html+=`<div class="chk-item ${c.level}"><div class="ic">${ic}</div><div style="flex:1"><div class="t">${c.title}</div><div class="d">${c.detail}</div></div>${c.page?`<button class="mini ghost" onclick="go('${c.page}')">Открыть</button>`:''}</div>`; });
|
|
758
|
+
C.innerHTML=html;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/* ---------- Задачи / регламент ---------- */
|
|
762
|
+
const WDFULL=['воскресенье','понедельник','вторник','среда','четверг','пятница','суббота'];
|
|
763
|
+
const WDSHORT=['вс','пн','вт','ср','чт','пт','сб'];
|
|
764
|
+
function dueOn(rule,dObj){ if(!rule.active)return false; if(rule.freq==='daily')return true; if(rule.freq==='weekly')return dObj.getDay()===Number(rule.weekday); if(rule.freq==='monthly')return dObj.getDate()===Number(rule.day); return false; }
|
|
765
|
+
function freqLabel(r){ if(r.freq==='daily')return 'ежедневно'; if(r.freq==='weekly')return 'каждый '+WDSHORT[r.weekday]+(r.time?` до ${r.time}`:''); if(r.freq==='monthly')return `${r.day} числа`+(r.time?` до ${r.time}`:''); return ''; }
|
|
766
|
+
function isoDay(d){ return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; }
|
|
767
|
+
function renderTodo(C){
|
|
768
|
+
const today=new Date(); ST.ui.todoDate=ST.ui.todoDate||isoDay(today);
|
|
769
|
+
const dstr=ST.ui.todoDate, dObj=new Date(dstr+'T00:00:00'); const nice=dstr.split('-').reverse().join('.'); const isToday=dstr===isoDay(today);
|
|
770
|
+
document.getElementById('pageSub').textContent=`Чек-лист на день`;
|
|
771
|
+
const due=ST.todoRules.filter(r=>dueOn(r,dObj)); const doneMap=ST.todoDone[dstr]||{};
|
|
772
|
+
const once=(ST.todoOnce&&ST.todoOnce[dstr])||[];
|
|
773
|
+
const total=due.length+once.length; const doneCnt=due.filter(r=>doneMap[r.id]).length+once.filter(o=>o.done).length;
|
|
774
|
+
const pct=total?Math.round(doneCnt/total*100):0;
|
|
775
|
+
let strip='';
|
|
776
|
+
for(let off=-3;off<=3;off++){ const dd=new Date(dObj.getTime()+off*86400000); const ds=isoDay(dd); const dm=ST.todoDone[ds]||{}; const oc=(ST.todoOnce&&ST.todoOnce[ds])||[]; const dn=ST.todoRules.filter(r=>dueOn(r,dd)); const tt=dn.length+oc.length; const dc=dn.filter(r=>dm[r.id]).length+oc.filter(o=>o.done).length; const full=tt&&dc===tt; const sel=off===0; const isTod=ds===isoDay(today);
|
|
777
|
+
strip+=`<div class="daychip ${sel?'on':''}" onclick="setTodoDate('${ds}')" style="flex:none;${sel?'':'opacity:.85'}">
|
|
778
|
+
<div class="muted small">${WDSHORT[dd.getDay()]}${isTod?' • сег':''}</div>
|
|
779
|
+
<div style="font-size:15px;font-weight:700;margin-top:1px">${dd.getDate()}.${String(dd.getMonth()+1).padStart(2,'0')}</div>
|
|
780
|
+
<div class="ring" style="width:28px;height:28px;background:${full?'var(--green)':'var(--panel)'};color:${full?'#fff':'var(--text)'};border:2px solid ${full?'var(--green)':'var(--line2)'}">${dc}/${tt}</div></div>`; }
|
|
781
|
+
let html=`<div class="row" style="align-items:center;gap:8px;margin-bottom:12px;flex-wrap:nowrap">
|
|
782
|
+
<button class="icon" onclick="shiftTodo(-1)" title="Предыдущий день" style="font-size:17px;padding:6px 11px">‹</button>
|
|
783
|
+
<div class="row" style="gap:8px;flex:1;justify-content:center;flex-wrap:nowrap;overflow-x:auto">${strip}</div>
|
|
784
|
+
<button class="icon" onclick="shiftTodo(1)" title="Следующий день" style="font-size:17px;padding:6px 11px">›</button></div>
|
|
785
|
+
<div class="row" style="margin-bottom:16px;gap:8px">
|
|
786
|
+
<button class="mini ${isToday?'primary':'ghost'}" onclick="setTodoDate('${isoDay(today)}')">Сегодня</button>
|
|
787
|
+
<input type="date" value="${dstr}" onchange="setTodoDate(this.value)" title="Перейти к дате">
|
|
788
|
+
<span class="tag">${WDFULL[dObj.getDay()]}, ${nice}${isToday?' · сегодня':''}</span></div>`;
|
|
789
|
+
// progress card
|
|
790
|
+
html+=`<div class="panel"><div class="bd">
|
|
791
|
+
<div class="row" style="justify-content:space-between;align-items:flex-end">
|
|
792
|
+
<div><div class="muted small">Выполнено за день</div><div style="font-size:30px;font-weight:800">${doneCnt} <span class="muted" style="font-size:18px">/ ${total}</span></div></div>
|
|
793
|
+
<div style="font-size:26px;font-weight:800" class="${pct===100&&total?'pos':''}">${pct}%</div></div>
|
|
794
|
+
<div class="todobar"><i style="width:${pct}%"></i></div></div></div>`;
|
|
795
|
+
// quick add
|
|
796
|
+
html+=`<div class="panel"><div class="bd"><div class="row">
|
|
797
|
+
<input id="td-quick" placeholder="Быстрая разовая задача на ${nice}…" style="flex:1;min-width:200px" onkeydown="if(event.key==='Enter')todoOnceAdd('${dstr}')">
|
|
798
|
+
<button class="primary mini" onclick="todoOnceAdd('${dstr}')">+ Добавить</button></div></div></div>`;
|
|
799
|
+
// checklist
|
|
800
|
+
html+=`<div class="panel"><div class="hd"><h3>Задачи на ${nice}</h3><div class="spacer"></div><span class="muted small">регламент настраивается в Конструкторе</span></div><div class="bd">`;
|
|
801
|
+
if(!total)html+=`<div class="empty">На этот день задач нет. Добавьте разовую выше.</div>`;
|
|
802
|
+
const undone=[], donel=[];
|
|
803
|
+
due.slice().sort((a,b)=>(a.time||'99')<(b.time||'99')?-1:1).forEach(r=>{ const d=!!doneMap[r.id]; const item={html:`<div class="todoitem ${d?'done':''}" onclick="todoToggle('${dstr}','${r.id}')">
|
|
804
|
+
<div class="chk">✓</div><div class="ttl">${esc(r.title)}</div><span class="fq">${freqLabel(r)}</span>${r.time?`<span class="dl">до ${r.time}</span>`:''}</div>`}; (d?donel:undone).push(item.html); });
|
|
805
|
+
once.forEach((o,i)=>{ const item=`<div class="todoitem ${o.done?'done':''}"><div class="chk" onclick="todoOnceToggle('${dstr}',${i})">✓</div><div class="ttl" onclick="todoOnceToggle('${dstr}',${i})">${esc(o.title)}</div><span class="once">разовая</span><button class="icon" onclick="todoOnceDel('${dstr}',${i})">✕</button></div>`; (o.done?donel:undone).push(item); });
|
|
806
|
+
html+=undone.join('')+ (donel.length?`<div class="muted small" style="margin:14px 0 8px">Выполнено</div>`+donel.join(''):'');
|
|
807
|
+
html+=`</div></div>`;
|
|
808
|
+
C.innerHTML=html;
|
|
809
|
+
}
|
|
810
|
+
function todoOnceAdd(dstr){ const el=document.getElementById('td-quick'); const t=el?el.value.trim():''; if(!t)return; ST.todoOnce=ST.todoOnce||{}; ST.todoOnce[dstr]=ST.todoOnce[dstr]||[]; ST.todoOnce[dstr].push({id:uid(),title:t,done:false}); save(); render(); }
|
|
811
|
+
function todoOnceToggle(dstr,i){ const a=ST.todoOnce&&ST.todoOnce[dstr]; if(a&&a[i]){ a[i].done=!a[i].done; save(); render(); } }
|
|
812
|
+
function todoOnceDel(dstr,i){ const a=ST.todoOnce&&ST.todoOnce[dstr]; if(a){ a.splice(i,1); save(); render(); } }
|
|
813
|
+
function setTodoDate(v){ ST.ui.todoDate=v; save(); render(); }
|
|
814
|
+
function shiftTodo(n){ const d=new Date((ST.ui.todoDate||isoDay(new Date()))+'T00:00:00'); d.setDate(d.getDate()+n); ST.ui.todoDate=isoDay(d); save(); render(); }
|
|
815
|
+
function todoToggle(dstr,ruleId){ ST.todoDone[dstr]=ST.todoDone[dstr]||{}; if(ST.todoDone[dstr][ruleId])delete ST.todoDone[dstr][ruleId]; else ST.todoDone[dstr][ruleId]=true; save(); render(); }
|
|
816
|
+
function todoRuleToggle(i){ ST.todoRules[i].active=!ST.todoRules[i].active; save(); render(); }
|
|
817
|
+
function todoRuleDel(i){ if(confirm('Удалить задачу из регламента?')){ ST.todoRules.splice(i,1); save(); render(); } }
|
|
818
|
+
function todoRuleModal(i){ const ed=(i!=null)?ST.todoRules[i]:null;
|
|
819
|
+
openModal(ed?'Изменить задачу':'Новая задача регламента',`
|
|
820
|
+
<label class="fld">Название<input id="td-title" value="${ed?esc(ed.title):''}"></label>
|
|
821
|
+
<label class="fld">Периодичность<select id="td-freq" onchange="todoFreqUI(this.value)"><option value="daily" ${!ed||ed.freq==='daily'?'selected':''}>Ежедневно</option><option value="weekly" ${ed&&ed.freq==='weekly'?'selected':''}>Еженедельно</option><option value="monthly" ${ed&&ed.freq==='monthly'?'selected':''}>Ежемесячно</option></select></label>
|
|
822
|
+
<label class="fld" id="td-wdbox" style="${ed&&ed.freq==='weekly'?'':'display:none'}">День недели<select id="td-wd">${WDFULL.map((w,idx)=>`<option value="${idx}" ${ed&&Number(ed.weekday)===idx?'selected':''}>${w}</option>`).join('')}</select></label>
|
|
823
|
+
<label class="fld" id="td-daybox" style="${ed&&ed.freq==='monthly'?'':'display:none'}">День месяца<input id="td-day" type="number" min="1" max="31" value="${ed&&ed.day?ed.day:1}"></label>
|
|
824
|
+
<label class="fld">Дедлайн (время, опц.)<input id="td-time" value="${ed?esc(ed.time||''):''}" placeholder="18:00"></label>`,
|
|
825
|
+
()=>{ const freq=val('td-freq'); const rec={title:val('td-title').trim(),freq,time:val('td-time'),active:ed?ed.active:true};
|
|
826
|
+
if(freq==='weekly')rec.weekday=Number(val('td-wd')); if(freq==='monthly')rec.day=Number(val('td-day'))||1;
|
|
827
|
+
if(!rec.title)return; if(ed)Object.assign(ed,rec); else ST.todoRules.push({id:uid(),...rec}); save(); render(); });
|
|
828
|
+
}
|
|
829
|
+
function todoFreqUI(v){ const w=document.getElementById('td-wdbox'),d=document.getElementById('td-daybox'); if(w)w.style.display=v==='weekly'?'block':'none'; if(d)d.style.display=v==='monthly'?'block':'none'; }
|
|
830
|
+
|
|
831
|
+
/* ---------- Аудит точек ---------- */
|
|
832
|
+
function auditAvg(month){ const a=ST.audits[month]||{}; const vals=ST.points.map(p=>a[p]).filter(v=>v!=null); return vals.length? vals.reduce((s,x)=>s+x,0)/vals.length : null; }
|
|
833
|
+
function renderAudit(C){
|
|
834
|
+
const m=primaryMonth(), lk=isLocked(m);
|
|
835
|
+
document.getElementById('pageSub').textContent=`${m} · ежемесячные аудиты качества`;
|
|
836
|
+
ST.audits[m]=ST.audits[m]||{}; const avg=auditAvg(m);
|
|
837
|
+
let html=`<div class="cards"><div class="card"><div class="k">Средняя оценка · ${m}</div><div class="v">${avg!=null?avg.toFixed(1)+' / 5':'—'}</div><div class="d muted">${ST.points.length} точек</div></div></div>`;
|
|
838
|
+
html+=`<div class="panel"><div class="hd"><h3>Оценки по точкам · ${m}</h3>${lk?'<div class="spacer"></div><span class="tag lock">🔒 закрыт</span>':''}</div><div class="bd" style="padding:0"><div class="tbl-wrap"><table>
|
|
839
|
+
<thead><tr><th>Точка</th><th class="num" style="width:160px">Оценка (0–5)</th><th>Уровень</th></tr></thead><tbody>`;
|
|
840
|
+
ST.points.forEach(p=>{ const v=ST.audits[m][p]; const lvl=v==null?'—':(v>=4.25?'<span class="pos">отлично</span>':v>=3.5?'хорошо':v>=2.5?'средне':'<span class="neg">низко</span>');
|
|
841
|
+
html+=`<tr><td>${p}</td><td class="num"><input class="money" ${lk?'disabled':''} value="${v!=null?v:''}" placeholder="—" onchange="auditSet('${m}','${p}',this.value)" style="width:80px"></td><td class="small">${lvl}</td></tr>`; });
|
|
842
|
+
html+=`</tbody></table></div></div></div>`;
|
|
843
|
+
const avgM=MONTHS.map(mo=>{ const a=auditAvg(mo); return a==null?0:a; });
|
|
844
|
+
html+=`<div class="panel"><div class="hd"><h3>Динамика средней оценки</h3></div><div class="bd">${svgLine(avgM,MSHORT,12)}</div></div>`;
|
|
845
|
+
html+=`<p class="hint">Ставьте оценку каждой точке за месяц. Средняя оценка выводится на Дашборде рядом с финрезультатом точки.</p>`;
|
|
846
|
+
C.innerHTML=html;
|
|
847
|
+
}
|
|
848
|
+
function auditSet(m,p,v){ if(isLocked(m)){toast('Месяц закрыт');render();return;} ST.audits[m]=ST.audits[m]||{}; const n=parseNum(v); if(n==null)delete ST.audits[m][p]; else ST.audits[m][p]=Math.min(5,Math.max(0,n)); save(); render(); }
|
|
849
|
+
|
|
850
|
+
/* ---------- Налоги ---------- */
|
|
851
|
+
function renderTaxes(C){
|
|
852
|
+
const P=ST.ui.period, mm=periodMonths(P), lk=periodLocked(P);
|
|
853
|
+
document.getElementById('pageSub').textContent=`${P} · патент + распределение по доле выручки`;
|
|
854
|
+
const planP=mm.reduce((s,mo)=>s+taxPlan('Общий',mo),0), factP=mm.reduce((s,mo)=>s+taxFact('Общий',mo),0);
|
|
855
|
+
const patentP=mm.reduce((s,mo)=>s+ST.points.filter(p=>taxCfg(p).mode==='patent').reduce((a,p)=>a+monthlyPatent(p),0),0);
|
|
856
|
+
let html=`<div class="cards">
|
|
857
|
+
<div class="card"><div class="k">Налоги план · ${P}</div><div class="v">${fmt(planP)} ₽</div><div class="d muted">патент + % от выручки</div></div>
|
|
858
|
+
<div class="card"><div class="k">Налоги факт · ${P}</div><div class="v">${fmt(factP)} ₽</div><div class="d muted">патент + распределённый факт</div></div>
|
|
859
|
+
<div class="card"><div class="k">В т.ч. патент</div><div class="v">${fmt(patentP)} ₽</div></div></div>`;
|
|
860
|
+
html+=`<div class="note">По части точек — <b>патент</b> (сумма на квартал/год, распределяется равномерно по месяцам). По остальным — налог считается <b>по доле выручки</b>: план = % от выручки, факт = фактический общий налог, распределённый пропорционально выручке точек. Эти суммы идут в строку «Налоги» PnL каждой точки.</div>`;
|
|
861
|
+
// config per point
|
|
862
|
+
html+=`<div class="panel"><div class="hd"><h3>Настройка налога по точкам</h3></div><div class="bd" style="padding:0"><div class="tbl-wrap"><table>
|
|
863
|
+
<thead><tr><th>Точка</th><th style="width:150px">Режим</th><th class="num">Патент, ₽</th><th style="width:130px">База патента</th></tr></thead><tbody>`;
|
|
864
|
+
ST.points.forEach(p=>{ const c=taxCfg(p); const pat=c.mode==='patent';
|
|
865
|
+
html+=`<tr><td>${p}</td>
|
|
866
|
+
<td><select ${lk?'disabled':''} onchange="taxCfgSet('${p}','mode',this.value)"><option value="revenue" ${!pat?'selected':''}>По выручке</option><option value="patent" ${pat?'selected':''}>Патент</option></select></td>
|
|
867
|
+
<td class="num">${pat?`<input class="money" ${lk?'disabled':''} value="${fmtNum(c.patentAmount)}" placeholder="0" onchange="taxCfgSet('${p}','patentAmount',this.value)" style="width:110px">`:'<span class="muted">—</span>'}</td>
|
|
868
|
+
<td>${pat?`<select ${lk?'disabled':''} onchange="taxCfgSet('${p}','patentBasis',this.value)"><option value="quarter" ${c.patentBasis!=='year'?'selected':''}>на квартал</option><option value="year" ${c.patentBasis==='year'?'selected':''}>на год</option></select>`:'<span class="muted">—</span>'}</td></tr>`; });
|
|
869
|
+
html+=`</tbody></table></div></div></div>`;
|
|
870
|
+
// monthly plan% and actual total (revenue points)
|
|
871
|
+
html+=`<div class="panel"><div class="hd"><h3>Помесячно · точки «по выручке»</h3><div class="spacer"></div><span class="muted small">план = % · выручка; факт = общий налог к распределению</span></div>
|
|
872
|
+
<div class="bd" style="padding:0"><div class="tbl-wrap"><table>
|
|
873
|
+
<thead><tr><th>Месяц</th><th class="num">Выручка (по выручке)</th><th class="num" style="width:100px">План, %</th><th class="num">План налог</th><th class="num" style="width:140px">Факт налог (всего)</th></tr></thead><tbody>`;
|
|
874
|
+
MONTHS.forEach(mo=>{ const rev=sumRevTaxRev(mo); if(!rev&&!(ST.taxData[mo])) return; const t=ST.taxData[mo]||{}; const plan=rev*taxPctOf(mo)/100; const inP=mm.includes(mo);
|
|
875
|
+
html+=`<tr class="${inP?'subtotal':'value'}"><td class="${inP?'':'nmcell'}">${mo}</td><td class="num">${fmt(rev)}</td>
|
|
876
|
+
<td class="num"><input class="money" ${lk?'disabled':''} value="${t.pct!=null&&t.pct!==''?t.pct:''}" placeholder="${TAX_PCT_DEFAULT}" onchange="taxDataSet('${mo}','pct',this.value)" style="width:70px"></td>
|
|
877
|
+
<td class="num">${fmt(plan)}</td>
|
|
878
|
+
<td class="num"><input class="money" ${lk?'disabled':''} value="${t.actual!=null&&t.actual!==''?fmtNum(t.actual):''}" placeholder="= план" onchange="taxDataSet('${mo}','actual',this.value)" style="width:120px"></td></tr>`; });
|
|
879
|
+
html+=`</tbody></table></div></div><p class="hint" style="padding:0 16px 12px">«План %» — ставка от выручки (по умолчанию ${TAX_PCT_DEFAULT}%). «Факт налог (всего)» — фактически начисленный общий налог за месяц; если пусто, факт = плану. Он распределяется по точкам пропорционально их выручке.</p></div>`;
|
|
880
|
+
// distribution per point for period
|
|
881
|
+
html+=`<div class="panel"><div class="hd"><h3>Распределение по точкам · ${P}</h3></div><div class="bd" style="padding:0"><div class="tbl-wrap"><table>
|
|
882
|
+
<thead><tr><th>Точка</th><th>Режим</th><th class="num">Выручка</th><th class="num">Доля</th><th class="num">План налог</th><th class="num">Факт налог</th></tr></thead><tbody>`;
|
|
883
|
+
const totRev=ST.points.reduce((s,p)=>s+mm.reduce((a,mo)=>a+Math.max(0,revFactOf(p,mo)),0),0);
|
|
884
|
+
let tPlan=0,tFact=0;
|
|
885
|
+
ST.points.forEach(p=>{ const c=taxCfg(p); const rev=mm.reduce((a,mo)=>a+Math.max(0,revFactOf(p,mo)),0); const pl=mm.reduce((a,mo)=>a+taxPlanPoint(p,mo),0); const fa=mm.reduce((a,mo)=>a+taxFactPoint(p,mo),0); tPlan+=pl; tFact+=fa;
|
|
886
|
+
html+=`<tr><td>${p}</td><td class="small muted">${c.mode==='patent'?'патент':'по выручке'}</td><td class="num">${fmt(rev)}</td><td class="num muted">${pct(safe(rev,totRev))}</td><td class="num">${fmt(pl)}</td><td class="num">${fmt(fa)}</td></tr>`; });
|
|
887
|
+
html+=`<tr class="subtotal"><td>Итого</td><td></td><td class="num">${fmt(totRev)}</td><td class="num">100%</td><td class="num">${fmt(tPlan)}</td><td class="num">${fmt(tFact)}</td></tr></tbody></table></div></div></div>`;
|
|
888
|
+
C.innerHTML=html;
|
|
889
|
+
}
|
|
890
|
+
function taxCfgSet(p,field,v){ ST.taxConfig=ST.taxConfig||{}; ST.taxConfig[p]=ST.taxConfig[p]||{mode:'revenue'}; ST.taxConfig[p][field]= (field==='patentAmount')?(parseNum(v)||0):v; if(field==='mode'&&v==='patent'&&ST.taxConfig[p].patentBasis==null)ST.taxConfig[p].patentBasis='quarter'; save(); render(); }
|
|
891
|
+
function taxDataSet(mo,field,v){ ST.taxData=ST.taxData||{}; ST.taxData[mo]=ST.taxData[mo]||{}; const n=parseNum(v); if(n==null)delete ST.taxData[mo][field]; else ST.taxData[mo][field]=n; save(); render(); }
|
|
892
|
+
|
|
893
|
+
/* ---------- Запасы (по дням) ---------- */
|
|
894
|
+
function renderWcap(C){
|
|
895
|
+
const m=primaryMonth(), mi=MONTHS.indexOf(m), N=daysInMonth(mi), lk=isLocked(m);
|
|
896
|
+
ST.ui.invPoint = ST.points.includes(ST.ui.invPoint)? ST.ui.invPoint : ST.points[0];
|
|
897
|
+
const point=ST.ui.invPoint;
|
|
898
|
+
document.getElementById('pageSub').textContent=`${m} · учёт запасов по дням`;
|
|
899
|
+
ST.inventory[point]=ST.inventory[point]||{};
|
|
900
|
+
let html=`<div class="row" style="margin-bottom:14px">`;
|
|
901
|
+
ST.points.forEach(p=>html+=`<span class="pill ${p===point?'on':''}" onclick="invPoint('${p}')">${p}</span>`); html+=`</div>`;
|
|
902
|
+
if(lk)html+=`<div class="note">🔒 ${m} закрыт — только просмотр.</div>`;
|
|
903
|
+
html+=`<div class="note">Ведите запасы по дням: остаток на начало (авто = конец прошлого дня), закупка, себестоимость (ушло в продажу), списания и фактический остаток на конец. Если факт ≠ расчёту — разница уходит в «Прочее».</div>`;
|
|
904
|
+
let sumPur=0,sumCogs=0,sumWof=0,sumOther=0; let prevEnd=Number(ST.invOpening[point]||0);
|
|
905
|
+
html+=`<div class="panel"><div class="hd"><h3>Запасы · ${point} · ${m}</h3><div class="spacer"></div><span class="tag">вход. остаток <input class="money" ${lk?'disabled':''} value="${fmtNum(ST.invOpening[point]||0)}" onchange="invOpenSet('${point}',this.value)" style="width:90px"> ₽</span></div>
|
|
906
|
+
<div class="bd" style="padding:0"><div class="tbl-wrap"><table style="font-size:12.5px">
|
|
907
|
+
<thead><tr><th>День</th><th class="num">Начало</th><th class="num">Закупка</th><th class="num">Себестоим.</th><th class="num">Списания</th><th class="num">Расч. конец</th><th class="num">Факт конец</th><th class="num">Прочее (Δ)</th></tr></thead><tbody>`;
|
|
908
|
+
for(let d=1;d<=N;d++){ const ds=`${YEAR}-${String(mi+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`; const r=ST.inventory[point][ds]||{};
|
|
909
|
+
const start=r.start!=null?r.start:prevEnd; const pur=Number(r.purchase||0),cogs=Number(r.cogs||0),wof=Number(r.writeoff||0);
|
|
910
|
+
const calc=start+pur-cogs-wof; const endF=r.end; const other=endF!=null?(endF-calc):0;
|
|
911
|
+
sumPur+=pur; sumCogs+=cogs; sumWof+=wof; sumOther+=other;
|
|
912
|
+
const inp=(f,v)=>`<input class="money" ${lk?'disabled':''} value="${(v!=null&&v!=='')?fmtNum(v):''}" placeholder="0" onchange="invDaySet('${point}','${ds}','${f}',this.value)" style="width:72px">`;
|
|
913
|
+
html+=`<tr><td class="small">${String(d).padStart(2,'0')}.${String(mi+1).padStart(2,'0')}</td>
|
|
914
|
+
<td class="num">${r.start!=null?inp('start',r.start):`<span class="muted">${fmt(start)}</span>`}</td>
|
|
915
|
+
<td class="num">${inp('purchase',r.purchase)}</td><td class="num">${inp('cogs',r.cogs)}</td><td class="num">${inp('writeoff',r.writeoff)}</td>
|
|
916
|
+
<td class="num muted">${fmt(calc)}</td><td class="num">${inp('end',r.end)}</td>
|
|
917
|
+
<td class="num ${Math.abs(other)>0.5?'neg':'muted'}">${endF!=null?fmt(other):'—'}</td></tr>`;
|
|
918
|
+
prevEnd = endF!=null? endF : calc;
|
|
919
|
+
}
|
|
920
|
+
html+=`<tr class="subtotal"><td>Итого</td><td></td><td class="num">${fmt(sumPur)}</td><td class="num">${fmt(sumCogs)}</td><td class="num">${fmt(sumWof)}</td><td colspan="2"></td><td class="num ${Math.abs(sumOther)>0.5?'neg':''}">${fmt(sumOther)}</td></tr>`;
|
|
921
|
+
html+=`</tbody></table></div></div></div>`;
|
|
922
|
+
html+=`<div class="cards">
|
|
923
|
+
<div class="card"><div class="k">Остаток на конец ${m}</div><div class="v">${fmt(prevEnd)} ₽</div><div class="d muted">${point}</div></div>
|
|
924
|
+
<div class="card"><div class="k">Закупки за месяц</div><div class="v">${fmt(sumPur)} ₽</div></div>
|
|
925
|
+
<div class="card"><div class="k">Себестоимость</div><div class="v">${fmt(sumCogs)} ₽</div></div>
|
|
926
|
+
<div class="card"><div class="k">Списания</div><div class="v">${fmt(sumWof)} ₽</div></div>
|
|
927
|
+
<div class="card" style="border-color:${Math.abs(sumOther)>0.5?'var(--accent)':'var(--line)'}"><div class="k">Прочее (расхождение)</div><div class="v ${Math.abs(sumOther)>0.5?'neg':''}">${fmt(sumOther)} ₽</div></div></div>`;
|
|
928
|
+
C.innerHTML=html;
|
|
929
|
+
}
|
|
930
|
+
function invPoint(p){ ST.ui.invPoint=p; save(); render(); }
|
|
931
|
+
function invOpenSet(point,v){ ST.invOpening[point]=parseNum(v)||0; save(); render(); }
|
|
932
|
+
function invDaySet(point,ds,field,v){ const mo=MONTHS[new Date(ds).getMonth()]; if(isLocked(mo)){toast('Месяц закрыт');render();return;} ST.inventory[point]=ST.inventory[point]||{}; ST.inventory[point][ds]=ST.inventory[point][ds]||{}; const n=parseNum(v); if(n==null)delete ST.inventory[point][ds][field]; else ST.inventory[point][ds][field]=n; save(); render(); }
|
|
933
|
+
|
|
934
|
+
/* ---------- PnL ---------- */
|
|
935
|
+
function renderPnL(C){
|
|
936
|
+
const P=ST.ui.period, scope=ST.ui.pnlScope, agg=isAggregate(P), locked=periodLocked(P);
|
|
937
|
+
const canEdit=ST.ui.pnlEdit&&!agg&&!locked;
|
|
938
|
+
const comp=computePnL(scope,P); const revF=revFactOf(scope,P), revB=revBudgetOf(scope,P);
|
|
939
|
+
document.getElementById('pageSub').textContent=`${scope} · ${P}`;
|
|
940
|
+
let pills=`<div class="row" style="margin-bottom:14px"><span class="pill ${scope==='Общий'?'on':''}" onclick="setScope('Общий')">Общий</span>`;
|
|
941
|
+
ST.points.forEach(p=>pills+=`<span class="pill ${scope===p?'on':''}" onclick="setScope('${p}')">${p}</span>`); pills+=`</div>`;
|
|
942
|
+
let ctrl = agg?`<span class="tag">Сводный период · просмотр</span>`: locked?`<span class="tag lock">🔒 Период закрыт</span>`
|
|
943
|
+
:`<button class="mini ${ST.ui.pnlEdit?'primary':'ghost'}" onclick="togglePnlEdit()">${ST.ui.pnlEdit?'✓ Правка включена':'✎ Править цифры'}</button>`;
|
|
944
|
+
let html=pills+`<div class="note">Состав и структуру строк меняйте во вкладке <b>«Конструктор»</b>. Здесь — только цифры и просмотр.${locked?' <b>Период закрыт.</b>':''}</div>`;
|
|
945
|
+
html+=`<div class="panel"><div class="hd"><h3>PnL · ${scope}</h3><div class="spacer"></div>${ctrl}</div>
|
|
946
|
+
<div class="bd" style="padding:0"><div class="tbl-wrap"><table class="pnl">
|
|
947
|
+
<colgroup><col><col class="colmoney"><col class="colpct"><col class="colmoney"><col class="colpct"><col class="coldelta"></colgroup>
|
|
948
|
+
<thead><tr><th>Статья</th><th class="num">Бюджет</th><th class="num">% выр.</th><th class="num">Факт</th><th class="num">% выр.</th><th class="num">Δ факт−бюдж</th></tr></thead><tbody>`;
|
|
949
|
+
const dcell=(b,f)=>{ if(!b&&!f)return'—'; const d=f-b; return `<span class="${d>=0?'pos':'neg'}">${d>=0?'+':''}${fmt(d)}</span>`; };
|
|
950
|
+
comp.forEach(x=>{ const row=x.row;
|
|
951
|
+
if(row.role==='spacer'){ html+=`<tr class="spacer"><td colspan="6"></td></tr>`; return; }
|
|
952
|
+
if(row.role==='header'){ html+=`<tr class="header"><td colspan="6" class="nmcell">${esc(row.name)}</td></tr>`; return; }
|
|
953
|
+
if(row.role==='subtotal'){ html+=`<tr class="${row.grand?'grand':'subtotal'}"><td class="nmcell">${esc(row.name)}</td>
|
|
954
|
+
<td class="num">${fmt(x.budget)}</td><td class="num pctcell">${pct(safe(x.budget,revB))}</td>
|
|
955
|
+
<td class="num">${fmt(x.fact)}</td><td class="num pctcell">${pct(safe(x.fact,revF))}</td>
|
|
956
|
+
<td class="num">${dcell(x.budget,x.fact)}</td></tr>`; return; }
|
|
957
|
+
const auto=row.source!=='manual'; const bud=cellVal(scope,primaryMonth(),row.id,'budget'), fac=cellVal(scope,primaryMonth(),row.id,'fact');
|
|
958
|
+
const budCell=canEdit?`<input class="money" value="${fmtNum(bud)}" placeholder="0" onchange="editCell('${scope}','${row.id}','budget',this.value)">`:fmt(x.budget);
|
|
959
|
+
const factCell=auto?`<span title="Автоматически из источника">${fmt(x.fact)}</span>`
|
|
960
|
+
:(canEdit?`<input class="money" value="${fmtNum(fac)}" placeholder="0" onchange="editCell('${scope}','${row.id}','fact',this.value)">`:`<span>${fmt(x.fact)}</span>`);
|
|
961
|
+
html+=`<tr class="value"><td class="nmcell"><div class="rowname"><span>${esc(row.name)}</span></div></td>
|
|
962
|
+
<td class="num">${budCell}</td><td class="num pctcell">${pct(safe(x.budget,revB))}</td>
|
|
963
|
+
<td class="num">${factCell}</td><td class="num pctcell">${pct(safe(x.fact,revF))}</td>
|
|
964
|
+
<td class="num">${dcell(x.budget,x.fact)}</td></tr>`;
|
|
965
|
+
});
|
|
966
|
+
html+=`</tbody></table></div></div></div>`;
|
|
967
|
+
C.innerHTML=html;
|
|
968
|
+
}
|
|
969
|
+
function srcBadge(row){ if(row.source==='ops')return`<span class="badge ops">операции</span>`; if(row.source==='fixed')return`<span class="badge fix">пост.</span>`; if(row.source==='schedule')return`<span class="badge sch">ФОТ</span>`; if(row.source==='pnl')return`<span class="badge pnl">из PnL</span>`; return''; }
|
|
970
|
+
function setScope(s){ ST.ui.pnlScope=s; save(); render(); }
|
|
971
|
+
function togglePnlEdit(){ ST.ui.pnlEdit=!ST.ui.pnlEdit; save(); render(); }
|
|
972
|
+
function editCell(scope,id,field,v){ const m=primaryMonth(); if(isLocked(m)){toast('Период закрыт');render();return;}
|
|
973
|
+
ST.data[scope]=ST.data[scope]||{}; ST.data[scope][m]=ST.data[scope][m]||{}; ST.data[scope][m][id]=ST.data[scope][m][id]||{}; ST.data[scope][m][id][field]=parseNum(v); save(); render(); }
|
|
974
|
+
|
|
975
|
+
/* ---------- Сравнение точек ---------- */
|
|
976
|
+
function renderPoints(C){
|
|
977
|
+
const P=ST.ui.period; const useFact=periodLocked(P);
|
|
978
|
+
document.getElementById('pageSub').textContent=`${P} · ${useFact?'период закрыт — факт':'период открыт — бюджет'}`;
|
|
979
|
+
const struct=ST.structures.point; const comps={}; ST.points.forEach(p=>comps[p]=computePnL(p,P));
|
|
980
|
+
const colw=130; const minw=240+(ST.points.length+1)*colw;
|
|
981
|
+
let html=`<div class="panel"><div class="hd"><h3>Сравнение точек · ${P}</h3><div class="spacer"></div><span class="tag ${useFact?'lock':''}">${useFact?'значения: факт':'значения: бюджет'}</span></div><div class="bd" style="padding:0"><div class="tbl-wrap"><table class="pnl" style="table-layout:fixed;min-width:${minw}px">
|
|
982
|
+
<colgroup><col style="width:240px">${ST.points.map(()=>`<col style="width:${colw}px">`).join('')}<col style="width:${colw+10}px"></colgroup>
|
|
983
|
+
<thead><tr><th>Статья</th>${ST.points.map(p=>`<th class="num" style="white-space:normal">${p.replace('Скандинавский','Сканд.')}</th>`).join('')}<th class="num">Итого</th></tr></thead><tbody>`;
|
|
984
|
+
struct.forEach((row,i)=>{
|
|
985
|
+
if(row.role==='spacer'){html+=`<tr class="spacer"><td colspan="${ST.points.length+2}"></td></tr>`;return;}
|
|
986
|
+
if(row.role==='header'){html+=`<tr class="header"><td colspan="${ST.points.length+2}" class="nmcell">${esc(row.name)}</td></tr>`;return;}
|
|
987
|
+
const cls=row.grand?'grand':row.role==='subtotal'?'subtotal':'value'; let tot=0,cells='';
|
|
988
|
+
ST.points.forEach(p=>{const x=comps[p][i]; const v=x?(useFact?x.fact:x.budget):0; tot+=v; cells+=`<td class="num">${v?fmt(v):'—'}</td>`;});
|
|
989
|
+
html+=`<tr class="${cls}"><td class="nmcell">${esc(row.name)}</td>${cells}<td class="num">${tot?fmt(tot):'—'}</td></tr>`;
|
|
990
|
+
});
|
|
991
|
+
html+=`</tbody></table></div></div></div>`;
|
|
992
|
+
// чеки и средний чек
|
|
993
|
+
const m=primaryMonth(), lk=isLocked(m);
|
|
994
|
+
html+=`<div class="panel"><div class="hd"><h3>Чеки и средний чек · ${m}</h3>${lk?'<div class="spacer"></div><span class="tag lock">🔒 закрыт</span>':''}</div><div class="bd" style="padding:0"><div class="tbl-wrap"><table>
|
|
995
|
+
<thead><tr><th>Точка</th><th class="num">Выручка</th><th class="num" style="width:150px">Кол-во чеков</th><th class="num">Средний чек</th></tr></thead><tbody>`;
|
|
996
|
+
let tChk=0,tRev=0;
|
|
997
|
+
ST.points.forEach(p=>{ const r=revFactOf(p,m); const c=(ST.checks[m]&&ST.checks[m][p])||''; tRev+=r; tChk+=Number(c)||0;
|
|
998
|
+
html+=`<tr><td>${p}</td><td class="num">${fmt(r)}</td><td class="num"><input class="money" ${lk?'disabled':''} value="${c!==''?fmtNum(c):''}" placeholder="0" onchange="checkSet('${m}','${p}',this.value)" style="width:110px"></td><td class="num">${c?fmt(r/Number(c)):'—'}</td></tr>`; });
|
|
999
|
+
html+=`<tr class="subtotal"><td>Итого</td><td class="num">${fmt(tRev)}</td><td class="num">${fmt(tChk)}</td><td class="num">${tChk?fmt(tRev/tChk):'—'}</td></tr></tbody></table></div></div></div>`;
|
|
1000
|
+
C.innerHTML=html;
|
|
1001
|
+
}
|
|
1002
|
+
function checkSet(m,p,v){ if(isLocked(m)){toast('Месяц закрыт');render();return;} ST.checks[m]=ST.checks[m]||{}; const n=parseNum(v); if(n==null)delete ST.checks[m][p]; else ST.checks[m][p]=Math.round(n); save(); render(); }
|
|
1003
|
+
function checksP(point,period){ return periodMonths(period).reduce((s,mo)=>s+((ST.checks[mo]&&ST.checks[mo][point])||0),0); }
|
|
1004
|
+
|
|
1005
|
+
/* ---------- CF прямой (счета, динамика, дневной прогноз, отчёт) ---------- */
|
|
1006
|
+
function renderCF(C){
|
|
1007
|
+
const P=ST.ui.period, mm=periodMonths(P), agg=isAggregate(P), locked=periodLocked(P);
|
|
1008
|
+
document.getElementById('pageSub').textContent=`${P} · прямой метод`;
|
|
1009
|
+
// account cards: банковские счета / наличные
|
|
1010
|
+
const bankBal=ST.accounts.filter(a=>accType(a)==='bank').reduce((s,a)=>s+accountBalance(a),0);
|
|
1011
|
+
const cashBal=ST.accounts.filter(a=>accType(a)==='cash').reduce((s,a)=>s+accountBalance(a),0);
|
|
1012
|
+
const bankN=ST.accounts.filter(a=>accType(a)==='bank').length;
|
|
1013
|
+
let html=`<div class="cards">
|
|
1014
|
+
<div class="card"><div class="k">Банковские счета</div><div class="v">${fmt(bankBal)} ₽</div><div class="d muted">${bankN} ${bankN===1?'счёт':'счета'}</div></div>
|
|
1015
|
+
<div class="card"><div class="k">Наличные</div><div class="v">${fmt(cashBal)} ₽</div><div class="d muted">касса</div></div>
|
|
1016
|
+
<div class="card" style="border-color:var(--accent)"><div class="k">Итого средств</div><div class="v">${fmt(bankBal+cashBal)} ₽</div><div class="d muted">банк + наличные</div></div></div>`;
|
|
1017
|
+
// dynamics
|
|
1018
|
+
const nets=MONTHS.map(monthNet); let bal=totalOpening(); const bals=MONTHS.map((m,i)=>{ bal+=nets[i]; return bal; });
|
|
1019
|
+
html+=`<div class="panel"><div class="hd"><h3>Динамика денежного потока по месяцам</h3></div><div class="bd">${svgBars(nets,MSHORT)}<div class="legend"><span><span class="sw" style="background:var(--green)"></span>приток</span><span><span class="sw" style="background:var(--red)"></span>отток</span></div></div></div>`;
|
|
1020
|
+
html+=`<div class="panel"><div class="hd"><h3>Остаток на счетах (нарастающим)</h3></div><div class="bd">${svgLine(bals,MSHORT,MONTHS.filter(m=>monthActive('Общий',m)).length)}</div></div>`;
|
|
1021
|
+
// daily
|
|
1022
|
+
if(agg){ html+=`<div class="note">Дневной прогноз доступен при выборе конкретного месяца.</div>`; }
|
|
1023
|
+
else {
|
|
1024
|
+
const mi=MONTHS.indexOf(primaryMonth()); const dc=dailyCF(mi);
|
|
1025
|
+
const balsD=dc.rows.map(r=>r.bal);
|
|
1026
|
+
html+=`<div class="panel"><div class="hd"><h3>Дневной CF и прогноз · ${primaryMonth()} (прямой метод)</h3><div class="spacer"></div>
|
|
1027
|
+
<span class="tag">старт ${fmt(dc.opening)} ₽</span><span class="tag">конец ${fmt(balsD[balsD.length-1])} ₽</span></div>
|
|
1028
|
+
<div class="bd">${svgLine(balsD,dc.rows.map(r=>r.d),dc.actualUntil)}
|
|
1029
|
+
<div class="legend"><span><span class="sw" style="background:var(--accent)"></span>факт (сплошная)</span><span>прогноз — пунктир</span><span class="muted">прогноз строится из плановых постоянных расходов по дню платежа</span></div>
|
|
1030
|
+
<div class="tbl-wrap" style="margin-top:12px;max-height:320px"><table>
|
|
1031
|
+
<thead><tr><th>День</th><th></th><th class="num">Приток</th><th class="num">Отток</th><th class="num">Net</th><th class="num">Остаток</th></tr></thead><tbody>`;
|
|
1032
|
+
dc.rows.forEach(r=>{
|
|
1033
|
+
html+=`<tr><td class="small">${String(r.d).padStart(2,'0')}.${String(mi+1).padStart(2,'0')}</td><td>${r.actual?'<span class="tag">факт</span>':'<span class="tag lock">план</span>'}</td>
|
|
1034
|
+
<td class="num">${r.inflow?fmt(r.inflow):'—'}</td><td class="num">${r.outflow?fmt(r.outflow):'—'}</td>
|
|
1035
|
+
<td class="num ${r.net>=0?'pos':'neg'}" style="font-weight:600">${r.net?(r.net>=0?'+':'')+fmt(r.net):'—'}</td><td class="num">${fmt(r.bal)}</td></tr>`; });
|
|
1036
|
+
html+=`</tbody></table></div></div></div>`;
|
|
1037
|
+
}
|
|
1038
|
+
// statement from editable structure
|
|
1039
|
+
const cf=computeCF('cf_direct',P);
|
|
1040
|
+
html+=`<div class="panel"><div class="hd"><h3>Отчёт о движении денежных средств · ${P}</h3><div class="spacer"></div>
|
|
1041
|
+
${locked?'<span class="tag lock">🔒 закрыт</span>':'<span class="muted small">строки — в Конструкторе</span>'}</div>
|
|
1042
|
+
<div class="bd" style="padding:0"><div class="tbl-wrap"><table class="pnl">
|
|
1043
|
+
<colgroup><col><col class="colmoney"></colgroup>
|
|
1044
|
+
<thead><tr><th>Статья</th><th class="num">Сумма, ₽</th></tr></thead><tbody>${cfRows(cf,'cf_direct',P,locked)}</tbody></table></div></div></div>`;
|
|
1045
|
+
C.innerHTML=html;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/* ---------- CF обратный ---------- */
|
|
1049
|
+
function renderCFI(C){
|
|
1050
|
+
const P=ST.ui.period, locked=periodLocked(P);
|
|
1051
|
+
document.getElementById('pageSub').textContent=`${P} · обратный (косвенный) метод`;
|
|
1052
|
+
const cf=computeCF('cf_indirect',P);
|
|
1053
|
+
let html='';
|
|
1054
|
+
if(locked)html+=`<div class="note">🔒 Период закрыт — редактирование заблокировано.</div>`;
|
|
1055
|
+
html+=`<div class="note">Стартует от чистой прибыли из PnL, далее корректировки на неденежные статьи и оборотный капитал. Строки настраиваются в <b>«Конструкторе»</b>.</div>`;
|
|
1056
|
+
html+=`<div class="panel"><div class="hd"><h3>Косвенный расчёт денежного потока · ${P}</h3></div><div class="bd" style="padding:0"><div class="tbl-wrap"><table class="pnl">
|
|
1057
|
+
<colgroup><col><col class="colmoney"></colgroup>
|
|
1058
|
+
<thead><tr><th>Показатель</th><th class="num">Сумма, ₽</th></tr></thead><tbody>${cfRows(cf,'cf_indirect',P,locked)}</tbody></table></div></div></div>`;
|
|
1059
|
+
C.innerHTML=html;
|
|
1060
|
+
}
|
|
1061
|
+
function cfRows(cf,key,P,locked){
|
|
1062
|
+
let html=''; cf.forEach(x=>{ const row=x.row;
|
|
1063
|
+
if(row.role==='spacer'){html+=`<tr class="spacer"><td colspan="2"></td></tr>`;return;}
|
|
1064
|
+
if(row.role==='header'){html+=`<tr class="header"><td colspan="2" class="nmcell">${esc(row.name)}</td></tr>`;return;}
|
|
1065
|
+
if(row.role==='subtotal'){html+=`<tr class="subtotal"><td class="nmcell">${esc(row.name)}</td><td class="num ${x.val>=0?'pos':'neg'}">${fmt(x.val)}</td></tr>`;return;}
|
|
1066
|
+
if(row.role==='grand'){html+=`<tr class="grand"><td class="nmcell">${esc(row.name)}</td><td class="num ${x.val>=0?'pos':'neg'}">${fmt(x.val)}</td></tr>`;return;}
|
|
1067
|
+
// value
|
|
1068
|
+
const editable=row.source==='manual'&&!locked;
|
|
1069
|
+
const cell= editable? `<input class="money" value="${fmtNum(cfManual(P,row.id))}" placeholder="0" onchange="editCFData('${row.id}',this.value)">`
|
|
1070
|
+
: `<span class="${x.val>=0?'pos':'neg'}">${fmt(x.val)}</span>`;
|
|
1071
|
+
html+=`<tr class="value"><td class="nmcell"><div class="rowname"><span>${esc(row.name)}</span></div></td><td class="num">${cell}</td></tr>`;
|
|
1072
|
+
});
|
|
1073
|
+
return html;
|
|
1074
|
+
}
|
|
1075
|
+
function editCFData(rowId,v){ const P=ST.ui.period; if(periodLocked(P)){toast('Период закрыт');render();return;} ST.cfData[P]=ST.cfData[P]||{}; ST.cfData[P][rowId]=parseNum(v)||0; save(); render(); }
|
|
1076
|
+
|
|
1077
|
+
/* ---------- Сверка PnL ↔ CF ---------- */
|
|
1078
|
+
function dividendsSum(period){ const mm=periodMonths(period); return ST.operations.filter(o=>mm.includes(o.month)&&o.type==='Расход'&&/дивиденд/i.test(o.category)).reduce((s,o)=>s+Math.abs(o.amount),0); }
|
|
1079
|
+
function renderRecon(C){
|
|
1080
|
+
const P=ST.ui.period, mm=periodMonths(P);
|
|
1081
|
+
document.getElementById('pageSub').textContent=`${P} · начисление (PnL) ↔ касса (CF)`;
|
|
1082
|
+
const net=netProfit('Общий',P);
|
|
1083
|
+
const cashOps=ST.operations.filter(o=>mm.includes(o.month)&&isCashOp(o));
|
|
1084
|
+
const cashIn=cashOps.filter(o=>o.type==='Доход').reduce((s,o)=>s+Math.abs(o.amount),0);
|
|
1085
|
+
const cashOut=cashOps.filter(o=>o.type==='Расход').reduce((s,o)=>s+Math.abs(o.amount),0);
|
|
1086
|
+
const cashNet=cashIn-cashOut;
|
|
1087
|
+
const pnlCats=new Set(ST.structures.general.filter(r=>r.role==='value').map(r=>r.cat));
|
|
1088
|
+
const comp=computePnL('Общий',P); const accr={}; comp.forEach(x=>{ if(x.row.role==='value')accr[x.row.cat]=(accr[x.row.cat]||0)+x.fact; });
|
|
1089
|
+
const paid={}; cashOps.forEach(o=>{ const v=o.type==='Доход'?Math.abs(o.amount):-Math.abs(o.amount); paid[o.category]=(paid[o.category]||0)+v; });
|
|
1090
|
+
const cats=[...new Set([...Object.keys(accr),...Object.keys(paid)])].filter(c=>pnlCats.has(c));
|
|
1091
|
+
const outside=cashOps.filter(o=>!pnlCats.has(o.category));
|
|
1092
|
+
const outsideNet=outside.reduce((s,o)=>s+(o.type==='Доход'?Math.abs(o.amount):-Math.abs(o.amount)),0);
|
|
1093
|
+
let html=`<div class="note">PnL — метод начисления, CF — касса. Здесь видно, где они расходятся: тайминг оплат, движения вне PnL и потерянные операции. Расхождение по статье — это кредиторка/дебиторка (тайминг).</div>`;
|
|
1094
|
+
html+=`<div class="cards">
|
|
1095
|
+
<div class="card"><div class="k">Чистая прибыль (начисление)</div><div class="v ${net>=0?'pos':'neg'}">${fmt(net)} ₽</div></div>
|
|
1096
|
+
<div class="card"><div class="k">Денежный поток (касса)</div><div class="v ${cashNet>=0?'pos':'neg'}">${fmt(cashNet)} ₽</div><div class="d muted">по операциям</div></div>
|
|
1097
|
+
<div class="card" style="border-color:${outside.length?'var(--accent)':'var(--line)'}"><div class="k">Движения вне PnL</div><div class="v">${fmt(outsideNet)} ₽</div><div class="d muted">${outside.length} операций</div></div>
|
|
1098
|
+
</div>`;
|
|
1099
|
+
html+=`<div class="panel"><div class="hd"><h3>Сверка по статьям · начислено (PnL) vs оплачено (касса)</h3></div><div class="bd" style="padding:0"><div class="tbl-wrap"><table class="pnl" style="table-layout:auto">
|
|
1100
|
+
<thead><tr><th>Статья</th><th class="num">Начислено (PnL)</th><th class="num">Касса (оплачено)</th><th class="num">Разница</th><th>Трактовка</th></tr></thead><tbody>`;
|
|
1101
|
+
cats.forEach(c=>{ const a=accr[c]||0, pd=paid[c]||0, d=a-pd; if(!a&&!pd)return;
|
|
1102
|
+
const warn=(!a&&Math.abs(pd)>0);
|
|
1103
|
+
const kind=warn?'⚠ оплачено, но нет начисления':(Math.abs(d)<1?'сходится':(a<0?(d<0?'кредиторка (недоплачено)':'переплата'):(d>0?'дебиторка (недополучено)':'аванс')));
|
|
1104
|
+
html+=`<tr class="value"><td class="nmcell">${esc(c)}${warn?' <span class="tag lock">без начисления</span>':''}</td><td class="num">${fmt(a)}</td><td class="num">${fmt(pd)}</td><td class="num ${Math.abs(d)<1?'':'neg'}">${d?fmt(d):'0'}</td><td class="small ${warn?'neg':'muted'}">${kind}</td></tr>`; });
|
|
1105
|
+
html+=`</tbody></table></div></div></div>`;
|
|
1106
|
+
const needs=outside.filter(o=>o.reconStatus!=='nonPnL'&&o.reconStatus!=='inPnL');
|
|
1107
|
+
const resolved=outside.filter(o=>o.reconStatus==='nonPnL'||o.reconStatus==='inPnL');
|
|
1108
|
+
const paidNoAccr=cats.filter(c=>!(accr[c]||0)&&Math.abs(paid[c]||0)>0);
|
|
1109
|
+
const needsSum=needs.reduce((s,o)=>s+(o.type==='Доход'?Math.abs(o.amount):-Math.abs(o.amount)),0);
|
|
1110
|
+
const lk=!isAggregate(P)&&isLocked(primaryMonth());
|
|
1111
|
+
if(needs.length||paidNoAccr.length)
|
|
1112
|
+
html+=`<div class="note" style="border-left-color:var(--neg)"><b>Итог по периоду — нужно решение.</b> Не отражено в PnL: <b>${needs.length}</b> операц. на ${fmt(needsSum)} ₽${paidNoAccr.length?`; статей оплачено без начисления: <b>${paidNoAccr.length}</b> (${paidNoAccr.join(', ')})`:''}.${lk?'':' Разберите ниже перед закрытием месяца.'}</div>`;
|
|
1113
|
+
else html+=`<div class="note" style="border-left-color:var(--green)">✓ Все денежные операции периода отражены в PnL или помечены как вне PnL. Период готов к закрытию.</div>`;
|
|
1114
|
+
html+=`<div class="panel"><div class="hd"><h3>Требует решения · операции вне PnL</h3><div class="spacer"></div><span class="tag ${needs.length?'lock':''}">${needs.length}</span></div><div class="bd">`;
|
|
1115
|
+
if(!needs.length)html+=`<div class="empty">Нет операций, ожидающих решения ✓</div>`;
|
|
1116
|
+
else{ html+=`<p class="hint">Деньги прошли мимо PnL. По каждой: если это операционный расход/доход — заведите статью в PnL (Конструктор) и отразите начисление; если финансовая/инвестиционная деятельность (займы, дивиденды, оборудование) или перевод — пометьте «вне PnL».</p>
|
|
1117
|
+
<div class="tbl-wrap"><table><thead><tr><th>Дата</th><th>Статья</th><th>Точка</th><th class="num">Сумма</th><th>Заметка</th><th>Решение</th></tr></thead><tbody>`;
|
|
1118
|
+
needs.sort((a,b)=>a.date<b.date?-1:1).forEach(o=>html+=`<tr><td class="small">${o.date}</td><td>${esc(o.category)}</td><td class="muted small">${o.point}</td>
|
|
1119
|
+
<td class="num ${o.type==='Доход'?'pos':'neg'}">${o.type==='Доход'?'+':'−'}${fmt(Math.abs(o.amount))}</td><td class="muted small">${esc(o.note||'')}</td>
|
|
1120
|
+
<td><div class="row" style="gap:4px;flex-wrap:nowrap"><button class="mini ghost" onclick="setRecon('${o.id}','nonPnL')">Вне PnL</button><button class="mini ghost" onclick="setRecon('${o.id}','inPnL')">Учтено</button><button class="icon" onclick="opModal('${o.id}')">✎</button></div></td></tr>`);
|
|
1121
|
+
html+=`</tbody></table></div>`; }
|
|
1122
|
+
html+=`</div></div>`;
|
|
1123
|
+
if(resolved.length){ html+=`<div class="panel"><div class="hd"><h3>Помечено как разобранное</h3><div class="spacer"></div><span class="tag">${resolved.length}</span></div>
|
|
1124
|
+
<div class="bd" style="padding:0"><div class="tbl-wrap"><table><thead><tr><th>Дата</th><th>Статья</th><th class="num">Сумма</th><th>Статус</th><th></th></tr></thead><tbody>`;
|
|
1125
|
+
resolved.sort((a,b)=>a.date<b.date?-1:1).forEach(o=>html+=`<tr style="opacity:.65"><td class="small">${o.date}</td><td>${esc(o.category)}</td><td class="num">${o.type==='Доход'?'+':'−'}${fmt(Math.abs(o.amount))}</td>
|
|
1126
|
+
<td class="small muted">${o.reconStatus==='nonPnL'?'вне PnL (финанс/инвест)':'учтено в PnL'}</td><td><button class="mini ghost" onclick="setRecon('${o.id}','')">Вернуть</button></td></tr>`);
|
|
1127
|
+
html+=`</tbody></table></div></div></div>`; }
|
|
1128
|
+
C.innerHTML=html;
|
|
1129
|
+
}
|
|
1130
|
+
function setRecon(id,status){ const o=ST.operations.find(x=>x.id===id); if(o){ o.reconStatus=status||null; save(); render(); } }
|
|
1131
|
+
|
|
1132
|
+
/* ---------- Займы и кредиты ---------- */
|
|
1133
|
+
function keyRateAtDate(ds){ let r=16; ST.keyRates.slice().sort((a,b)=>(a.date||'')<(b.date||'')?-1:1).forEach(k=>{ if((k.date||'2026-01-01')<=ds)r=k.rate; }); return r; }
|
|
1134
|
+
function keyRateAt(monthIdx){ return keyRateAtDate(`${YEAR}-${String(monthIdx+1).padStart(2,'0')}-15`); }
|
|
1135
|
+
function monthInterest(out,mi,spread){ const N=daysInMonth(mi); let interest=0; for(let d=1;d<=N;d++){ const ds=`${YEAR}-${String(mi+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`; interest+= out*(keyRateAtDate(ds)+(spread||0))/100/365; } return interest; }
|
|
1136
|
+
function loanSchedule(loan){ const rows=[]; const term=loan.termMonths||12, start=loan.startIdx||0; const princPay=(loan.type==='interest_only')?0:loan.principal/term; let out=0;
|
|
1137
|
+
for(let m=0;m<12;m++){ if(m===start)out=loan.principal;
|
|
1138
|
+
if(m<start||m>=start+term){ rows.push({m,active:false,out0:0,interest:0,principalPay:0,out1:0,rate:keyRateAt(m)+(loan.spread||0)}); continue; }
|
|
1139
|
+
const rate=keyRateAt(m)+(loan.spread||0); const interest=monthInterest(out,m,loan.spread);
|
|
1140
|
+
let pp=(loan.type==='interest_only'&&m===start+term-1)?out:princPay; pp=Math.min(pp,out); const out1=out-pp;
|
|
1141
|
+
rows.push({m,active:true,out0:out,interest,principalPay:pp,out1,rate}); out=out1; }
|
|
1142
|
+
return rows; }
|
|
1143
|
+
function periodEndIdx(period){ return Math.max(...periodMonths(period).map(m=>MONTHS.indexOf(m))); }
|
|
1144
|
+
function loanBalanceAt(l,idx){ return loanSchedule(l)[idx].out1; }
|
|
1145
|
+
function loanSum(l,mm,field){ const s=loanSchedule(l); return mm.reduce((a,mo)=>a+s[MONTHS.indexOf(mo)][field],0); }
|
|
1146
|
+
function loanInterestMonth(mo){ return ST.loans.reduce((s,l)=>s+loanSchedule(l)[MONTHS.indexOf(mo)].interest,0); }
|
|
1147
|
+
function renderLoans(C){
|
|
1148
|
+
const P=ST.ui.period, idx=periodEndIdx(P), mm=periodMonths(P);
|
|
1149
|
+
document.getElementById('pageSub').textContent=`${P} · кредиты и ключевая ставка`;
|
|
1150
|
+
const totBal=ST.loans.reduce((s,l)=>s+loanBalanceAt(l,idx),0);
|
|
1151
|
+
const intPer=ST.loans.reduce((s,l)=>s+loanSum(l,mm,'interest'),0);
|
|
1152
|
+
const prinPer=ST.loans.reduce((s,l)=>s+loanSum(l,mm,'principalPay'),0);
|
|
1153
|
+
let html=`<div class="cards">
|
|
1154
|
+
<div class="card"><div class="k">Общий долг (конец ${MSHORT[idx]})</div><div class="v">${fmt(totBal)} ₽</div><div class="d muted">${ST.loans.length} кредита</div></div>
|
|
1155
|
+
<div class="card"><div class="k">Проценты за период</div><div class="v neg">${fmt(intPer)} ₽</div></div>
|
|
1156
|
+
<div class="card"><div class="k">Погашено тела за период</div><div class="v">${fmt(prinPer)} ₽</div></div>
|
|
1157
|
+
<div class="card"><div class="k">Ключевая ставка</div><div class="v">${keyRateAt(idx)}%</div><div class="d muted">на ${MSHORT[idx]}</div></div></div>`;
|
|
1158
|
+
html+=`<div class="panel"><div class="hd"><h3>Ключевая ставка</h3><div class="spacer"></div><button class="primary mini" onclick="keyRateModal()">+ Изменение</button></div>
|
|
1159
|
+
<div class="bd" style="padding:0"><div class="tbl-wrap"><table>
|
|
1160
|
+
<thead><tr><th>Действует с даты</th><th class="num">Ставка</th><th style="width:90px"></th></tr></thead><tbody>`;
|
|
1161
|
+
ST.keyRates.slice().sort((a,b)=>(a.date||'')<(b.date||'')?-1:1).forEach(k=>html+=`<tr><td>${(k.date||'').split('-').reverse().join('.')}</td><td class="num"><b>${k.rate}%</b></td>
|
|
1162
|
+
<td><div class="ctrls" style="opacity:1"><button class="icon" onclick="keyRateModal('${k.id}')">✎</button><button class="icon" onclick="keyRateDel('${k.id}')">✕</button></div></td></tr>`);
|
|
1163
|
+
html+=`</tbody></table></div><p class="hint" style="padding:10px 16px 0">Ставка по кредиту = ключевая + спред. Проценты считаются по дням: при изменении ставки с конкретной даты пересчитываются с этого дня.</p></div></div>`;
|
|
1164
|
+
html+=`<div class="panel"><div class="hd"><h3>Кредиты</h3><div class="spacer"></div><button class="primary mini" onclick="loanModal()">+ Кредит</button></div>
|
|
1165
|
+
<div class="bd" style="padding:0"><div class="tbl-wrap"><table>
|
|
1166
|
+
<thead><tr><th>Кредит</th><th class="num">Тело</th><th>Ставка</th><th class="num">Остаток</th><th class="num">% за период</th><th class="num">Тело за период</th><th></th></tr></thead><tbody>`;
|
|
1167
|
+
if(!ST.loans.length)html+=`<tr><td colspan="7"><div class="empty">Нет кредитов</div></td></tr>`;
|
|
1168
|
+
ST.loans.forEach((l,i)=>html+=`<tr><td>${esc(l.name)}</td><td class="num">${fmt(l.principal)}</td><td>${(keyRateAt(idx)+l.spread)}% <span class="muted small">(кл+${l.spread})</span></td>
|
|
1169
|
+
<td class="num">${fmt(loanBalanceAt(l,idx))}</td><td class="num neg">${fmt(loanSum(l,mm,'interest'))}</td><td class="num">${fmt(loanSum(l,mm,'principalPay'))}</td>
|
|
1170
|
+
<td><div class="ctrls" style="opacity:1"><button class="icon" onclick="loanModal(${i})">✎</button><button class="icon" onclick="loanDel(${i})">✕</button></div></td></tr>`);
|
|
1171
|
+
html+=`</tbody></table></div></div></div>`;
|
|
1172
|
+
ST.loans.forEach(l=>{ const sch=loanSchedule(l);
|
|
1173
|
+
html+=`<div class="panel"><div class="hd"><h3>График · ${esc(l.name)}</h3><div class="spacer"></div><span class="muted small">срок ${l.termMonths} мес, с ${MONTHS[l.startIdx]}</span></div>
|
|
1174
|
+
<div class="bd" style="padding:0"><div class="tbl-wrap"><table class="pnl" style="table-layout:auto">
|
|
1175
|
+
<thead><tr><th>Месяц</th><th>Ставка</th><th class="num">Остаток начало</th><th class="num">Проценты</th><th class="num">Тело</th><th class="num">Остаток конец</th></tr></thead><tbody>`;
|
|
1176
|
+
sch.forEach(r=>{ if(!r.active)return; html+=`<tr class="${mm.includes(MONTHS[r.m])?'subtotal':'value'}"><td class="nmcell">${MONTHS[r.m]}</td><td>${r.rate}%</td><td class="num">${fmt(r.out0)}</td><td class="num neg">${fmt(r.interest)}</td><td class="num">${fmt(r.principalPay)}</td><td class="num">${fmt(r.out1)}</td></tr>`; });
|
|
1177
|
+
html+=`</tbody></table></div></div></div>`; });
|
|
1178
|
+
C.innerHTML=html;
|
|
1179
|
+
}
|
|
1180
|
+
function loanModal(i){ const ed=(i!=null)?ST.loans[i]:null;
|
|
1181
|
+
openModal(ed?'Изменить кредит':'Новый кредит',`
|
|
1182
|
+
<label class="fld">Название<input id="ln-name" value="${ed?esc(ed.name):''}"></label>
|
|
1183
|
+
<label class="fld">Тело кредита, ₽<input id="ln-prin" type="number" value="${ed?ed.principal:''}"></label>
|
|
1184
|
+
<div class="row"><label class="fld" style="flex:1">Месяц выдачи<select id="ln-start">${MONTHS.map((m,idx)=>`<option value="${idx}" ${ed&&ed.startIdx===idx?'selected':''}>${m}</option>`).join('')}</select></label>
|
|
1185
|
+
<label class="fld" style="flex:1">Срок, мес<input id="ln-term" type="number" value="${ed?ed.termMonths:12}"></label></div>
|
|
1186
|
+
<div class="row"><label class="fld" style="flex:1">Спред к ключевой, %<input id="ln-spread" type="number" step="0.1" value="${ed?ed.spread:4}"></label>
|
|
1187
|
+
<label class="fld" style="flex:1">Погашение<select id="ln-type"><option value="equal" ${!ed||ed.type==='equal'?'selected':''}>Равными долями</option><option value="interest_only" ${ed&&ed.type==='interest_only'?'selected':''}>Проценты + тело в конце</option></select></label></div>`,
|
|
1188
|
+
()=>{ const rec={name:val('ln-name'),principal:Number(val('ln-prin'))||0,startIdx:Number(val('ln-start')),termMonths:Number(val('ln-term'))||12,spread:Number(val('ln-spread'))||0,type:val('ln-type')};
|
|
1189
|
+
if(ed)Object.assign(ed,rec); else ST.loans.push({id:uid(),...rec}); save(); render(); });
|
|
1190
|
+
}
|
|
1191
|
+
function loanDel(i){ if(confirm('Удалить кредит?')){ ST.loans.splice(i,1); save(); render(); } }
|
|
1192
|
+
function keyRateModal(id){ const ed=id?ST.keyRates.find(k=>k.id===id):null;
|
|
1193
|
+
openModal(ed?'Изменить ставку':'Изменение ключевой ставки',`
|
|
1194
|
+
<label class="fld">Действует с даты<input type="date" id="kr-date" value="${ed?(ed.date||'2026-01-01'):'2026-01-01'}"></label>
|
|
1195
|
+
<label class="fld">Ключевая ставка, %<input id="kr-rate" type="number" step="0.25" value="${ed?ed.rate:16}"></label>
|
|
1196
|
+
${ed?`<button class="ghost mini" onclick="keyRateDel('${ed.id}')">Удалить эту точку</button>`:''}`,
|
|
1197
|
+
()=>{ const rec={date:val('kr-date'),rate:Number(val('kr-rate'))||0}; if(ed)Object.assign(ed,rec); else ST.keyRates.push({id:uid(),...rec}); save(); render(); });
|
|
1198
|
+
}
|
|
1199
|
+
function keyRateDel(id){ ST.keyRates=ST.keyRates.filter(k=>k.id!==id); if(!ST.keyRates.length)ST.keyRates=[{id:uid(),date:'2026-01-01',rate:16}]; save(); closeModal(); render(); }
|
|
1200
|
+
|
|
1201
|
+
/* ---------- Операции ---------- */
|
|
1202
|
+
function renderOps(C){
|
|
1203
|
+
const P=ST.ui.period, mm=periodMonths(P), lk=!isAggregate(P)&&isLocked(primaryMonth());
|
|
1204
|
+
document.getElementById('pageSub').textContent=`${P}`;
|
|
1205
|
+
const ops=ST.operations.filter(o=>mm.includes(o.month)).sort((a,b)=>a.date<b.date?-1:1);
|
|
1206
|
+
let html=''; if(lk)html+=`<div class="note">🔒 Период закрыт — операции только для просмотра.</div>`;
|
|
1207
|
+
html+=`<div class="panel"><div class="hd"><h3>Операции · ${P}</h3><div class="spacer"></div>
|
|
1208
|
+
<button class="primary mini" onclick="opModal()" ${lk?'disabled':''}>+ Операция</button></div>
|
|
1209
|
+
<div class="bd" style="padding:0"><div class="tbl-wrap"><table>
|
|
1210
|
+
<thead><tr><th>Дата</th><th>Тип</th><th>Статья</th><th>Точка</th><th>Счёт</th><th class="num">Сумма</th><th>Заметка</th><th></th></tr></thead><tbody>`;
|
|
1211
|
+
if(!ops.length)html+=`<tr><td colspan="8"><div class="empty">Нет операций за ${P}</div></td></tr>`;
|
|
1212
|
+
ops.forEach(o=>{ const emp=o.employeeId&&(ST.employees||[]).find(e=>e.id===o.employeeId);
|
|
1213
|
+
html+=`<tr><td class="small">${o.date}</td><td><span class="tag">${o.type}</span>${o.cash===false?' <span class="tag lock">неден.</span>':''}</td><td>${o.category}${emp?` <span class="muted small">· ${esc(emp.name)}</span>`:''}</td>
|
|
1214
|
+
<td class="muted small">${o.point}</td><td class="muted small">${o.account||''}</td>
|
|
1215
|
+
<td class="num ${o.type==='Доход'?'pos':'neg'}">${o.type==='Доход'?'+':'−'}${fmt(Math.abs(o.amount))}</td>
|
|
1216
|
+
<td class="muted small">${esc(o.note||'')}</td>
|
|
1217
|
+
<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>`; });
|
|
1218
|
+
html+=`</tbody></table></div></div></div>`;
|
|
1219
|
+
const inflow=ops.filter(o=>o.type==='Доход').reduce((s,o)=>s+Math.abs(o.amount),0);
|
|
1220
|
+
const outflow=ops.filter(o=>o.type==='Расход').reduce((s,o)=>s+Math.abs(o.amount),0);
|
|
1221
|
+
html+=`<div class="row"><span class="tag">Поступлений: <b class="pos">${fmt(inflow)} ₽</b></span><span class="tag">Выбытий: <b class="neg">${fmt(outflow)} ₽</b></span></div>`;
|
|
1222
|
+
C.innerHTML=html;
|
|
1223
|
+
}
|
|
1224
|
+
function opModal(id){ const ed=id?ST.operations.find(o=>o.id===id):null; const cats=[...new Set([...ST.structures.general,...ST.structures.point].filter(r=>r.role==='value').map(r=>r.cat))];
|
|
1225
|
+
openModal(ed?'Изменить операцию':'Новая операция',`
|
|
1226
|
+
<div class="row"><label class="fld" style="flex:1">Дата<input type="date" id="op-date" value="${ed?ed.date:'2026-01-01'}"></label>
|
|
1227
|
+
<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>
|
|
1228
|
+
<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>
|
|
1229
|
+
<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>
|
|
1230
|
+
<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>
|
|
1231
|
+
<label class="fld">Сумма, ₽<input id="op-amt" type="number" value="${ed?Math.abs(ed.amount):''}"></label>
|
|
1232
|
+
<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>
|
|
1233
|
+
<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>
|
|
1234
|
+
<label class="fld">Заметка<input id="op-note" value="${ed?esc(ed.note||''):''}" placeholder="комментарий к операции"></label>`,
|
|
1235
|
+
()=>{ const date=val('op-date'); const month=MONTHS[new Date(date).getMonth()]; if(isLocked(month)){toast('Месяц закрыт');return;}
|
|
1236
|
+
const rec={date,month,type:val('op-type'),category:val('op-cat'),point:val('op-point'),account:val('op-acc'),amount:Number(val('op-amt'))||0,cash:val('op-cash')==='1',employeeId:val('op-emp')||null,note:val('op-note'),comment:val('op-note')};
|
|
1237
|
+
if(ed)Object.assign(ed,rec); else ST.operations.push({id:uid(),...rec}); save(); render(); });
|
|
1238
|
+
}
|
|
1239
|
+
function delOp(id){ if(confirm('Удалить операцию?')){ ST.operations=ST.operations.filter(o=>o.id!==id); save(); render(); } }
|
|
1240
|
+
|
|
1241
|
+
/* ---------- Постоянные расходы ---------- */
|
|
1242
|
+
function renderFixed(C){
|
|
1243
|
+
document.getElementById('pageSub').textContent=`Реестр регулярных платежей`;
|
|
1244
|
+
const list=ST.fixed; const total=list.filter(f=>f.active).reduce((s,f)=>s+Number(f.amount||0),0);
|
|
1245
|
+
let html=`<div class="cards">
|
|
1246
|
+
<div class="card"><div class="k">Постоянных расходов / мес</div><div class="v">${fmt(total)} ₽</div><div class="d muted">${list.filter(f=>f.active).length} активных</div></div>
|
|
1247
|
+
<div class="card"><div class="k">Точек с расходами</div><div class="v">${new Set(list.map(f=>f.point)).size}</div></div></div>`;
|
|
1248
|
+
html+=`<div class="row" style="margin-bottom:16px"><div class="spacer"></div><button class="primary mini" onclick="fxModal()">+ Постоянный расход</button></div>`;
|
|
1249
|
+
const byP={}; list.forEach(f=>(byP[f.point]=byP[f.point]||[]).push(f));
|
|
1250
|
+
const order=[...ST.points.filter(p=>byP[p]),...Object.keys(byP).filter(p=>!ST.points.includes(p))];
|
|
1251
|
+
order.forEach(p=>{ const items=byP[p]; const sub=items.filter(f=>f.active).reduce((s,f)=>s+Number(f.amount||0),0);
|
|
1252
|
+
html+=`<div class="panel"><div class="hd"><h3>${esc(p)}</h3><div class="spacer"></div><span class="tag">${items.filter(f=>f.active).length} поз. · <b>${fmt(sub)} ₽/мес</b></span></div>
|
|
1253
|
+
<div class="bd" style="padding:0"><div class="tbl-wrap"><table>
|
|
1254
|
+
<thead><tr><th style="width:40px"></th><th>Статья</th><th class="num">Сумма/мес</th><th>День платежа</th><th>Комментарий</th><th style="width:80px"></th></tr></thead><tbody>`;
|
|
1255
|
+
items.forEach(f=>html+=`<tr style="${f.active?'':'opacity:.4'}"><td style="text-align:center"><input type="checkbox" ${f.active?'checked':''} onchange="toggleFx('${f.id}')"></td>
|
|
1256
|
+
<td><b>${esc(f.category)}</b></td><td class="num">${fmt(f.amount)}</td>
|
|
1257
|
+
<td><span class="tag">${esc(f.payDay||'—')}</span></td><td class="muted small">${esc(f.comment||'')}</td>
|
|
1258
|
+
<td><div class="ctrls" style="opacity:1"><button class="icon" onclick="fxModal('${f.id}')">✎</button><button class="icon" onclick="delFx('${f.id}')">✕</button></div></td></tr>`);
|
|
1259
|
+
html+=`<tr class="subtotal"><td></td><td>Итого по точке</td><td class="num">${fmt(sub)}</td><td colspan="3"></td></tr>`;
|
|
1260
|
+
html+=`</tbody></table></div></div></div>`;
|
|
1261
|
+
});
|
|
1262
|
+
html+=`<p class="hint">Тянутся в PnL (строки-источник «пост.»), в дневной прогноз CF (по дню платежа) и в отчёт ДДС. «День платежа» — число месяца для прогноза.</p>`;
|
|
1263
|
+
C.innerHTML=html;
|
|
1264
|
+
}
|
|
1265
|
+
function toggleFx(id){ const f=ST.fixed.find(x=>x.id===id); f.active=!f.active; save(); render(); }
|
|
1266
|
+
function delFx(id){ if(confirm('Удалить расход?')){ ST.fixed=ST.fixed.filter(f=>f.id!==id); save(); render(); } }
|
|
1267
|
+
function fxModal(id){ const ed=id?ST.fixed.find(f=>f.id===id):null; const cats=[...new Set([...ST.structures.general,...ST.structures.point].filter(r=>r.role==='value').map(r=>r.cat))];
|
|
1268
|
+
openModal(ed?'Изменить расход':'Новый постоянный расход',`
|
|
1269
|
+
<label class="fld">Статья<input id="fx-cat" list="fxcat" value="${ed?esc(ed.category):''}"><datalist id="fxcat">${cats.map(c=>`<option>${c}</option>`).join('')}</datalist></label>
|
|
1270
|
+
<label class="fld">Точка<select id="fx-point"><option>Общий</option>${ST.points.map(p=>`<option ${ed&&ed.point===p?'selected':''}>${p}</option>`).join('')}</select></label>
|
|
1271
|
+
<label class="fld">Сумма в месяц, ₽<input id="fx-amt" type="number" value="${ed?ed.amount:''}"></label>
|
|
1272
|
+
<label class="fld">День платежа (число)<input id="fx-day" value="${ed?esc(ed.payDay||''):''}" placeholder="10"></label>
|
|
1273
|
+
<label class="fld">Комментарий<input id="fx-cm" value="${ed?esc(ed.comment||''):''}"></label>`,
|
|
1274
|
+
()=>{ const rec={category:val('fx-cat'),point:val('fx-point'),amount:Number(val('fx-amt'))||0,payDay:val('fx-day'),comment:val('fx-cm')};
|
|
1275
|
+
if(ed)Object.assign(ed,rec); else ST.fixed.push({id:uid(),active:true,...rec}); save(); render(); });
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/* ---------- Расписание и ФОТ ---------- */
|
|
1279
|
+
function renderSched(C){
|
|
1280
|
+
const P=ST.ui.period, agg=isAggregate(P), m=primaryMonth(), mi=MONTHS.indexOf(m), N=daysInMonth(mi), lk=isLocked(m);
|
|
1281
|
+
const nextIdx=(mi+1)%12; const advDate=`16.${String(mi+1).padStart(2,'0')}`, finDate=`01.${String(nextIdx+1).padStart(2,'0')}`;
|
|
1282
|
+
document.getElementById('pageSub').textContent=`${m} · ФОТ, выплаты и табель`;
|
|
1283
|
+
ST.timesheet[m]=ST.timesheet[m]||{}; ST.payAdj[m]=ST.payAdj[m]||{};
|
|
1284
|
+
const emps=(ST.employees||[]).filter(e=>e.active);
|
|
1285
|
+
const calc=(e)=>{ const a=ST.payAdj[m][e.id]||{}; const sh1=empDaysHalf(m,e.id,true), sh2=empDaysHalf(m,e.id,false);
|
|
1286
|
+
const mgrH=e.isManager?Math.round((e.managerBonus||0)/2):0;
|
|
1287
|
+
const acc1=sh1+Number(a.bonus1||0)-Number(a.penalty1||0)+mgrH; const pay1=acc1-Number(a.adv1||0);
|
|
1288
|
+
const acc2=sh2+Number(a.bonus2||0)-Number(a.penalty2||0)+mgrH; const pay2=acc2-Number(a.adv2||0);
|
|
1289
|
+
return {a,sh1,sh2,mgrH,acc1,pay1,acc2,pay2,accM:acc1+acc2,paid:empPaid(m,e.id)}; };
|
|
1290
|
+
const C_=emps.map(e=>({e,c:calc(e)}));
|
|
1291
|
+
const grand=C_.reduce((s,x)=>s+x.c.accM,0); const pay1t=C_.reduce((s,x)=>s+x.c.pay1,0); const pay2t=C_.reduce((s,x)=>s+x.c.pay2,0);
|
|
1292
|
+
let html='';
|
|
1293
|
+
if(agg)html+=`<div class="note">Период «${P}» сводный — табель показан за ${m}.</div>`;
|
|
1294
|
+
if(lk)html+=`<div class="note">🔒 ${m} закрыт — только просмотр.</div>`;
|
|
1295
|
+
if(!emps.length)html+=`<div class="note">Сначала заведите сотрудников: Конструктор → Сотрудники.</div>`;
|
|
1296
|
+
html+=`<div class="cards">
|
|
1297
|
+
<div class="card"><div class="k">ФОТ начислено · ${m}</div><div class="v">${fmt(grand)} ₽</div><div class="d muted">${emps.length} сотрудников</div></div>
|
|
1298
|
+
<div class="card"><div class="k">К выплате за 1–16 · ${advDate}</div><div class="v">${fmt(pay1t)} ₽</div><div class="d muted">за вычетом авансов</div></div>
|
|
1299
|
+
<div class="card"><div class="k">К выплате за 17–${N} · ${finDate}</div><div class="v">${fmt(pay2t)} ₽</div><div class="d muted">за вычетом авансов</div></div></div>`;
|
|
1300
|
+
const inp=(id,f,v)=>`<input class="money" ${lk?'disabled':''} value="${fmtNum(v)}" placeholder="0" onchange="adjSet('${m}','${id}','${f}',this.value)" style="width:100%">`;
|
|
1301
|
+
const bl='border-left:2px solid var(--line2)';
|
|
1302
|
+
const cg=`<colgroup><col style="width:150px"><col style="width:86px">
|
|
1303
|
+
<col style="width:58px"><col style="width:66px"><col style="width:66px"><col style="width:54px"><col style="width:66px"><col style="width:86px">
|
|
1304
|
+
<col style="width:58px"><col style="width:66px"><col style="width:66px"><col style="width:54px"><col style="width:66px"><col style="width:86px">
|
|
1305
|
+
<col style="width:92px"><col style="width:82px"><col style="width:64px"></colgroup>`;
|
|
1306
|
+
html+=`<div class="panel"><div class="hd"><h3>Свод и выплаты по половинам месяца · ${m}</h3><div class="spacer"></div><span class="muted small">аванс уменьшает выплату</span></div>
|
|
1307
|
+
<div class="bd" style="padding:0"><div class="tbl-wrap"><table style="font-size:12.5px;table-layout:fixed;min-width:1258px">
|
|
1308
|
+
${cg}<thead>
|
|
1309
|
+
<tr><th rowspan="2">Сотрудник</th><th rowspan="2">Точки</th>
|
|
1310
|
+
<th colspan="6" style="text-align:center;${bl}">Первая половина · 1–16 · ${advDate}</th>
|
|
1311
|
+
<th colspan="6" style="text-align:center;${bl}">Вторая половина · 17–${N} · ${finDate}</th>
|
|
1312
|
+
<th rowspan="2" class="num" style="${bl}">Начислено</th><th rowspan="2" class="num">Выпл. CF</th><th rowspan="2" class="num">Δ</th></tr>
|
|
1313
|
+
<tr><th class="num" style="${bl}">Смены</th><th class="num">Премия</th><th class="num">Штраф</th><th class="num">Надб.</th><th class="num">Аванс</th><th class="num">К выпл.</th>
|
|
1314
|
+
<th class="num" style="${bl}">Смены</th><th class="num">Премия</th><th class="num">Штраф</th><th class="num">Надб.</th><th class="num">Аванс</th><th class="num">К выпл.</th></tr></thead><tbody>`;
|
|
1315
|
+
if(!emps.length)html+=`<tr><td colspan="17"><div class="empty">Нет сотрудников</div></td></tr>`;
|
|
1316
|
+
let totPaid=0;
|
|
1317
|
+
C_.forEach(({e,c})=>{ totPaid+=c.paid; const diff=c.accM-c.paid;
|
|
1318
|
+
html+=`<tr><td style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(e.name)}${e.isManager?' <span class="badge fix">упр.</span>':''}</td><td class="muted small" style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${e.points.map(x=>x.replace('Скандинавский','Ск.')).join(', ')}</td>
|
|
1319
|
+
<td class="num" style="${bl}">${fmt(c.sh1)}</td><td class="num">${inp(e.id,'bonus1',c.a.bonus1)}</td><td class="num">${inp(e.id,'penalty1',c.a.penalty1)}</td><td class="num muted">${c.mgrH?fmt(c.mgrH):'—'}</td><td class="num">${inp(e.id,'adv1',c.a.adv1)}</td><td class="num" style="font-weight:700">${fmt(c.pay1)}</td>
|
|
1320
|
+
<td class="num" style="${bl}">${fmt(c.sh2)}</td><td class="num">${inp(e.id,'bonus2',c.a.bonus2)}</td><td class="num">${inp(e.id,'penalty2',c.a.penalty2)}</td><td class="num muted">${c.mgrH?fmt(c.mgrH):'—'}</td><td class="num">${inp(e.id,'adv2',c.a.adv2)}</td><td class="num" style="font-weight:700">${fmt(c.pay2)}</td>
|
|
1321
|
+
<td class="num" style="${bl}"><b>${fmt(c.accM)}</b></td><td class="num">${fmt(c.paid)}</td><td class="num ${Math.abs(diff)<1?'':'neg'}" style="font-weight:600">${diff?fmt(diff):'0'}</td></tr>`; });
|
|
1322
|
+
html+=`<tr class="subtotal"><td>Итого</td><td></td><td class="num" style="${bl}"></td><td colspan="4"></td><td class="num">${fmt(pay1t)}</td><td class="num" style="${bl}"></td><td colspan="4"></td><td class="num">${fmt(pay2t)}</td><td class="num" style="${bl}">${fmt(grand)}</td><td class="num">${fmt(totPaid)}</td><td class="num ${Math.abs(grand-totPaid)<1?'':'neg'}">${fmt(grand-totPaid)}</td></tr>`;
|
|
1323
|
+
html+=`</tbody></table></div></div><p class="hint" style="padding:0 16px 12px">Смены — из табеля (1–16 и 17–конец). Надб. — половина доплаты управляющему на каждую половину. Аванс уменьшает «К выпл.». «Выпл. CF» — операции с сотрудником; Δ — расхождение начислено↔выплачено. Реквизиты для перевода — в Конструктор → Сотрудники.</p></div>`;
|
|
1324
|
+
ST.points.forEach(p=>{ const pemps=emps.filter(e=>e.points.includes(p)); if(!pemps.length)return; const ts=ST.timesheet[m][p]||{};
|
|
1325
|
+
html+=`<div class="panel"><div class="hd"><h3>${p}</h3><div class="spacer"></div><span class="tag">ФОТ ${fmt(schedSum(m,p))} ₽</span></div>
|
|
1326
|
+
<div class="bd" style="padding:0"><div class="tbl-wrap" style="scrollbar-width:thin;padding-bottom:14px"><table style="font-size:12px;border-collapse:separate;border-spacing:0">
|
|
1327
|
+
<thead><tr><th style="position:sticky;left:0;background:var(--panel);min-width:150px;z-index:2;box-shadow:2px 0 0 var(--line)">Сотрудник</th>`;
|
|
1328
|
+
const gd=(d)=>new Date(YEAR,mi,d).getDay(); const WD=['вс','пн','вт','ср','чт','пт','сб'];
|
|
1329
|
+
for(let d=1;d<=N;d++){ const g=gd(d), we=(g===0||g===6), mon=(g===1&&d!==1), div=d===17?'border-left:2px solid var(--accent)':mon?'border-left:1px solid var(--line2)':'';
|
|
1330
|
+
html+=`<th class="num" style="min-width:64px;padding:6px 8px;${div};${we?'background:rgba(125,125,125,.06)':''}">${d}<div style="font-size:9px;font-weight:400;color:${we?'var(--accent)':'var(--muted)'}">${WD[g]}</div></th>`; }
|
|
1331
|
+
html+=`<th class="num" style="min-width:90px;padding-left:14px">Итого</th></tr></thead><tbody>`;
|
|
1332
|
+
pemps.forEach(e=>{ const d=ts[e.id]||{}; const rowTot=Object.values(d).reduce((s,x)=>s+Number(x||0),0);
|
|
1333
|
+
html+=`<tr><td style="position:sticky;left:0;background:var(--panel);z-index:1;box-shadow:2px 0 0 var(--line)">${esc(e.name)}${e.isManager?' <span class="badge fix">упр.</span>':''}</td>`;
|
|
1334
|
+
for(let day=1;day<=N;day++){ const g=gd(day), we=(g===0||g===6), mon=(g===1&&day!==1), div=day===17?'border-left:2px solid var(--accent)':mon?'border-left:1px solid var(--line2)':''; const v=d[day];
|
|
1335
|
+
html+=`<td style="padding:5px 7px;${div};${we?'background:rgba(125,125,125,.05)':''}"><input ${lk?'disabled':''} value="${v?fmtNum(v):''}" placeholder="${e.rate}" onchange="tsSet('${m}','${p}','${e.id}',${day},this.value)" title="${esc(e.name)}, ${day} ${m} (${WD[g]})" style="width:56px;text-align:right;padding:5px 7px;background:${v?'var(--accentSoft)':'transparent'};border:1px solid ${v?'var(--accent2)':'var(--line2)'};border-radius:6px;color:inherit"></td>`; }
|
|
1336
|
+
html+=`<td class="num" style="padding-left:14px"><b>${fmt(rowTot)}</b></td></tr>`; });
|
|
1337
|
+
html+=`</tbody></table></div></div><div style="padding:8px 16px" class="hint">Впишите сумму за день, если сотрудник работал. Выходные подсвечены, недели разделены линиями, между 16 и 17 — граница половин. Табель листается влево-вправо.</div></div>`;
|
|
1338
|
+
});
|
|
1339
|
+
C.innerHTML=html;
|
|
1340
|
+
}
|
|
1341
|
+
function tsSet(m,point,empId,day,v){ if(isLocked(m)){toast('Месяц закрыт');render();return;} ST.timesheet[m]=ST.timesheet[m]||{}; ST.timesheet[m][point]=ST.timesheet[m][point]||{}; ST.timesheet[m][point][empId]=ST.timesheet[m][point][empId]||{}; const n=parseNum(v); if(n)ST.timesheet[m][point][empId][day]=n; else delete ST.timesheet[m][point][empId][day]; save(); render(); }
|
|
1342
|
+
function adjSet(m,empId,field,v){ if(isLocked(m)){toast('Месяц закрыт');render();return;} ST.payAdj[m]=ST.payAdj[m]||{}; ST.payAdj[m][empId]=ST.payAdj[m][empId]||{}; ST.payAdj[m][empId][field]=parseNum(v)||0; save(); render(); }
|
|
1343
|
+
|
|
1344
|
+
/* ---------- КОНСТРУКТОР ---------- */
|
|
1345
|
+
function renderSettings(C){
|
|
1346
|
+
const tab=ST.ui.settingsTab||'struct';
|
|
1347
|
+
document.getElementById('pageSub').textContent={struct:'Структура отчётов',accounts:'Счета',points:'Точки',staff:'Сотрудники',capital:'Капитал',regl:'Регламент задач',locks:'Закрытие периодов'}[tab];
|
|
1348
|
+
let html=`<div class="row" style="margin-bottom:18px"><div class="seg">
|
|
1349
|
+
<button class="${tab==='struct'?'on':''}" onclick="setSettingsTab('struct')">Структура отчётов</button>
|
|
1350
|
+
<button class="${tab==='accounts'?'on':''}" onclick="setSettingsTab('accounts')">Счета</button>
|
|
1351
|
+
<button class="${tab==='points'?'on':''}" onclick="setSettingsTab('points')">Точки</button>
|
|
1352
|
+
<button class="${tab==='staff'?'on':''}" onclick="setSettingsTab('staff')">Сотрудники</button>
|
|
1353
|
+
<button class="${tab==='capital'?'on':''}" onclick="setSettingsTab('capital')">Капитал</button>
|
|
1354
|
+
<button class="${tab==='regl'?'on':''}" onclick="setSettingsTab('regl')">Регламент</button>
|
|
1355
|
+
<button class="${tab==='locks'?'on':''}" onclick="setSettingsTab('locks')">Закрытие периодов</button></div></div>`;
|
|
1356
|
+
html+=({struct:settStruct,accounts:settAccounts,points:settPoints,staff:settStaff,capital:settCapital,regl:settTodo,locks:settLocks}[tab])();
|
|
1357
|
+
C.innerHTML=html;
|
|
1358
|
+
}
|
|
1359
|
+
function setSettingsTab(t){ ST.ui.settingsTab=t; save(); render(); }
|
|
1360
|
+
function settStruct(){
|
|
1361
|
+
const bs=ST.ui.builderScope; const keyOf={'Общий':'Общий','point':'__points__','cf_direct':'cf_direct','cf_indirect':'cf_indirect'}[bs];
|
|
1362
|
+
const struct=structOf(keyOf); const isCF=bs==='cf_direct'||bs==='cf_indirect';
|
|
1363
|
+
const titles={'Общий':'PnL — общий','point':'PnL — шаблон точек','cf_direct':'CF — прямой метод','cf_indirect':'CF — обратный метод'};
|
|
1364
|
+
let html=`<div class="note">Выберите отчёт и настройте его строки: состав, порядок, источники и знаки. В самих отчётах правятся только цифры.</div>`;
|
|
1365
|
+
html+=`<div class="row" style="margin-bottom:14px"><div class="seg">
|
|
1366
|
+
<button class="${bs==='Общий'?'on':''}" onclick="setBuilder('Общий')">PnL общий</button>
|
|
1367
|
+
<button class="${bs==='point'?'on':''}" onclick="setBuilder('point')">PnL точки</button>
|
|
1368
|
+
<button class="${bs==='cf_direct'?'on':''}" onclick="setBuilder('cf_direct')">CF прямой</button>
|
|
1369
|
+
<button class="${bs==='cf_indirect'?'on':''}" onclick="setBuilder('cf_indirect')">CF обратный</button></div></div>`;
|
|
1370
|
+
html+=`<div class="panel"><div class="hd"><h3>${titles[bs]}</h3><div class="spacer"></div>
|
|
1371
|
+
<button class="mini" onclick="addRow('${keyOf}','value')">+ Строка</button>
|
|
1372
|
+
<button class="mini" onclick="addRow('${keyOf}','subtotal')">+ Подытог</button>
|
|
1373
|
+
<button class="mini" onclick="addRow('${keyOf}','header')">+ Раздел</button>
|
|
1374
|
+
${isCF?`<button class="mini" onclick="addRow('${keyOf}','grand')">+ Итог</button>`:''}
|
|
1375
|
+
<button class="mini" onclick="addRow('${keyOf}','spacer')">+ Отступ</button></div>
|
|
1376
|
+
${bs==='point'?'<div style="padding:10px 16px 0"><span class="note" style="display:block;margin:0">Изменения строк применяются ко всем точкам. Цифры у каждой точки свои.</span></div>':''}
|
|
1377
|
+
<div class="bd" style="padding:0"><div class="tbl-wrap"><table class="pnl" style="table-layout:auto"><thead><tr><th style="width:36%">Строка</th><th>Тип</th><th>Знак</th><th>Источник</th><th style="width:120px"></th></tr></thead><tbody>`;
|
|
1378
|
+
struct.forEach((row,i)=>{
|
|
1379
|
+
if(row.role==='spacer'){ html+=`<tr><td class="muted small">— отступ —</td><td class="muted small">spacer</td><td></td><td></td><td>${bctrls(keyOf,i)}</td></tr>`; return; }
|
|
1380
|
+
const typ=row.role==='value'?'статья':row.role==='subtotal'?'подытог':row.role==='grand'?'ИТОГ':'раздел';
|
|
1381
|
+
const sgn=row.role==='value'?(row.sign===1?'<span class="pos">+</span>':'<span class="neg">−</span>'):'';
|
|
1382
|
+
const src=row.role==='value'?srcLabel(row):'';
|
|
1383
|
+
html+=`<tr class="${row.role==='grand'?'grand':row.role==='subtotal'?'subtotal':row.role==='header'?'header':'value'}">
|
|
1384
|
+
<td class="nmcell"><div class="rowname"><input value="${esc(row.name)}" onchange="editName('${keyOf}','${row.id}',this.value)"></div></td>
|
|
1385
|
+
<td class="small muted">${typ}</td><td class="small">${sgn}</td><td class="small">${src}</td><td>${bctrls(keyOf,i,row)}</td></tr>`;
|
|
1386
|
+
});
|
|
1387
|
+
html+=`</tbody></table></div></div></div>`;
|
|
1388
|
+
return html;
|
|
1389
|
+
}
|
|
1390
|
+
function settAccounts(){
|
|
1391
|
+
let html=`<div class="note">Счета делятся на <b>банковские</b> и <b>наличные</b> — в CF-прямом остатки показываются двумя группами.</div>`;
|
|
1392
|
+
html+=`<div class="panel"><div class="hd"><h3>Счета</h3><div class="spacer"></div><button class="primary mini" onclick="accModal()">+ Счёт</button></div>
|
|
1393
|
+
<div class="bd" style="padding:0"><div class="tbl-wrap"><table><thead><tr><th>Счёт</th><th>Тип</th><th class="num">Остаток на начало</th><th class="num">Текущий остаток</th><th style="width:110px"></th></tr></thead><tbody>`;
|
|
1394
|
+
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>
|
|
1395
|
+
<td><div class="ctrls" style="opacity:1;justify-content:flex-start;gap:4px"><button class="icon" onclick="accModal(${i})">✎</button><button class="icon" onclick="accDel(${i})">✕</button></div></td></tr>`);
|
|
1396
|
+
html+=`</tbody></table></div></div></div>`;
|
|
1397
|
+
return html;
|
|
1398
|
+
}
|
|
1399
|
+
function settPoints(){
|
|
1400
|
+
let html=`<div class="panel"><div class="hd"><h3>Точки (пекарни)</h3><div class="spacer"></div><button class="primary mini" onclick="addPoint()">+ Точка</button></div>
|
|
1401
|
+
<div class="bd" style="padding:0"><div class="tbl-wrap"><table><thead><tr><th>Название</th><th style="width:120px"></th></tr></thead><tbody>`;
|
|
1402
|
+
ST.points.forEach((p,i)=>html+=`<tr><td>${esc(p)}</td><td><div class="ctrls" style="opacity:1;justify-content:flex-start;gap:4px"><button class="icon" onclick="renamePoint(${i})">✎</button><button class="icon" onclick="delPoint(${i})">✕</button></div></td></tr>`);
|
|
1403
|
+
html+=`</tbody></table></div></div></div>`;
|
|
1404
|
+
return html;
|
|
1405
|
+
}
|
|
1406
|
+
function settLocks(){
|
|
1407
|
+
let html=`<div class="note">Закрытый месяц нельзя править ни в одном отчёте. Кварталы и год закрываются автоматически, когда закрыты все месяцы внутри.</div>`;
|
|
1408
|
+
html+=`<div class="panel"><div class="hd"><h3>Закрытие месяцев · 2026</h3></div><div class="bd"><div class="lockgrid">`;
|
|
1409
|
+
MONTHS.forEach((m,i)=>{ const on=isLocked(m); html+=`<div class="lockcell ${on?'on':''}"><span>${MSHORT[i]} · 2026</span><button class="mini ${on?'primary':'ghost'}" onclick="toggleLockMonth('${m}')">${on?'🔒 закрыт':'открыть →'}</button></div>`; });
|
|
1410
|
+
html+=`</div><div class="row" style="margin-top:16px">`;
|
|
1411
|
+
Object.keys(QUARTERS).forEach(q=>{ const lk=periodLocked(q); html+=`<span class="tag ${lk?'lock':''}">${q}: ${lk?'🔒 закрыт':'открыт'}</span>`; });
|
|
1412
|
+
const yl=periodLocked('Год'); html+=`<span class="tag ${yl?'lock':''}">Год: ${yl?'🔒 закрыт':'открыт'}</span></div></div></div>`;
|
|
1413
|
+
return html;
|
|
1414
|
+
}
|
|
1415
|
+
function settCapital(){
|
|
1416
|
+
let html=`<div class="note">Акционеры и доли по месяцам. Впишите долю каждого в нужном месяце — итог считается снизу и подсвечивается красным, если сумма ≠ 100%. Доли можно менять помесячно. Дивиденды (операции «Дивиденды») делятся по долям месяца.</div>`;
|
|
1417
|
+
html+=`<div class="panel"><div class="hd"><h3>Доли по месяцам, %</h3><div class="spacer"></div><button class="primary mini" onclick="shAdd()">+ Акционер</button></div>
|
|
1418
|
+
<div class="bd" style="padding:0"><div class="tbl-wrap"><table style="font-size:12.5px;table-layout:fixed;min-width:934px;border-collapse:separate;border-spacing:0">
|
|
1419
|
+
<colgroup><col style="width:150px">${MONTHS.map(()=>`<col style="width:62px">`).join('')}<col style="width:40px"></colgroup>
|
|
1420
|
+
<thead><tr><th style="position:sticky;left:0;background:var(--panel);z-index:2">Акционер</th>${MONTHS.map((m,i)=>`<th class="num">${MSHORT[i]}</th>`).join('')}<th></th></tr></thead><tbody>`;
|
|
1421
|
+
if(!ST.shareholders.length)html+=`<tr><td colspan="${MONTHS.length+2}"><div class="empty">Акционеры не заданы</div></td></tr>`;
|
|
1422
|
+
ST.shareholders.forEach((sh,i)=>{ html+=`<tr><td style="position:sticky;left:0;background:var(--panel)"><input value="${esc(sh.name)}" onchange="shRename(${i},this.value)" style="background:transparent;border:1px solid transparent;padding:3px 6px;color:inherit;width:100%"></td>`;
|
|
1423
|
+
MONTHS.forEach((m,mi)=>{ html+=`<td style="padding:2px 3px"><input class="money" value="${shareOf(sh.id,mi)||''}" placeholder="0" onchange="capShareSet('${sh.id}',${mi},this.value)" style="width:100%;padding:4px 5px;border:1px solid var(--line2);border-radius:5px;background:transparent;color:inherit"></td>`; });
|
|
1424
|
+
html+=`<td style="text-align:center"><button class="icon" onclick="shDel(${i})">✕</button></td></tr>`; });
|
|
1425
|
+
html+=`<tr class="subtotal"><td style="position:sticky;left:0;background:var(--subbg)">Итого</td>`;
|
|
1426
|
+
MONTHS.forEach((m,mi)=>{ const t=capTotalAt(mi); const bad=Math.abs(t-100)>0.01&&t!==0; html+=`<td class="num" style="${bad?'color:var(--neg)':''}">${t?t+(bad?' ⚠':''):''}</td>`; });
|
|
1427
|
+
html+=`<td></td></tr></tbody></table></div></div></div>`;
|
|
1428
|
+
return html;
|
|
1429
|
+
}
|
|
1430
|
+
function settTodo(){
|
|
1431
|
+
let html=`<div class="note">Регламент — регулярные задачи, которые автоматически появляются в чек-листе на вкладке «Задачи». Настройте периодичность и дедлайн. Разовые задачи добавляются прямо в чек-листе.</div>`;
|
|
1432
|
+
html+=`<div class="panel"><div class="hd"><h3>Шаблоны регулярных задач</h3><div class="spacer"></div><button class="primary mini" onclick="todoRuleModal()">+ Задача</button></div>
|
|
1433
|
+
<div class="bd" style="padding:0"><div class="tbl-wrap"><table><thead><tr><th style="width:40px"></th><th>Задача</th><th>Периодичность</th><th style="width:90px"></th></tr></thead><tbody>`;
|
|
1434
|
+
if(!ST.todoRules.length)html+=`<tr><td colspan="4"><div class="empty">Нет задач в регламенте</div></td></tr>`;
|
|
1435
|
+
ST.todoRules.forEach((r,i)=>html+=`<tr style="${r.active?'':'opacity:.45'}"><td style="text-align:center"><input type="checkbox" ${r.active?'checked':''} onchange="todoRuleToggle(${i})"></td><td><b>${esc(r.title)}</b></td><td class="muted small">${freqLabel(r)}</td>
|
|
1436
|
+
<td><div class="ctrls" style="opacity:1;justify-content:flex-start;gap:4px"><button class="icon" onclick="todoRuleModal(${i})">✎</button><button class="icon" onclick="todoRuleDel(${i})">✕</button></div></td></tr>`);
|
|
1437
|
+
html+=`</tbody></table></div></div></div>`;
|
|
1438
|
+
return html;
|
|
1439
|
+
}
|
|
1440
|
+
function settStaff(){
|
|
1441
|
+
let html=`<div class="note">Единый список сотрудников. Один сотрудник может работать на нескольких точках и быть управляющим (получает доплату). Табель ведётся во вкладке «Расписание и ФОТ».</div>`;
|
|
1442
|
+
html+=`<div class="panel"><div class="hd"><h3>Справочник сотрудников</h3><div class="spacer"></div><button class="primary mini" onclick="empModal()">+ Сотрудник</button></div>
|
|
1443
|
+
<div class="bd" style="padding:0"><div class="tbl-wrap"><table><thead><tr><th>Имя</th><th>Точки</th><th class="num">Ставка</th><th>Роль</th><th>Телефон</th><th>Банк / карта</th><th></th></tr></thead><tbody>`;
|
|
1444
|
+
if(!ST.employees.length)html+=`<tr><td colspan="7"><div class="empty">Нет сотрудников</div></td></tr>`;
|
|
1445
|
+
ST.employees.forEach((e,i)=>html+=`<tr style="${e.active?'':'opacity:.45'}"><td><b>${esc(e.name)}</b>${e.isManager?` <span class="muted small">+${fmt(e.managerBonus)}</span>`:''}</td><td class="muted small">${(e.points||[]).join(', ')||'—'}</td>
|
|
1446
|
+
<td class="num">${fmt(e.rate)}</td><td>${e.isManager?'<span class="badge fix">управляющий</span>':'<span class="muted small">сотрудник</span>'}</td>
|
|
1447
|
+
<td class="small muted">${esc(e.phone||'—')}</td><td class="small muted">${esc(e.bank||'—')}${e.card?`<br>${esc(e.card)}`:''}</td>
|
|
1448
|
+
<td><div class="ctrls" style="opacity:1;justify-content:flex-start;gap:4px"><button class="icon" onclick="empModal(${i})">✎</button><button class="icon" onclick="empDelM(${i})">✕</button></div></td></tr>`);
|
|
1449
|
+
html+=`</tbody></table></div></div></div>`;
|
|
1450
|
+
return html;
|
|
1451
|
+
}
|
|
1452
|
+
function empModal(i){ const ed=(i!=null)?ST.employees[i]:null; const pts=ed?ed.points||[]:[];
|
|
1453
|
+
openModal(ed?'Изменить сотрудника':'Новый сотрудник',`
|
|
1454
|
+
<label class="fld">Имя<input id="em-name" value="${ed?esc(ed.name):''}"></label>
|
|
1455
|
+
<label class="fld">Точки (где работает)<div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:2px">${ST.points.map(p=>`<label style="display:flex;align-items:center;gap:5px;font-size:12.5px;color:var(--text)"><input type="checkbox" class="em-pt" value="${esc(p)}" ${pts.includes(p)?'checked':''}> ${esc(p)}</label>`).join('')}</div></label>
|
|
1456
|
+
<div class="row"><label class="fld" style="flex:1">Ставка за смену, ₽<input id="em-rate" type="number" value="${ed?ed.rate:5000}"></label>
|
|
1457
|
+
<label class="fld" style="flex:1">Активен<select id="em-active"><option value="1" ${!ed||ed.active?'selected':''}>Да</option><option value="0" ${ed&&!ed.active?'selected':''}>Нет</option></select></label></div>
|
|
1458
|
+
<div class="row"><label class="fld" style="flex:1">Управляющий<select id="em-mgr" onchange="document.getElementById('em-mgrbox').style.display=this.value==='1'?'block':'none'"><option value="0" ${!ed||!ed.isManager?'selected':''}>Нет</option><option value="1" ${ed&&ed.isManager?'selected':''}>Да</option></select></label>
|
|
1459
|
+
<label class="fld" style="flex:1" id="em-mgrbox" ${ed&&ed.isManager?'':'style="display:none"'}>Доплата управляющему, ₽/мес<input id="em-mgrb" type="number" value="${ed?ed.managerBonus||0:0}"></label></div>
|
|
1460
|
+
<div style="border-top:1px solid var(--line);margin-top:4px;padding-top:10px"><b style="font-size:12px;color:var(--muted)">Реквизиты для выплат</b></div>
|
|
1461
|
+
<div class="row"><label class="fld" style="flex:1">Телефон<input id="em-phone" value="${ed?esc(ed.phone||''):''}" placeholder="+7 ..."></label>
|
|
1462
|
+
<label class="fld" style="flex:1">Банк<input id="em-bank" value="${ed?esc(ed.bank||''):''}" placeholder="Сбербанк / Т-Банк"></label></div>
|
|
1463
|
+
<label class="fld">Номер карты<input id="em-card" value="${ed?esc(ed.card||''):''}" placeholder="0000 0000 0000 0000"></label>`,
|
|
1464
|
+
()=>{ const points=[...document.querySelectorAll('.em-pt')].filter(c=>c.checked).map(c=>c.value);
|
|
1465
|
+
const rec={name:val('em-name').trim(),points,rate:Number(val('em-rate'))||0,isManager:val('em-mgr')==='1',managerBonus:Number(val('em-mgrb'))||0,active:val('em-active')==='1',phone:val('em-phone'),bank:val('em-bank'),card:val('em-card')};
|
|
1466
|
+
if(!rec.name)return; if(ed)Object.assign(ed,rec); else ST.employees.push({id:uid(),...rec}); save(); render(); });
|
|
1467
|
+
}
|
|
1468
|
+
function empDelM(i){ if(confirm('Удалить сотрудника? Записи табеля останутся в истории.')){ ST.employees.splice(i,1); save(); render(); } }
|
|
1469
|
+
function shAdd(){ ST.shareholders.push({id:uid(),name:'Новый акционер'}); save(); render(); }
|
|
1470
|
+
function shRename(i,v){ ST.shareholders[i].name=v.trim()||ST.shareholders[i].name; save(); }
|
|
1471
|
+
function shDel(i){ if(confirm('Удалить акционера?')){ const id=ST.shareholders[i].id; ST.shareholders.splice(i,1); Object.keys(ST.capShares||{}).forEach(mi=>{ if(ST.capShares[mi])delete ST.capShares[mi][id]; }); save(); render(); } }
|
|
1472
|
+
function capShareSet(shId,mi,v){ ST.capShares=ST.capShares||{}; ST.capShares[mi]=ST.capShares[mi]||{}; const n=parseNum(v); if(n==null)delete ST.capShares[mi][shId]; else ST.capShares[mi][shId]=n; save(); render(); }
|
|
1473
|
+
function srcLabel(row){ const m={manual:'ручной',ops:'операции',fixed:'пост.: '+(row.cat||''),fixed_rest:'пост. (прочее)',ops_cat:'операции: '+(row.cat||''),ops_rest:'операции (прочее)',income:'выручка',schedule:'ФОТ',pnl:'чистая приб.',tax:'налоги (модуль)'}; return m[row.source]||row.source; }
|
|
1474
|
+
function bctrls(scope,i,row){ return `<div class="ctrls" style="opacity:1">
|
|
1475
|
+
<button onclick="moveRow('${scope}',${i},-1)">↑</button><button onclick="moveRow('${scope}',${i},1)">↓</button>
|
|
1476
|
+
${row&&row.role==='value'?`<button onclick="rowSource('${scope}','${row.id}')" title="Источник/знак">⚙</button>`:''}
|
|
1477
|
+
${row&&row.role==='subtotal'?`<button onclick="toggleGrand('${scope}','${row.id}')" title="Сделать итогом">${row.grand?'●':'○'}</button>`:''}
|
|
1478
|
+
<button onclick="delRow('${scope}',${i})">✕</button></div>`; }
|
|
1479
|
+
function setBuilder(s){ ST.ui.builderScope=s; save(); render(); }
|
|
1480
|
+
function editName(scope,id,v){ const r=structOf(scope).find(x=>x.id===id); if(r){ r.name=v; if(r.role==='value'&&r.source==='manual'&&(scope==='Общий'||scope==='__points__'))r.cat=v; save(); } }
|
|
1481
|
+
function moveRow(scope,i,dir){ const a=structOf(scope); const j=i+dir; if(j<0||j>=a.length)return; [a[i],a[j]]=[a[j],a[i]]; save(); render(); }
|
|
1482
|
+
function delRow(scope,i){ if(confirm('Удалить строку?')){ structOf(scope).splice(i,1); save(); render(); } }
|
|
1483
|
+
function addRow(scope,role){ const name=role==='subtotal'?'Новый подытог':role==='header'?'Новый раздел':role==='grand'?'ИТОГО':role==='spacer'?'':'Новая статья';
|
|
1484
|
+
structOf(scope).push({id:uid(),name,role,sign:-1,source:'manual',cat:name}); save(); render(); }
|
|
1485
|
+
function toggleGrand(scope,id){ const r=structOf(scope).find(x=>x.id===id); if(r){ r.grand=!r.grand; save(); render(); } }
|
|
1486
|
+
function rowSource(scope,id){ const r=structOf(scope).find(x=>x.id===id); if(!r)return; const isCF=scope==='cf_direct'||scope==='cf_indirect';
|
|
1487
|
+
const opts= isCF? [['manual','Ручной ввод (по периоду)'],['income','Выручка (все поступления)'],['ops_cat','Из «Операции» по категории'],['ops_rest','Из «Операции» (прочее)'],['fixed','Из «Постоянные расходы» по категории'],['fixed_rest','Постоянные расходы (прочее)'],['schedule','ФОТ из «Расписание»'],['pnl','Чистая прибыль из PnL']]
|
|
1488
|
+
: [['manual','Ручной ввод'],['ops','Из «Операции»'],['fixed','Из «Постоянные расходы»'],['schedule','Из «Расписание/ФОТ»'],['tax','Налоги (модуль: патент / доля выручки)']];
|
|
1489
|
+
openModal('Источник и знак строки',`
|
|
1490
|
+
<label class="fld">Название<input id="ps-name" value="${esc(r.name)}"></label>
|
|
1491
|
+
<label class="fld">Знак<select id="ps-sign"><option value="1" ${r.sign===1?'selected':''}>Плюс (+)</option><option value="-1" ${r.sign===-1?'selected':''}>Минус (−)</option></select></label>
|
|
1492
|
+
<label class="fld">Источник<select id="ps-src">${opts.map(o=>`<option value="${o[0]}" ${r.source===o[0]?'selected':''}>${o[1]}</option>`).join('')}</select></label>
|
|
1493
|
+
<label class="fld">Категория для сопоставления<input id="ps-cat" value="${esc(r.cat||'')}" placeholder="напр. Аренда / Налоги"></label>`,
|
|
1494
|
+
()=>{ r.name=val('ps-name'); r.sign=Number(val('ps-sign')); r.source=val('ps-src'); r.cat=val('ps-cat'); save(); render(); });
|
|
1495
|
+
}
|
|
1496
|
+
function accModal(i){ const ed=(i!=null)?ST.accounts[i]:null; const t=ed?accType(ed):'bank';
|
|
1497
|
+
openModal(ed?'Изменить счёт':'Новый счёт',`
|
|
1498
|
+
<label class="fld">Название<input id="ac-name" value="${ed?esc(ed.name):''}" placeholder="Сбер / Наличные / Точка"></label>
|
|
1499
|
+
<label class="fld">Тип<select id="ac-type"><option value="bank" ${t==='bank'?'selected':''}>Банковский счёт</option><option value="cash" ${t==='cash'?'selected':''}>Наличные (касса)</option></select></label>
|
|
1500
|
+
<label class="fld">Остаток на начало года, ₽<input id="ac-open" type="number" value="${ed?ed.opening:0}"></label>`,
|
|
1501
|
+
()=>{ const name=val('ac-name').trim(); const opening=Number(val('ac-open'))||0; const type=val('ac-type'); if(!name)return;
|
|
1502
|
+
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;}); }
|
|
1503
|
+
else ST.accounts.push({id:uid(),name,opening,type}); save(); render(); });
|
|
1504
|
+
}
|
|
1505
|
+
function accDel(i){ const a=ST.accounts[i]; if(confirm('Удалить счёт «'+a.name+'»?')){ ST.accounts.splice(i,1); save(); render(); } }
|
|
1506
|
+
function addPoint(){ openModal('Новая точка',`<label class="fld">Название точки<input id="np-name" placeholder="Например: Ленина 12"></label>`,
|
|
1507
|
+
()=>{ const n=val('np-name').trim(); if(!n)return; ST.points.push(n); ST.data[n]={}; ST.taxConfig=ST.taxConfig||{}; if(!ST.taxConfig[n])ST.taxConfig[n]={mode:'revenue'}; save(); render(); }); }
|
|
1508
|
+
function renamePoint(i){ const old=ST.points[i]; openModal('Переименовать точку',`<label class="fld">Название<input id="rp-name" value="${esc(old)}"></label>`,
|
|
1509
|
+
()=>{ const n=val('rp-name').trim(); if(!n||n===old)return; ST.points[i]=n;
|
|
1510
|
+
if(ST.data[old]){ST.data[n]=ST.data[old];delete ST.data[old];}
|
|
1511
|
+
ST.fixed.forEach(f=>{if(f.point===old)f.point=n;}); ST.operations.forEach(o=>{if(o.point===old)o.point=n;});
|
|
1512
|
+
Object.keys(ST.timesheet||{}).forEach(mo=>{ const m=ST.timesheet[mo]; if(m&&m[old]){m[n]=m[old];delete m[old];} });
|
|
1513
|
+
[ST.audits,ST.checks].forEach(map=>Object.keys(map||{}).forEach(mo=>{ const m=map[mo]; if(m&&m[old]!==undefined){m[n]=m[old];delete m[old];} }));
|
|
1514
|
+
[ST.invOpening,ST.inventory,ST.taxConfig].forEach(map=>{ if(map&&map[old]!==undefined){map[n]=map[old];delete map[old];} });
|
|
1515
|
+
(ST.employees||[]).forEach(e=>{ e.points=(e.points||[]).map(x=>x===old?n:x); });
|
|
1516
|
+
save(); render(); }); }
|
|
1517
|
+
function delPoint(i){ const p=ST.points[i]; if(confirm('Удалить точку «'+p+'» и её данные?')){ bkCreate('Перед удалением точки «'+p+'»',true,true); ST.points.splice(i,1); delete ST.data[p];
|
|
1518
|
+
ST.fixed=ST.fixed.filter(f=>f.point!==p); ST.operations=ST.operations.filter(o=>o.point!==p);
|
|
1519
|
+
Object.keys(ST.timesheet||{}).forEach(mo=>{ if(ST.timesheet[mo])delete ST.timesheet[mo][p]; });
|
|
1520
|
+
[ST.audits,ST.checks].forEach(map=>Object.keys(map||{}).forEach(mo=>{ if(map[mo])delete map[mo][p]; }));
|
|
1521
|
+
delete (ST.invOpening||{})[p]; delete (ST.inventory||{})[p]; delete (ST.taxConfig||{})[p];
|
|
1522
|
+
(ST.employees||[]).forEach(e=>{ e.points=(e.points||[]).filter(x=>x!==p); });
|
|
1523
|
+
save(); render(); } }
|
|
1524
|
+
|
|
1525
|
+
/* ---------- utils ---------- */
|
|
1526
|
+
function openModal(title,body,onSave){ const root=document.getElementById('modalRoot');
|
|
1527
|
+
root.innerHTML=`<div class="modal-bg" onclick="if(event.target===this)closeModal()"><div class="modal"><div class="mh"><h3>${title}</h3></div><div class="mb">${body}</div>
|
|
1528
|
+
<div class="mf"><button class="ghost" onclick="closeModal()">Отмена</button><button class="primary" id="modalSave">Сохранить</button></div></div></div>`;
|
|
1529
|
+
document.getElementById('modalSave').onclick=()=>{ onSave(); closeModal(); }; }
|
|
1530
|
+
function closeModal(){ document.getElementById('modalRoot').innerHTML=''; }
|
|
1531
|
+
function val(id){ const e=document.getElementById(id); return e?e.value:''; }
|
|
1532
|
+
function num(v){ return Number(v)||0; }
|
|
1533
|
+
function esc(s){ return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
|
1534
|
+
|
|
1535
|
+
|
|
1536
|
+
|
|
1537
|
+
/* ============================================================ ОБЛАКО ============================================================ */
|
|
1538
|
+
let LAST_EDIT=0, CLOUD_T=null, CLOUD_POLL=0;
|
|
1539
|
+
const CLOUD={ on:false, status:'init', savedAt:null, err:'', busy:false, pending:false };
|
|
1540
|
+
function cloudPossible(){ return location.protocol==='http:'||location.protocol==='https:'; }
|
|
1541
|
+
function hhmm(iso){ try{ const d=new Date(iso), p=x=>String(x).padStart(2,'0'); return p(d.getHours())+':'+p(d.getMinutes()); }catch(e){ return ''; } }
|
|
1542
|
+
function setCloud(status,savedAt,err){
|
|
1543
|
+
CLOUD.status=status; if(savedAt!==undefined&&savedAt!==null)CLOUD.savedAt=savedAt; CLOUD.err=err||'';
|
|
1544
|
+
const el=document.getElementById('cloudTag'); if(!el)return;
|
|
1545
|
+
const M={
|
|
1546
|
+
init: ['…','' ,'проверяю связь с облаком'],
|
|
1547
|
+
offline:['○ локальный файл','','Файл открыт с диска — данные хранятся только в этом браузере'],
|
|
1548
|
+
nostore:['○ облако не подключено','warn','Хранилище Vercel Blob ещё не создано. Данные пока живут только в этом браузере'],
|
|
1549
|
+
saving: ['⟳ сохраняю…','warn','идёт запись в облако'],
|
|
1550
|
+
synced: ['● в облаке'+(CLOUD.savedAt?' · '+hhmm(CLOUD.savedAt):''),'ok','Все изменения сохранены в облаке. Нажмите, чтобы синхронизировать вручную'],
|
|
1551
|
+
error: ['⚠ не сохранено','bad','Не удалось записать в облако: '+(CLOUD.err||'нет связи')+'. Данные в браузере целы. Нажмите, чтобы повторить'],
|
|
1552
|
+
};
|
|
1553
|
+
const m=M[status]||M.init;
|
|
1554
|
+
el.textContent=m[0]; el.className='cloudtag '+(m[1]||''); el.title=m[2];
|
|
1555
|
+
}
|
|
1556
|
+
async function cloudGet(){ const r=await fetch('/api/state',{cache:'no-store'}); return r.json(); }
|
|
1557
|
+
async function cloudPut(){
|
|
1558
|
+
const r=await fetch('/api/state',{method:'PUT',headers:{'Content-Type':'application/json'},
|
|
1559
|
+
body:JSON.stringify({state:ST,ts:Number(ST._ts||Date.now())})});
|
|
1560
|
+
return r.json();
|
|
1561
|
+
}
|
|
1562
|
+
async function cloudBoot(){
|
|
1563
|
+
if(!cloudPossible()){ setCloud('offline'); return; }
|
|
1564
|
+
setCloud('init');
|
|
1565
|
+
let d; try{ d=await cloudGet(); }catch(e){ setCloud('error',null,'нет связи'); return; }
|
|
1566
|
+
if(!d||!d.ok){ setCloud(d&&d.reason==='no-store'?'nostore':'error',null,d&&d.reason); return; }
|
|
1567
|
+
CLOUD.on=true;
|
|
1568
|
+
const localTs=BOOT_FRESH?-1:Number(ST._ts||0), cloudTs=Number(d.ts||0);
|
|
1569
|
+
const localEmpty=BOOT_FRESH||(!(ST.operations||[]).length&&!bkCells(ST)&&!(ST.employees||[]).length);
|
|
1570
|
+
if(d.state && (localEmpty || cloudTs>localTs)){
|
|
1571
|
+
if((ST.operations||[]).length||bkCells(ST)) bkCreate('Перед загрузкой из облака',true,true);
|
|
1572
|
+
ST=d.state; migrate(); schemaGuard(); saveLocal(); render();
|
|
1573
|
+
setCloud('synced',d.savedAt); toast('Загружены данные из облака');
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
if(!d.state || localTs>cloudTs){ setCloud('saving'); await cloudSync(); return; }
|
|
1577
|
+
setCloud('synced',d.savedAt);
|
|
1578
|
+
}
|
|
1579
|
+
function cloudSchedule(){ if(!CLOUD.on)return; if(CLOUD_T)clearTimeout(CLOUD_T); setCloud('saving'); CLOUD_T=setTimeout(()=>cloudSync(),2500); }
|
|
1580
|
+
async function cloudSync(){
|
|
1581
|
+
if(!CLOUD.on){ if(cloudPossible()) return cloudBoot(); return; }
|
|
1582
|
+
if(CLOUD.busy){ CLOUD.pending=true; return; }
|
|
1583
|
+
CLOUD.busy=true; setCloud('saving');
|
|
1584
|
+
try{
|
|
1585
|
+
const d=await cloudPut();
|
|
1586
|
+
if(d&&d.ok) setCloud('synced',d.savedAt);
|
|
1587
|
+
else setCloud('error',null,(d&&d.reason)||'отказ сервера');
|
|
1588
|
+
}catch(e){ setCloud('error',null,'нет связи'); }
|
|
1589
|
+
CLOUD.busy=false;
|
|
1590
|
+
if(CLOUD.pending){ CLOUD.pending=false; cloudSchedule(); }
|
|
1591
|
+
}
|
|
1592
|
+
function cloudFlush(){
|
|
1593
|
+
if(!CLOUD.on||CLOUD.status==='synced') return;
|
|
1594
|
+
try{ navigator.sendBeacon('/api/state', new Blob([JSON.stringify({state:ST,ts:Number(ST._ts||Date.now())})],{type:'application/json'})); }catch(e){}
|
|
1595
|
+
}
|
|
1596
|
+
async function cloudPollTick(){
|
|
1597
|
+
/* подхватываем правки с другого устройства, только когда сами ничего не редактируем */
|
|
1598
|
+
if(!CLOUD.on||CLOUD.busy||CLOUD.status!=='synced') return;
|
|
1599
|
+
if(Date.now()-LAST_EDIT<60000) return;
|
|
1600
|
+
if(Date.now()-CLOUD_POLL<60000) return;
|
|
1601
|
+
CLOUD_POLL=Date.now();
|
|
1602
|
+
try{
|
|
1603
|
+
const d=await cloudGet();
|
|
1604
|
+
if(d&&d.ok&&d.state&&Number(d.ts||0)>Number(ST._ts||0)){
|
|
1605
|
+
bkCreate('Перед подхватом правок с другого устройства',false,true);
|
|
1606
|
+
ST=d.state; migrate(); schemaGuard(); saveLocal(); render();
|
|
1607
|
+
setCloud('synced',d.savedAt); toast('Подтянуты изменения с другого устройства');
|
|
1608
|
+
}
|
|
1609
|
+
}catch(e){}
|
|
1610
|
+
}
|
|
1611
|
+
async function cloudLoadNow(){
|
|
1612
|
+
if(!CLOUD.on){ toast('Облако не подключено'); return; }
|
|
1613
|
+
let d; try{ d=await cloudGet(); }catch(e){ toast('Нет связи'); return; }
|
|
1614
|
+
if(!d||!d.ok||!d.state){ toast('В облаке пока пусто'); return; }
|
|
1615
|
+
if(!confirm('Заменить текущие данные версией из облака?\n\nТекущее состояние сначала сохранится копией.'))return;
|
|
1616
|
+
bkCreate('Перед ручной загрузкой из облака',true,true);
|
|
1617
|
+
ST=d.state; migrate(); schemaGuard(); saveLocal(); render(); setCloud('synced',d.savedAt); toast('Загружено из облака');
|
|
1618
|
+
}
|
|
1619
|
+
async function cloudHistLoad(){
|
|
1620
|
+
const box=document.getElementById('cloudHist'); if(!box)return;
|
|
1621
|
+
if(!CLOUD.on){ box.innerHTML='<div class="empty">Облако не подключено</div>'; return; }
|
|
1622
|
+
box.innerHTML='<div class="empty">Загружаю…</div>';
|
|
1623
|
+
let d; try{ d=await (await fetch('/api/history',{cache:'no-store'})).json(); }catch(e){ box.innerHTML='<div class="empty">Нет связи</div>'; return; }
|
|
1624
|
+
if(!d.ok||!d.items||!d.items.length){ box.innerHTML='<div class="empty">Облачных снимков пока нет</div>'; return; }
|
|
1625
|
+
let h=`<div class="tbl-wrap"><table><thead><tr><th style="width:170px">Дата и время</th><th class="num">Размер</th><th style="width:150px">Действие</th></tr></thead><tbody>`;
|
|
1626
|
+
d.items.forEach(it=>{ const dt=new Date(it.at); const p=x=>String(x).padStart(2,'0');
|
|
1627
|
+
h+=`<tr><td class="small">${p(dt.getDate())}.${p(dt.getMonth()+1)}.${dt.getFullYear()} ${p(dt.getHours())}:${p(dt.getMinutes())}</td>
|
|
1628
|
+
<td class="num small">${bkSize(it.size||0)}</td>
|
|
1629
|
+
<td><button class="mini ghost" onclick="cloudHistRestore('${it.path}')">Восстановить</button></td></tr>`; });
|
|
1630
|
+
box.innerHTML=h+'</tbody></table></div>';
|
|
1631
|
+
}
|
|
1632
|
+
async function cloudHistRestore(path){
|
|
1633
|
+
if(!confirm('Восстановить облачный снимок?\n\nТекущее состояние сначала сохранится копией.'))return;
|
|
1634
|
+
let d; try{ d=await (await fetch('/api/history?path='+encodeURIComponent(path),{cache:'no-store'})).json(); }catch(e){ toast('Нет связи'); return; }
|
|
1635
|
+
if(!d.ok||!d.state){ toast('Снимок недоступен'); return; }
|
|
1636
|
+
bkCreate('Перед восстановлением облачного снимка',true,true);
|
|
1637
|
+
ST=d.state; migrate(); schemaGuard(); save(); render(); toast('Снимок восстановлен');
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
/* ============================================================ РЕЗЕРВНЫЕ КОПИИ ============================================================ */
|
|
1641
|
+
let BK_DIRTY=false, BK_TIMER=null, BK_LASTTS=0;
|
|
1642
|
+
function bkRead(){ try{ return JSON.parse(localStorage.getItem(BACKUP_KEY)||'[]'); }catch(e){ return []; } }
|
|
1643
|
+
function bkPersist(list){
|
|
1644
|
+
for(let guard=0; guard<40; guard++){
|
|
1645
|
+
try{ localStorage.setItem(BACKUP_KEY, JSON.stringify(list)); return true; }
|
|
1646
|
+
catch(e){ let i=list.length-1; while(i>=0 && list[i].pin) i--;
|
|
1647
|
+
if(i<0){ try{ localStorage.removeItem(BACKUP_KEY); }catch(e2){} return false; }
|
|
1648
|
+
list.splice(i,1); }
|
|
1649
|
+
}
|
|
1650
|
+
return false;
|
|
1651
|
+
}
|
|
1652
|
+
function bkTrim(list){ let n=0;
|
|
1653
|
+
for(let i=0;i<list.length;i++){ if(!list[i].pin){ n++; if(n>BACKUP_MAX){ list.splice(i,1); i--; } } }
|
|
1654
|
+
return list; }
|
|
1655
|
+
function bkStamp(d){ const p=x=>String(x).padStart(2,'0');
|
|
1656
|
+
return `${p(d.getDate())}.${p(d.getMonth()+1)}.${d.getFullYear()} ${p(d.getHours())}:${p(d.getMinutes())}`; }
|
|
1657
|
+
function bkCells(st){ let n=0; const d=(st&&st.data)||{};
|
|
1658
|
+
Object.keys(d).forEach(sc=>{ const m=d[sc]||{}; Object.keys(m).forEach(mo=>{ n+=Object.keys(m[mo]||{}).length; }); }); return n; }
|
|
1659
|
+
function bkSize(n){ return n>=1048576 ? (n/1048576).toFixed(1)+' МБ' : Math.max(1,Math.round(n/1024))+' КБ'; }
|
|
1660
|
+
function bkCreate(label,pin,silent){
|
|
1661
|
+
if(!STORAGE_OK) return false;
|
|
1662
|
+
let payload; try{ payload=JSON.stringify(ST); }catch(e){ return false; }
|
|
1663
|
+
const list=bkRead();
|
|
1664
|
+
if(!pin && list.length && list[0].payload===payload){ BK_DIRTY=false; BK_LASTTS=Date.now(); return false; }
|
|
1665
|
+
const now=new Date();
|
|
1666
|
+
list.unshift({ id:uid(), ts:now.getTime(), when:bkStamp(now), label:label||'Автоснимок', pin:!!pin,
|
|
1667
|
+
size:payload.length, ops:(ST.operations||[]).length, cells:bkCells(ST), payload });
|
|
1668
|
+
bkTrim(list);
|
|
1669
|
+
const ok=bkPersist(list);
|
|
1670
|
+
BK_DIRTY=false; BK_LASTTS=Date.now();
|
|
1671
|
+
if(!silent) toast(ok?'Копия сохранена':'Не хватило места для копии');
|
|
1672
|
+
return ok;
|
|
1673
|
+
}
|
|
1674
|
+
function bkRestore(id){
|
|
1675
|
+
const list=bkRead(), b=list.find(x=>x.id===id); if(!b)return;
|
|
1676
|
+
if(!confirm('Восстановить состояние от '+b.when+' («'+b.label+'»)?\n\nТекущие данные сначала сохранятся отдельной копией.'))return;
|
|
1677
|
+
bkCreate('Перед восстановлением от '+b.when,true,true);
|
|
1678
|
+
let next; try{ next=JSON.parse(b.payload); }catch(e){ alert('Копия повреждена'); return; }
|
|
1679
|
+
ST=next; migrate(); save(); render(); toast('Восстановлено: '+b.when);
|
|
1680
|
+
}
|
|
1681
|
+
function bkDownload(id){
|
|
1682
|
+
const b=bkRead().find(x=>x.id===id); if(!b)return;
|
|
1683
|
+
const blob=new Blob([b.payload],{type:'application/json'}); const a=document.createElement('a');
|
|
1684
|
+
a.href=URL.createObjectURL(blob);
|
|
1685
|
+
a.download='buhanka-'+new Date(b.ts).toISOString().slice(0,16).replace(/[:T]/g,'-')+'.json'; a.click();
|
|
1686
|
+
}
|
|
1687
|
+
function bkDelete(id){ const b=bkRead().find(x=>x.id===id); if(!b)return;
|
|
1688
|
+
if(!confirm('Удалить копию от '+b.when+'?'))return;
|
|
1689
|
+
bkPersist(bkRead().filter(x=>x.id!==id)); render(); }
|
|
1690
|
+
function bkTogglePin(id){ const list=bkRead(), b=list.find(x=>x.id===id); if(!b)return; b.pin=!b.pin; bkTrim(list); bkPersist(list); render(); }
|
|
1691
|
+
function bkManual(){ openModal('Создать копию',
|
|
1692
|
+
`<label class="fld">Название копии<input id="bk-label" placeholder="Например: январь заполнен"></label>
|
|
1693
|
+
<p class="hint">Ручные копии закрепляются и никогда не вытесняются автоснимками.</p>`,
|
|
1694
|
+
()=>{ bkCreate(val('bk-label').trim()||'Ручная копия', true); render(); }); }
|
|
1695
|
+
function bootNote(){
|
|
1696
|
+
const v=document.getElementById('verTag'); if(v)v.textContent='v'+APP_VERSION;
|
|
1697
|
+
if(!BOOT_NOTE) return;
|
|
1698
|
+
if(BOOT_NOTE==='LEGACY'){ setTimeout(()=>toast('Найдены данные прошлой версии файла — раздел «Бэкапы»'),700); }
|
|
1699
|
+
else { setTimeout(()=>toast(BOOT_NOTE),700); }
|
|
1700
|
+
}
|
|
1701
|
+
function bkStart(){
|
|
1702
|
+
if(!STORAGE_OK) return;
|
|
1703
|
+
if(!bkRead().length) bkCreate('Стартовое состояние', true, true);
|
|
1704
|
+
if(BK_TIMER) clearInterval(BK_TIMER);
|
|
1705
|
+
BK_TIMER=setInterval(()=>{ if(BK_DIRTY){ if(Date.now()-BK_LASTTS>=BACKUP_EVERY_MS) bkCreate('Автоснимок',false,true); autoDownload(); }
|
|
1706
|
+
if(CLOUD.on&&CLOUD.status==='error') cloudSync(); else cloudPollTick(); }, 30000);
|
|
1707
|
+
window.addEventListener('beforeunload', ()=>{ if(BK_DIRTY) bkCreate('Автоснимок (закрытие вкладки)',false,true); cloudFlush(); });
|
|
1708
|
+
document.addEventListener('visibilitychange', ()=>{ if(document.hidden){ if(BK_DIRTY)bkCreate('Автоснимок',false,true); cloudFlush(); } else cloudPollTick(); });
|
|
1709
|
+
}
|
|
1710
|
+
function dataPassport(){
|
|
1711
|
+
let months=0; MONTHS.forEach(mo=>{ if(monthActive('Общий',mo)) months++; });
|
|
1712
|
+
return [
|
|
1713
|
+
['Точек', (ST.points||[]).length], ['Сотрудников', (ST.employees||[]).length],
|
|
1714
|
+
['Операций', (ST.operations||[]).length], ['Заполнено ячеек PnL', bkCells(ST)],
|
|
1715
|
+
['Месяцев с данными', months], ['Постоянных расходов', (ST.fixed||[]).length],
|
|
1716
|
+
['Счетов', (ST.accounts||[]).length], ['Кредитов', (ST.loans||[]).length],
|
|
1717
|
+
['Статей PnL', (ST.structures&&ST.structures.point||[]).length],
|
|
1718
|
+
['Закрытых месяцев', Object.keys(ST.locks||{}).length],
|
|
1719
|
+
];
|
|
1720
|
+
}
|
|
1721
|
+
function integrity(){
|
|
1722
|
+
const bad=[];
|
|
1723
|
+
const ids=new Set(); [(ST.structures||{}).general,(ST.structures||{}).point,(ST.cfStructures||{}).direct,(ST.cfStructures||{}).indirect]
|
|
1724
|
+
.forEach(arr=>(arr||[]).forEach(r=>ids.add(r.id)));
|
|
1725
|
+
let orphan=0;
|
|
1726
|
+
Object.keys(ST.data||{}).forEach(sc=>Object.keys(ST.data[sc]||{}).forEach(mo=>Object.keys(ST.data[sc][mo]||{}).forEach(id=>{ if(!ids.has(id))orphan++; })));
|
|
1727
|
+
if(orphan) bad.push('Ячеек без статьи: '+orphan+' — менялась структура статей, цифры повисли');
|
|
1728
|
+
const pts=new Set(ST.points||[]);
|
|
1729
|
+
const badOps=(ST.operations||[]).filter(o=>o.point&&o.point!=='Общий'&&!pts.has(o.point)).length;
|
|
1730
|
+
if(badOps) bad.push('Операций на удалённых точках: '+badOps);
|
|
1731
|
+
const badFix=(ST.fixed||[]).filter(f=>!pts.has(f.point)).length;
|
|
1732
|
+
if(badFix) bad.push('Постоянных расходов на удалённых точках: '+badFix);
|
|
1733
|
+
const emp=new Set((ST.employees||[]).map(e=>e.id));
|
|
1734
|
+
let badTs=0; Object.values(ST.timesheet||{}).forEach(m=>Object.values(m||{}).forEach(p=>Object.keys(p||{}).forEach(id=>{ if(!emp.has(id))badTs++; })));
|
|
1735
|
+
if(badTs) bad.push('Записей табеля без сотрудника: '+badTs);
|
|
1736
|
+
const dataScopes=Object.keys(ST.data||{}).filter(k=>k!=='Общий'&&!pts.has(k)).length;
|
|
1737
|
+
if(dataScopes) bad.push('Наборов цифр по несуществующим точкам: '+dataScopes);
|
|
1738
|
+
return bad;
|
|
1739
|
+
}
|
|
1740
|
+
function renderBackup(C){
|
|
1741
|
+
document.getElementById('pageSub').textContent='автоснимки и восстановление данных';
|
|
1742
|
+
const list=bkRead(); const total=list.reduce((s,b)=>s+(b.size||0),0);
|
|
1743
|
+
let cur=0; try{ cur=JSON.stringify(ST).length; }catch(e){}
|
|
1744
|
+
const last=list[0];
|
|
1745
|
+
let html=`<div class="cards">
|
|
1746
|
+
<div class="card"><div class="k">Копий сохранено</div><div class="v">${list.length}</div><div class="d muted">авто ${list.filter(b=>!b.pin).length} · закреплено ${list.filter(b=>b.pin).length}</div></div>
|
|
1747
|
+
<div class="card"><div class="k">Последняя копия</div><div class="v" style="font-size:19px">${last?last.when:'—'}</div><div class="d muted">${last?esc(last.label):'копий пока нет'}</div></div>
|
|
1748
|
+
<div class="card"><div class="k">Текущие данные</div><div class="v" style="font-size:19px">${bkSize(cur)}</div><div class="d muted">${fmt((ST.operations||[]).length)} операций · ${fmt(bkCells(ST))} ячеек</div></div>
|
|
1749
|
+
<div class="card"><div class="k">Занято копиями</div><div class="v" style="font-size:19px">${bkSize(total)}</div><div class="d muted">храним ${BACKUP_MAX} последних автоснимков</div></div>
|
|
1750
|
+
</div>`;
|
|
1751
|
+
if(!STORAGE_OK) html+=`<p class="hint" style="color:var(--red)">Браузер не даёт сохранять данные — автоснимки не работают. Сохраняйте данные кнопкой «Экспорт в файл».</p>`;
|
|
1752
|
+
|
|
1753
|
+
const cs=CLOUD.status;
|
|
1754
|
+
const csText={init:'проверяю связь',offline:'локальный файл — облака нет',nostore:'хранилище не подключено',
|
|
1755
|
+
saving:'идёт сохранение',synced:'всё сохранено'+(CLOUD.savedAt?' в '+hhmm(CLOUD.savedAt):''),error:'ошибка: '+(CLOUD.err||'нет связи')}[cs]||cs;
|
|
1756
|
+
html+=`<div class="panel"><div class="hd"><h3>Облако</h3><div class="spacer"></div>
|
|
1757
|
+
<span class="tag ${cs==='synced'?'':'lock'}">${csText}</span>
|
|
1758
|
+
<button class="mini primary" onclick="cloudSync()">Сохранить в облако</button>
|
|
1759
|
+
<button class="mini ghost" onclick="cloudLoadNow()">Загрузить из облака</button></div>
|
|
1760
|
+
<div class="bd">
|
|
1761
|
+
<p class="hint" style="margin:0 0 10px">Данные хранятся в облаке проекта и не зависят от браузера: обновление версии приложения их не трогает. Браузер держит локальную копию — если связь пропадёт, работа продолжится, а изменения уедут в облако при первой возможности.</p>
|
|
1762
|
+
<div id="cloudHist"><div class="empty">—</div></div>
|
|
1763
|
+
</div></div>`;
|
|
1764
|
+
|
|
1765
|
+
const ld=lastDownload(), today=dlDay();
|
|
1766
|
+
html+=`<div class="panel"><div class="hd"><h3>Копия вне браузера</h3><div class="spacer"></div>
|
|
1767
|
+
<span class="tag ${ld===today?'':'lock'}">${ld?('последняя выгрузка: '+ld):'выгрузок ещё не было'}</span>
|
|
1768
|
+
<button class="mini primary" onclick="exportData();render()">↓ Скачать сейчас</button></div>
|
|
1769
|
+
<div class="bd"><p class="hint" style="margin:0">Файл сам сохраняет JSON в «Загрузки» один раз в день — при первом изменении данных. Это единственная копия, которая переживёт очистку браузера, переустановку системы и переезд на другой компьютер. Чтобы вернуть данные из такой выгрузки: «Импорт из файла».</p></div></div>`;
|
|
1770
|
+
|
|
1771
|
+
const bad=integrity();
|
|
1772
|
+
html+=`<div class="panel"><div class="hd"><h3>Паспорт данных</h3><div class="spacer"></div>
|
|
1773
|
+
<span class="tag ${bad.length?'lock':''}">${bad.length?('замечаний: '+bad.length):'целостность в порядке'}</span></div>
|
|
1774
|
+
<div class="bd"><div class="grid2">`;
|
|
1775
|
+
dataPassport().forEach(([k,v])=>{ html+=`<div class="kv"><span class="muted">${k}</span><b>${fmt(v)}</b></div>`; });
|
|
1776
|
+
html+=`</div>`;
|
|
1777
|
+
if(bad.length) html+=`<ul style="margin:12px 0 0;padding-left:18px;color:var(--red)">${bad.map(b=>`<li>${esc(b)}</li>`).join('')}</ul>`;
|
|
1778
|
+
html+=`<p class="hint">Сверяйте эти цифры после каждого обновления файла — если они совпали с прошлым разом, ничего не потерялось.</p></div></div>`;
|
|
1779
|
+
|
|
1780
|
+
const leg=legacyFound();
|
|
1781
|
+
if(leg.length){
|
|
1782
|
+
html+=`<div class="panel"><div class="hd"><h3>Данные предыдущих версий файла</h3></div><div class="bd" style="padding:0"><div class="tbl-wrap"><table>
|
|
1783
|
+
<thead><tr><th>Хранилище</th><th class="num">Размер</th><th style="width:160px">Действие</th></tr></thead><tbody>`;
|
|
1784
|
+
leg.forEach(l=>{ html+=`<tr><td class="small">${l.key}</td><td class="num small">${bkSize(l.size)}</td>
|
|
1785
|
+
<td><button class="mini ghost" onclick="legacyLoad('${l.key}')">Загрузить</button></td></tr>`; });
|
|
1786
|
+
html+=`</tbody></table></div></div></div>`;
|
|
1787
|
+
}
|
|
1788
|
+
html+=`<div class="panel"><div class="hd"><h3>Резервные копии</h3><div class="spacer"></div>
|
|
1789
|
+
<button class="mini primary" onclick="bkManual()">+ Создать копию</button>
|
|
1790
|
+
<button class="mini ghost" onclick="exportData()">↓ Экспорт в файл</button>
|
|
1791
|
+
<button class="mini ghost" onclick="importData()">↑ Импорт из файла</button></div>
|
|
1792
|
+
<div class="bd" style="padding:0"><div class="tbl-wrap"><table>
|
|
1793
|
+
<thead><tr><th style="width:145px">Дата и время</th><th>Название</th><th class="num">Операций</th><th class="num">Ячеек</th><th class="num">Размер</th><th style="width:290px">Действия</th></tr></thead><tbody>`;
|
|
1794
|
+
if(!list.length) html+=`<tr><td colspan="6"><div class="empty">Копий пока нет</div></td></tr>`;
|
|
1795
|
+
list.forEach(b=>{ html+=`<tr>
|
|
1796
|
+
<td class="small">${b.when||'—'}${b.pin?' <span class="tag">закреплена</span>':''}</td>
|
|
1797
|
+
<td>${esc(b.label)}</td>
|
|
1798
|
+
<td class="num">${b.ops!=null?fmt(b.ops):'—'}</td>
|
|
1799
|
+
<td class="num">${b.cells!=null?fmt(b.cells):'—'}</td>
|
|
1800
|
+
<td class="num small">${bkSize(b.size||0)}</td>
|
|
1801
|
+
<td><button class="mini primary" onclick="bkRestore('${b.id}')">Восстановить</button>
|
|
1802
|
+
<button class="mini ghost" onclick="bkDownload('${b.id}')">Скачать</button>
|
|
1803
|
+
<button class="mini ghost" onclick="bkTogglePin('${b.id}')">${b.pin?'Открепить':'Закрепить'}</button>
|
|
1804
|
+
<button class="mini ghost" onclick="bkDelete('${b.id}')">✕</button></td></tr>`; });
|
|
1805
|
+
html+=`</tbody></table></div></div></div>`;
|
|
1806
|
+
setTimeout(cloudHistLoad,0);
|
|
1807
|
+
html+=`<p class="hint">Снимок создаётся автоматически после изменений (не чаще раза в 5 минут), при переключении на другую вкладку и при закрытии страницы. Автоснимков хранится ${BACKUP_MAX} — старые вытесняются. Закреплённые копии не вытесняются никогда. Копии живут внутри этого браузера: если очистить историю вместе с «данными сайтов», они пропадут — раз в неделю делайте «Экспорт в файл».</p>`;
|
|
1808
|
+
C.innerHTML=html;
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
/* ---------- init ---------- */
|
|
1812
|
+
try{ load(); migrate(); schemaGuard(); render(); bkStart(); bootNote(); cloudBoot(); if(!STORAGE_OK){ const t=document.getElementById('toast'); if(t){t.textContent='Хранилище недоступно — изменения не сохранятся между сессиями. Используйте Экспорт.'; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),4000);} } }
|
|
1813
|
+
catch(err){ const c=document.getElementById('content'); if(c)c.innerHTML='<div style="padding:30px;font:16px sans-serif;color:#e0685f">Ошибка запуска: '+(err&&err.message||err)+'</div>'; console.error(err); }
|
|
1814
|
+
</script>
|
|
1815
|
+
</body>
|
|
1816
|
+
</html>
|