v0.5.1: Первая версия

This commit is contained in:
Flooxik committed 2026-10-24 16:53:46 +03:00
commit 8491a6daa0
11 files changed
+1095

No files matched your search

+641
View File
@@ -0,0 +1,641 @@
<!DOCTYPE html>
<html lang="ru"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>inventorEZ</title>
<meta name="theme-color" content="#f6efdf">
<link rel="icon" type="image/svg+xml" href="icons/favicon.svg">
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32.png">
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png">
<link rel="manifest" href="manifest.webmanifest">
<style>
:root{--bg:#f6efdf;--card:#fffaf0;--tx:#2b2218;--mu:#7d6c58;--ac:#895a35;--on:#fff;--bd:#e2d4b8;--hl:#f3e5c8;--er:#b83a2a}
@media(prefers-color-scheme:dark){:root{--bg:#222926;--card:#2c3532;--tx:#f6efdf;--mu:#a99f8c;--ac:#c99560;--on:#231a10;--bd:#3d4743;--hl:#37433e;--er:#e57368}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--tx);font:15px/1.4 system-ui,sans-serif}
.wrap{display:flex;gap:16px;max-width:1150px;margin:0 auto;padding:16px;align-items:flex-start}
main{flex:1;min-width:0}
aside{width:250px;background:var(--card);border:1px solid var(--bd);border-radius:10px;padding:12px;position:sticky;top:16px}
h1{font-size:20px;margin:0 0 12px}h2{font-size:16px;margin:0}
aside h2{font-size:14px;margin:0 0 8px;color:var(--mu);text-transform:uppercase;letter-spacing:.05em}
.card{background:var(--card);border:1px solid var(--bd);border-radius:10px;padding:12px;overflow-x:auto;margin-bottom:16px}
table{width:100%;border-collapse:collapse}
th,td{text-align:left;padding:8px;border-bottom:1px solid var(--bd);vertical-align:middle}
th{color:var(--mu);font-weight:600;font-size:13px}
td.a{width:100px;text-align:right;white-space:nowrap}
td.c,th.c{width:28px;padding-right:0}
input,select{font:inherit;padding:6px 8px;border:1px solid var(--bd);border-radius:6px;background:var(--bg);color:var(--tx);width:100%;min-width:60px}
input[type=checkbox],input[type=radio]{width:auto;min-width:0}
button{white-space:nowrap;font:inherit;cursor:pointer;border:1px solid var(--bd);background:var(--card);color:var(--tx);border-radius:6px;padding:6px 12px}
button.p{background:var(--ac);border-color:var(--ac);color:var(--on)}
button.p:hover:not(:disabled){filter:brightness(1.1)}
button.i{padding:4px 8px;border:none;background:none}
button:disabled{opacity:.5;cursor:default}
.bar{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}.sp{flex:1}
tr.ed td{background:var(--hl)}
.li{display:flex;align-items:center;gap:4px;padding:6px 8px;border-radius:6px;cursor:pointer}
.li:hover,.li.on{background:var(--hl)}.li.on{font-weight:600}
.li span{flex:1;min-width:0;overflow-wrap:anywhere}
.tag{font-size:11px;color:var(--mu);font-weight:400}
.badge{display:inline-block;font-size:12px;padding:2px 8px;border-radius:10px;background:var(--hl);color:var(--ac);border:1px solid var(--bd);white-space:nowrap}
.title{display:flex;gap:8px;align-items:center;margin-bottom:12px;flex-wrap:wrap}
.title input{font-size:18px;font-weight:600;flex:1;min-width:160px;width:auto}
.empty{color:var(--mu);padding:16px 8px;text-align:center}
.dd{position:relative}.menu{display:none;position:absolute;right:0;top:100%;margin-top:4px;background:var(--card);border:1px solid var(--bd);border-radius:8px;min-width:290px;z-index:5;padding:4px;box-shadow:0 6px 20px #0003}
.menu.open{display:block}.menu button{display:block;width:100%;text-align:left;border:none;background:none;padding:8px 10px}
.menu button:hover:not(:disabled){background:var(--hl)}
.qr{display:flex;gap:16px;align-items:center;flex-wrap:wrap;margin-top:16px}.qr img{width:200px;height:auto;background:#fff;border-radius:6px;border:1px solid var(--bd)}
#login{max-width:320px;margin:15vh auto;background:var(--card);border:1px solid var(--bd);border-radius:10px;padding:20px;display:flex;flex-direction:column;gap:10px}
#lerr{color:var(--er);min-height:1.2em;font-size:13px}
#top{max-width:1150px;margin:0 auto;padding:12px 16px 0;display:flex;gap:8px;align-items:center;color:var(--mu);font-size:13px}
.brand{display:flex;align-items:center;gap:8px;font-size:22px;font-weight:700;letter-spacing:-.02em;color:var(--tx)}
.brand svg{width:36px;height:36px;flex:none}.brand b{color:var(--ac);font-weight:800}
#login .brand{justify-content:center;font-size:28px;margin-bottom:6px}#login .brand svg{width:52px;height:52px}
#toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:var(--ac);color:var(--on);padding:10px 16px;border-radius:8px;display:none;z-index:30;cursor:pointer}
.ov{position:fixed;inset:0;background:#0007;display:none;align-items:flex-start;justify-content:center;padding:6vh 12px;z-index:20;overflow:auto}
.mc{background:var(--card);border:1px solid var(--bd);border-radius:12px;padding:18px;width:100%;max-width:480px;display:flex;flex-direction:column;gap:12px}
.mc h2{font-size:18px}.mc label{display:flex;flex-direction:column;gap:4px;font-size:13px;color:var(--mu)}
.mc .row{display:flex;gap:8px;justify-content:flex-end;margin-top:4px}
.opt{display:flex;gap:8px;align-items:flex-start;padding:8px 10px;border:1px solid var(--bd);border-radius:8px;cursor:pointer;color:var(--tx);font-size:14px}
.opt.on{border-color:var(--ac);background:var(--hl)}.opt input{margin-top:3px}
.opt small{display:block;color:var(--mu)}
.mc label.opt{flex-direction:row;color:var(--tx);font-size:14px}
.chk{display:flex;gap:8px;align-items:center;color:var(--tx)!important;font-size:14px!important;flex-direction:row!important}
.tabs{display:flex;gap:4px;margin-bottom:16px;border-bottom:1px solid var(--bd)}
.tabs button{border:none;border-bottom:3px solid transparent;border-radius:0;background:none;padding:8px 14px;color:var(--mu)}
.tabs button.on{border-color:var(--ac);color:var(--tx);font-weight:600}
.dot{display:inline-block;width:10px;height:10px;border-radius:50%;background:#aaa;margin-right:8px}.dot.on{background:#2ecc71;box-shadow:0 0 0 3px #2ecc7133}
.chips{display:flex;gap:6px;flex-wrap:wrap}.note{color:var(--mu);font-size:13px;margin:0 0 10px}
.dh{display:flex;gap:8px;align-items:center}.dh+table{margin-top:8px}.dh input{font-weight:600}
.fld{display:flex;flex-direction:column;gap:4px;font-size:13px;color:var(--mu);margin-bottom:10px;max-width:380px}
@media(max-width:760px){.wrap{flex-direction:column-reverse}aside{width:100%;position:static}.who{display:none}#top{flex-wrap:wrap}.brand{font-size:19px}.brand svg{width:30px;height:30px}}
</style></head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true"><symbol id="logo" viewBox="0 0 512 512"><path d="M88 176L256 260V432L94 351C90 349 88 345.667 88 341V176Z" fill="#BC8954"/><path d="M256 260L424 176V341C424 345.667 422 349 418 351L256 432V260Z" fill="#895A35"/><path d="M94 173L250 95C254 93 258 93 262 95L418 173C422 175 422 177 418 179L256 260L94 179C90 177 90 175 94 173Z" fill="#DAAA73"/><path d="M319.84 123.92L353.44 140.72L185.44 224.72L151.84 207.92L319.84 123.92Z" fill="#F6DEB4"/><path d="M151.84 207.92L185.44 224.72V269.72L151.84 252.92V207.92Z" fill="#E8C899"/><path d="M268.6 268.32L332.44 236.4V262.2L292.96 281.94V312.9L326.56 296.1V321.9L292.96 338.7V373.1L332.44 353.36V379.16L268.6 411.08V268.32Z" fill="#FFF4E2"/><path d="M344.2 230.52L411.4 196.92V222.72L371.92 333.62L411.4 313.88V339.68L344.2 373.28V347.48L383.68 236.58L344.2 256.32V230.52Z" fill="#FFF4E2"/></symbol></svg>
<form id="login" onsubmit="doLogin();return false"><div class="brand"><svg><use href="#logo"/></svg><span>inventor<b>EZ</b></span></div>
<input id="lg" placeholder="Логин" autocomplete="username"><input id="pw" type="password" placeholder="Пароль" autocomplete="current-password">
<div id="lerr"></div><button class="p">Войти</button></form>
<div id="toast"></div>
<div class="ov" id="ov" onmousedown="if(event.target==this)closeModal()"><div class="mc" id="mc"></div></div>
<div class="ov" id="dv" style="z-index:40" onmousedown="if(event.target==this)dlgClose(null)"><div class="mc" id="dc"></div></div>
<div id="app" style="display:none"><div id="top"></div><div class="wrap"><main id="m"></main><aside id="s"></aside></div></div>
<script>
const FONT={h:27,g:{" ":"8|AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","!":"10|AAAAAAAAB4HgeB4HgeB4HgeB4AAAB4HgeB4AAAAAAAAAAA","\"":"11|AAAAAAAAAHMOYcw5hzDmAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","#":"18|AAAAAAAAAAAAAAAAc4Ac4AY4AYwA4wH/+H/+AxgBxgBzgf/8f/8DjADnADHADGAAAAAAAAAAAAAAAAAAAA","$":"15|AAAAAAAAAAAAADAAYAfwH/h7MOYB7AP8B/4H/gP+AbwDeMbx/8H+ADAAYADAAAAAAAAA","%":"22|AAAAAAAAAAAAAAAAAAB4DgPwMBzhwHOGAYYwBznAHOYAPzngec/ABnOAOc4AxxgGHOA4c4DA/AcB4AAAAAAAAAAAAAAAAAAAAAAA","&":"19|AAAAAAAAAAAAAAAAB+AD/gB4QA8AAeAAPgAP4eP+OHvnHj3jw/x4fweHwPj8D//AfnwAAAAAAAAAAAAAAAAAAAA","'":"7|AAAAAAcOHDhw4AAAAAAAAAAAAAAAAAAA","(":"10|AAAAAAAHA8DgeBwHA8DwPA8DwPA8DwHAcB4DgPAcAAAAAA",")":"10|AAAAAAA4DwHAeA4DwPA8DwPA8DwPA8DwOB4HA8DgAAAAAA","*":"12|AAAAAAAAAABgBgRmf+H4H4f+RmBgBgAAAAAAAAAAAAAAAAAAAAAAAAA","+":"18|AAAAAAAAAAAAAAAAAAAAAAAAAOAAOAAOAAOAAOAP/8P/8P/8AOAAOAAOAAOAAOAAAAAAAAAAAAAAAAAAAA",",":"8|AAAAAAAAAAAAAAAAAAAAAAA8PDw8ODhwAAAA","-":"9|AAAAAAAAAAAAAAAAAAAD+fz+AAAAAAAAAAAAAAAAAA",".":"8|AAAAAAAAAAAAAAAAAAAAAAA8PDw8AAAAAAAA","/":"9|AAAAAAAAOBwMDgcDAYHAwGBwMBgcDgYHA4AAAAAAAA","0":"15|AAAAAAAAAAAAAPgH/B48PDj4eeDzweeDzweeDzwefDx4cPHg/4B8AAAAAAAAAAAAAAAA","1":"15|AAAAAAAAAAAAAfgP8B3gA8AHgA8AHgA8AHgA8AHgA8AHgA8A/+H/wAAAAAAAAAAAAAAA","2":"15|AAAAAAAAAAAAA/gP/Bx8IHgA8AHgA8APgB4AeAHgD4A+APgB/8P/gAAAAAAAAAAAAAAA","3":"15|AAAAAAAAAAAAAfwP/Bh8AHgA8AHgB4D+Af4APgA8AHwA8YPj/4H8AAAAAAAAAAAAAAAA","4":"15|AAAAAAAAAAAAAD4A/AH4B/Ad4HvA54OPDx4cPD/+f/wB4APAB4APAAAAAAAAAAAAAAAA","5":"15|AAAAAAAAAAAAB/8P/h4APAB4APAB/wP/Bh8AHwA+AHwA+MPh/4H+AAAAAAAAAAAAAAAA","6":"15|AAAAAAAAAAAAAH4D/g8EPAB4AP+D/8fnj4efDx4ePDx4eHngf4B+AAAAAAAAAAAAAAAA","7":"15|AAAAAAAAAAAAD/+f/wA+APgB8APAD4AeAHwA8AHgB4APAD4AeAHwAAAAAAAAAAAAAAAA","8":"15|AAAAAAAAAAAAAfwH/h48PHx4ePHh48D/A/4PHj4eeDz4ePHx/8D+AAAAAAAAAAAAAAAA","9":"15|AAAAAAAAAAAAAfgH/B58fHjw+eHzw+fHx5+H/wf+ADgA8MPB/wH4AAAAAAAAAAAAAAAA",":":"9|AAAAAAAAAAAAAB4PB4PAAAAAAB4PB4PAAAAAAAAAAA",";":"9|AAAAAAAAAAAAAB4PB4PAAAAAAB4PB4PBwODgAAAAAA","<":"18|AAAAAAAAAAAAAAAAAAAAAAAAAAMAA8AH8A/wH+APwAPgAH8AA/wAH8AA8AAMAAAAAAAAAAAAAAAAAAAAAA","=":"18|AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/8P/8P/8AAAAAAP/8P/8P/8AAAAAAAAAAAAAAAAAAAAAAAAAAAA",">":"18|AAAAAAAAAAAAAAAAAAAAAAAAMAAPgAP8AB/gAP4AB8AB8AP4B/gP8APgAMAAAAAAAAAAAAAAAAAAAAAAAA","?":"13|AAAAAAAAAAAPwf+MPAHgDwD4D4D4B4A8AAAAAHgDwB4A8AAAAAAAAAAAAAA","@":"22|AAAAAAAAAAAAAAAAAAAD+AA/+AHg8A4A4GABw4dzDH/MMc8xxhzHOHMcYcwxz3DH/4OHeAYAAA4BgD4eAD/wAD8AAAAAAAAAAAAA","A":"17|AAAAAAAAAAAAAAAfAB/AD+AH8Af8A94B7wHjwPHg+Ph//D/+PAeeA88B7wB4AAAAAAAAAAAAAAAAAA","B":"17|AAAAAAAAAAAAAAH/gP/weHg8Ph4fDw8Hh4P/gf/g8Ph4PDweHg8PD4f/g/+AAAAAAAAAAAAAAAAAAA","C":"16|AAAAAAAAAAAAAAP8D/4fDj4CPAB8AHwAeAB4AHwAfAA8AD4CHw4P/gP8AAAAAAAAAAAAAAAA","D":"18|AAAAAAAAAAAAAAAP/AP/wPD4PB8PA8PA+PAePAePAePAePA+PA8PB8PD4P/wP/AAAAAAAAAAAAAAAAAAAA","E":"15|AAAAAAAAAAAAB/8P/h4APAB4APAB4AP/h/8PAB4APAB4APAB/8P/gAAAAAAAAAAAAAAA","F":"15|AAAAAAAAAAAAB/8P/h4APAB4APAB4AP/h/8PAB4APAB4APAB4APAAAAAAAAAAAAAAAAA","G":"18|AAAAAAAAAAAAAAAA/4D/8HwcPgEPAAfAAfAAeD+eD+fA+fA+PA+Pg+Hw+D/+A/4AAAAAAAAAAAAAAAAAAA","H":"18|AAAAAAAAAAAAAAAPA8PA8PA8PA8PA8PA8PA8P/8P/8PA8PA8PA8PA8PA8PA8PA8AAAAAAAAAAAAAAAAAAA","I":"8|AAAAAAA8PDw8PDw8PDw8PDw8PDw8AAAAAAAA","J":"8|AAAAAAA8PDw8PDw8PDw8PDw8PDw8PHz44AAA","K":"18|AAAAAAAAAAAAAAAPA8PB4PDwPPgPfAP+AP8AP8AP8AP+APfAPPgPHwPD4PB8PA+AAAAAAAAAAAAAAAAAAA","L":"14|AAAAAAAAAAAA8APADwA8APADwA8APADwA8APADwA8APAD/4/+AAAAAAAAAAAAAAA","M":"22|AAAAAAAAAAAAAAAAAAD8D8PwPw/B/D+H8P4fw/z/D3O8Pf7w8/PDz88PHjw8ePDx48PADw8APDwA8AAAAAAAAAAAAAAAAAAAAAAA","N":"18|AAAAAAAAAAAAAAAPg8Pw8Pw8P48P48P88Pc8Pe8PO8PP8PH8PH8PD8PD8PB8PB8AAAAAAAAAAAAAAAAAAA","O":"19|AAAAAAAAAAAAAAAAB/AD/4D4+D4Ph4Dx8B4+A+eAfPAPnwHz4Dw8B4fB8Hx8B/8AP4AAAAAAAAAAAAAAAAAAAAA","P":"16|AAAAAAAAAAAAAD/wP/w8PjwePB48HjwePD4//D/wPAA8ADwAPAA8ADwAAAAAAAAAAAAAAAAA","Q":"19|AAAAAAAAAAAAAAAAB/AD/4D4+D4Ph4Dx8B4+A+eAfPAPnwHz4Dw8B4fB8Hx8B/8AP8AAeAAHgAB4AAAAAAAAAAA","R":"17|AAAAAAAAAAAAAAH/gP/gePg8PB4eDw8Hh4PHwf/A/+B4+Dw8Hh8PB4eD48DwAAAAAAAAAAAAAAAAAA","S":"16|AAAAAAAAAAAAAA/4H/g8ODwIOAA8AD/AP/Af/AP8AHwAPCA8OHw/+B/gAAAAAAAAAAAAAAAA","T":"15|AAAAAAAAAAAAH////4PgB8APgB8APgB8APgB8APgB8APgB8APgB8AAAAAAAAAAAAAAAA","U":"18|AAAAAAAAAAAAAAAPA8PA8PA8PA8PA8PA8PA8PA8PA8PA8PA8PA8Ph8Hj4D/wB/gAAAAAAAAAAAAAAAAAAA","V":"17|AAAAAAAAAAAAAAfAPeA88B58Hx4PDweHx8HjwPHgPeAe8A/4A/gB/AD+AD4AAAAAAAAAAAAAAAAAAA","W":"24|AAAAAAAAAAAAAAAAAAAAeDwfeD4eeH4efH4ePH4ePH48PHc8POc8Huc8Hud4H+P4H8P4D8P4D8PwD8HwD4HwAAAAAAAAAAAAAAAAAAAAAAAA","X":"17|AAAAAAAAAAAAAAPAePB4fHwePAe8A/4A/gA+AD8AH8Af8B98Dx4Pj4+D54DwAAAAAAAAAAAAAAAAAA","Y":"17|AAAAAAAAAAAAAAfA+eB4+Hw+fA88B/4B/gB+AD8ADwAHgAPAAeAA8AB4ADwAAAAAAAAAAAAAAAAAAA","Z":"16|AAAAAAAAAAAAAH/+f/4AfgD8APgB+APwB+AHwA/AH4A/AD4AfgB//n/+AAAAAAAAAAAAAAAA","[":"10|AAAAAAA/j+PA8DwPA8DwPA8DwPA8DwPA8DwPA/j+AAAAAA","\\":"9|AAAAAAAHA4DAcDgMBgOAwGA4DAYDgcBgOBwAAAAAAA","]":"10|AAAAAAB/H8DwPA8DwPA8DwPA8DwPA8DwPA8Dx/H8AAAAAA","^":"18|AAAAAAAAAAAAAAAAeAA/AB/gBzwDh4HAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","_":"11|AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA///8AAA","`":"11|AAAAADgDgDgDgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALine truncated
const QR = (() => {
const EC = {1: [16, 10, 1], 2: [28, 16, 1], 3: [44, 26, 1], 4: [32, 18, 2], 5: [43, 24, 2], 6: [27, 16, 4]};
const EXP = new Uint8Array(512), LOG = new Uint8Array(256);
let x = 1;
for (let i = 0; i < 255; i++) { EXP[i] = x; LOG[x] = i; x <<= 1; if (x & 256) x ^= 0x11d; }
for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
const mul = (a, b) => (a && b ? EXP[LOG[a] + LOG[b]] : 0);
function gen(n) {
let g = [1];
for (let i = 0; i < n; i++) {
const ng = new Array(g.length + 1).fill(0);
for (let j = 0; j < g.length; j++) { ng[j] ^= g[j]; ng[j + 1] ^= mul(g[j], EXP[i]); }
g = ng;
}
return g;
}
function rem(data, g) {
const n = g.length - 1, r = new Array(n).fill(0);
for (const b of data) {
const f = b ^ r.shift(); r.push(0);
for (let i = 0; i < n; i++) r[i] ^= mul(g[i + 1], f);
}
return r;
}
function utf8(s) { return Array.from(new TextEncoder().encode(s)); }
function codewords(bytes, v) {
const [dpb, epb, nb] = EC[v], cap = dpb * nb, bits = [];
const put = (val, len) => { for (let i = len - 1; i >= 0; i--) bits.push((val >>> i) & 1); };
put(4, 4); put(bytes.length, 8); bytes.forEach(b => put(b, 8));
put(0, Math.min(4, cap * 8 - bits.length));
while (bits.length % 8) bits.push(0);
const data = [];
for (let i = 0; i < bits.length; i += 8) data.push(parseInt(bits.slice(i, i + 8).join(''), 2));
for (let p = 0; data.length < cap; p ^= 1) data.push(p ? 0x11 : 0xec);
const g = gen(epb), blocks = [], ecs = [];
for (let b = 0; b < nb; b++) { const d = data.slice(b * dpb, (b + 1) * dpb); blocks.push(d); ecs.push(rem(d, g)); }
const out = [];
for (let i = 0; i < dpb; i++) blocks.forEach(b => out.push(b[i]));
for (let i = 0; i < epb; i++) ecs.forEach(e => out.push(e[i]));
return out;
}
function build(text) {
const bytes = utf8(text);
let v = 0;
for (let k = 1; k <= 6; k++) if (bytes.length <= EC[k][0] * EC[k][2] - 2) { v = k; break; }
if (!v) return null;
const size = 17 + 4 * v;
const mod = Array.from({length: size}, () => new Array(size).fill(false));
const fn = Array.from({length: size}, () => new Array(size).fill(false));
const set = (x, y, d) => { mod[y][x] = d; fn[y][x] = true; };
for (let i = 0; i < size; i++) { set(6, i, i % 2 === 0); set(i, 6, i % 2 === 0); }
const finder = (cx, cy) => {
for (let dy = -4; dy <= 4; dy++) for (let dx = -4; dx <= 4; dx++) {
const x = cx + dx, y = cy + dy;
if (x < 0 || y < 0 || x >= size || y >= size) continue;
const d = Math.max(Math.abs(dx), Math.abs(dy));
set(x, y, d !== 2 && d !== 4);
}
};
finder(3, 3); finder(size - 4, 3); finder(3, size - 4);
if (v >= 2) {
const c = size - 7;
for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) set(c + dx, c + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
}
const drawFormat = mask => {
const d = mask; let rr = d;
for (let i = 0; i < 10; i++) rr = (rr << 1) ^ ((rr >>> 9) * 0x537);
const bits = ((d << 10) | rr) ^ 0x5412, bit = i => ((bits >>> i) & 1) !== 0;
for (let i = 0; i <= 5; i++) set(8, i, bit(i));
set(8, 7, bit(6)); set(8, 8, bit(7)); set(7, 8, bit(8));
for (let i = 9; i < 15; i++) set(14 - i, 8, bit(i));
for (let i = 0; i < 8; i++) set(size - 1 - i, 8, bit(i));
for (let i = 8; i < 15; i++) set(8, size - 15 + i, bit(i));
set(8, size - 8, true);
};
drawFormat(0);
const cw = codewords(bytes, v);
let i = 0;
for (let right = size - 1; right >= 1; right -= 2) {
if (right === 6) right = 5;
for (let vert = 0; vert < size; vert++) for (let j = 0; j < 2; j++) {
const x = right - j, up = ((right + 1) & 2) === 0, y = up ? size - 1 - vert : vert;
if (!fn[y][x] && i < cw.length * 8) { mod[y][x] = ((cw[i >>> 3] >>> (7 - (i & 7))) & 1) !== 0; i++; }
}
}
const masks = [
(x, y) => (x + y) % 2 === 0, (x, y) => y % 2 === 0, (x, y) => x % 3 === 0, (x, y) => (x + y) % 3 === 0,
(x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0, (x, y) => (x * y) % 2 + (x * y) % 3 === 0,
(x, y) => ((x * y) % 2 + (x * y) % 3) % 2 === 0, (x, y) => ((x + y) % 2 + (x * y) % 3) % 2 === 0];
const apply = m => { for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) if (!fn[y][x] && masks[m](x, y)) mod[y][x] = !mod[y][x]; };
const penalty = () => {
let p = 0, dark = 0;
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) if (mod[y][x]) dark++;
for (let t = 0; t < 2; t++) {
for (let a = 0; a < size; a++) {
const line = []; for (let b = 0; b < size; b++) line.push(t ? mod[b][a] : mod[a][b]);
let run = 1;
for (let b = 1; b <= size; b++) {
if (b < size && line[b] === line[b - 1]) run++; else { if (run >= 5) p += 3 + run - 5; run = 1; }
}
const s = line.map(c => (c ? '1' : '0')).join('');
for (const pat of ['00001011101', '10111010000']) { let k = -1; while ((k = s.indexOf(pat, k + 1)) >= 0) p += 40; }
}
}
for (let y = 0; y < size - 1; y++) for (let x = 0; x < size - 1; x++) {
const c = mod[y][x]; if (c === mod[y][x + 1] && c === mod[y + 1][x] && c === mod[y + 1][x + 1]) p += 3;
}
const total = size * size, k = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1;
return p + Math.max(0, k) * 10;
};
let best = 0, bp = Infinity;
for (let m = 0; m < 8; m++) { apply(m); drawFormat(m); const p = penalty(); if (p < bp) { bp = p; best = m; } apply(m); }
apply(best); drawFormat(best);
return mod;
}
const CRC = (() => { const t = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } return t; })();
const crc32 = b => { let c = 0xffffffff; for (let i = 0; i < b.length; i++) c = CRC[(c ^ b[i]) & 255] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };
const be32 = v => [(v >>> 24) & 255, (v >>> 16) & 255, (v >>> 8) & 255, v & 255];
function chunk(type, data) {
const t = Array.from(type, ch => ch.charCodeAt(0)), body = new Uint8Array(t.length + data.length);
body.set(t); body.set(data, 4);
return [...be32(data.length), ...body, ...be32(crc32(body))];
}
const GL = {};
function glyph(ch) {
if (GL[ch]) return GL[ch];
let src = FONT.g[ch] || FONT.g['?'], [w, b64] = src.split('|'); w = +w;
const bin = atob(b64), bits = [];
for (let i = 0; i < bin.length; i++) for (let k = 7; k >= 0; k--) bits.push((bin.charCodeAt(i) >> k) & 1);
return (GL[ch] = {w, bits});
}
const tw = s => Array.from(s).reduce((a, ch) => a + glyph(ch).w + 1, -1);
function wrap(text, maxW, maxLines) {
const words = text.replace(/\s+/g, ' ').trim().split(' '), lines = []; let cur = '';
const push = () => { if (cur) lines.push(cur); cur = ''; };
for (let w of words) {
while (tw(w) > maxW) {
let k = 1; while (k < w.length && tw(w.slice(0, k + 1)) <= maxW) k++;
push(); lines.push(w.slice(0, k)); w = w.slice(k);
}
if (!cur) cur = w; else if (tw(cur + ' ' + w) <= maxW) cur += ' ' + w; else { push(); cur = w; }
}
push();
if (lines.length > maxLines) {
lines.length = maxLines; let l = lines[maxLines - 1];
while (l.length > 1 && tw(l + '…') > maxW) l = l.slice(0, -1);
lines[maxLines - 1] = l + '…';
}
return lines;
}
function render(m, scale, border, label) {
const n = m.length, QW = (n + border * 2) * scale;
let lines = [], lh = 0, textH = 0;
if (label) { lines = wrap(label, QW - 24, 3); lh = FONT.h + 4; textH = lines.length * lh + 14; }
const W = QW, H = QW + textH, pix = new Uint8Array(W * H);
for (let y = 0; y < QW; y++) for (let x = 0; x < QW; x++) {
const mx = Math.floor(x / scale) - border, my = Math.floor(y / scale) - border;
if (mx >= 0 && my >= 0 && mx < n && my < n && m[my][mx]) pix[y * W + x] = 1;
}
lines.forEach((ln, li) => {
let x0 = Math.max(0, Math.round((W - tw(ln)) / 2)); const y0 = QW + 4 + li * lh;
for (const ch of ln) {
const g = glyph(ch);
for (let y = 0; y < FONT.h; y++) for (let x = 0; x < g.w; x++) if (g.bits[y * g.w + x] && x0 + x < W) pix[(y0 + y) * W + x0 + x] = 1;
x0 += g.w + 1;
}
});
return {W, H, pix};
}
function encodePng(W, H, pix) {
const rb = Math.ceil(W / 8), raw = new Uint8Array((rb + 1) * H);
for (let y = 0; y < H; y++) {
const o = y * (rb + 1); raw[o] = 0;
for (let x = 0; x < W; x++) if (!pix[y * W + x]) raw[o + 1 + (x >> 3)] |= 0x80 >> (x & 7);
}
const z = [0x78, 0x01];
for (let p = 0; p < raw.length; p += 65535) {
const len = Math.min(65535, raw.length - p), last = p + len >= raw.length ? 1 : 0;
z.push(last, len & 255, len >> 8, ~len & 255, (~len >> 8) & 255);
for (let k = 0; k < len; k++) z.push(raw[p + k]);
}
let a = 1, b = 0; for (let i = 0; i < raw.length; i++) { a = (a + raw[i]) % 65521; b = (b + a) % 65521; }
z.push(...be32(((b << 16) | a) >>> 0));
return Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10,
...chunk('IHDR', Uint8Array.from([...be32(W), ...be32(H), 1, 0, 0, 0, 0])),
...chunk('IDAT', Uint8Array.from(z)),
...chunk('IEND', new Uint8Array(0))]);
}
function make(text, o = {}) {
const m = build(text); if (!m) return null;
const {W, H, pix} = render(m, o.scale || 8, 4, o.label || '');
const bytes = encodePng(W, H, pix);
let s = ''; for (let i = 0; i < bytes.length; i += 8192) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 8192));
return {url: 'data:image/png;base64,' + btoa(s), w: W, h: H};
}
const png = (text, o) => { const r = make(text, o); return r ? r.url : ''; };
return {build, make, png};
})();
</script>
<script>
let MUST=false;
let S={lists:[],cur:null},REF={dicts:[],columns:[]},STD=[],LT=[],COLS={},me=null,CSRF='',editing=null,sel=new Set();
const $=id=>document.getElementById(id);
const today=()=>new Date().toLocaleDateString('ru-RU');
const uid=()=>'l'+Math.random().toString(36).slice(2,11);
const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const cur=()=>S.lists.find(l=>l.id==S.cur);
const TYPES={text:'Текст',number:'Число',date:'Дата',select:'Выпадающий список',check:'Флажок (да/нет)'};
const dict=id=>REF.dicts.find(d=>d.id==id);
const dvals=id=>(dict(id)||{values:[]}).values;
const fd=s=>s?s.split('-').reverse().join('.'):'';
const show=(c,v)=>c.type=='date'?fd(v):c.type=='check'?(v?'Да':''):v||'';
async function api(a,d={}){
const r=await fetch('api.php',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRF':CSRF},body:JSON.stringify({a,...d})});
let j=null;try{j=await r.json()}catch(e){}
if(r.status==401&&a!='login'&&a!='me'){location.reload();throw new Error('auth')}
if(!j)throw new Error('Сервер вернул не JSON (код '+r.status+'). Проверьте версию PHP и права на папку data.');
if(!r.ok)throw new Error(j.error||'Ошибка '+r.status);
return j;
}
const fail=e=>{if(e.message!='auth')return say(e.message)};
function toast(t,fn){const e=$('toast');e.textContent=t;e.style.display='block';e.onclick=fn?()=>{e.style.display='none';fn()}:null;clearTimeout(toast.t);toast.t=setTimeout(()=>e.style.display='none',fn?6000:2500)}
function openModal(h){$('mc').innerHTML=h;$('ov').style.display='flex'}
function closeModal(){$('ov').style.display='none';$('mc').innerHTML=''}
let dlgRes=null;
function dlgOpen(html,done){$('dc').innerHTML=html;$('dv').style.display='flex';dlgRes=done;const b=$('dc').querySelector('input,button.p');if(b)b.focus()}
function dlgClose(v){$('dv').style.display='none';$('dc').innerHTML='';const r=dlgRes;dlgRes=null;if(r)r(v)}
const say=m=>new Promise(r=>dlgOpen(`<div>${esc(m).replace(/\n/g,'<br>')}</div><div class="row"><button class="p" onclick="dlgClose(true)">ОК</button></div>`,r));
const ask=(m,ok='Удалить')=>new Promise(r=>dlgOpen(`<div>${esc(m)}</div><div class="row"><button onclick="dlgClose(false)">Отмена</button><button class="p" onclick="dlgClose(true)">${esc(ok)}</button></div>`,v=>r(v===true)));
document.addEventListener('keydown',e=>{if(e.key=='Escape'){if($('dv').style.display=='flex')dlgClose(null);else closeModal()}});
function applyInfo(j){me=j.user;CSRF=j.csrf;MUST=!!j.mustChange}
async function doLogin(){
try{const j=await api('login',{login:$('lg').value,password:$('pw').value});applyInfo(j);await showApp()}
catch(e){$('lerr').textContent=e.message;$('pw').value=''}
}
async function chPw(){
const v=await new Promise(r=>dlgOpen(`<h2>Смена пароля</h2><label>Текущий пароль<input id="pw_o" type="password" autocomplete="current-password"></label>
<label>Новый пароль (не короче 8 символов)<input id="pw_n" type="password" autocomplete="new-password" onkeydown="if(event.key=='Enter')dlgClose({o:$('pw_o').value,n:$('pw_n').value})"></label>
<div class="row"><button onclick="dlgClose(null)">Отмена</button><button class="p" onclick="dlgClose({o:$('pw_o').value,n:$('pw_n').value})">Сменить</button></div>`,r));
if(!v)return;
try{await api('passwd',{old:v.o,new:v.n});say('Пароль изменён')}catch(e){fail(e)}
}
async function logout(){try{await api('logout')}catch(e){}location.reload()}
function setRef(j){REF=j.ref;if(j.std)STD=j.std;LT=(dict('ltypes')||{values:[]}).values;COLS={};STD.concat(REF.columns).forEach(c=>COLS[c.id]=c)}
async function load(){const j=await api('state');setRef(j);if(j.lists)S.lists=j.lists;if(S.cur&&!cur())S.cur=null}
const BRAND='<span class="brand"><svg><use href="#logo"/></svg><span>inventor<b>EZ</b></span></span>';
function drawTop(){
$('top').innerHTML=BRAND+`<span class="sp"></span><span class="who">Вы вошли как <b>${esc(me.login)}</b></span>`+(MUST?'':`<button onclick="chPw()">Сменить пароль</button>`)+`<button onclick="logout()">Выйти</button>`;
}
function renderForce(){
$('s').style.display='none';
$('m').innerHTML=`<div class="card" style="max-width:440px;margin:6vh auto 0"><h2 style="margin-bottom:6px">Смените пароль</h2>
<p class="note">Сейчас действует пароль по умолчанию. Придумайте свой, не короче 8 символов: без этого работа с системой недоступна.</p>
<label class="fld">Текущий пароль<input id="f_o" type="password" autocomplete="current-password"></label>
<label class="fld">Новый пароль<input id="f_n" type="password" autocomplete="new-password"></label>
<label class="fld">Повторите новый пароль<input id="f_r" type="password" autocomplete="new-password" onkeydown="if(event.key=='Enter')forcePw()"></label>
<button class="p" onclick="forcePw()">Сменить пароль</button></div>`;
$('f_o').focus();
}
async function forcePw(){
if($('f_n').value!==$('f_r').value)return say('Новый пароль и повтор не совпадают');
try{await api('passwd',{old:$('f_o').value,new:$('f_n').value});MUST=false;drawTop();await enter()}catch(e){fail(e)}
}
async function enter(){
await load();
if(me.role=='admin'){$('s').style.display='none';adminTab='dicts';renderAdmin()}
else{hashPick();$('s').style.display='';render()}
}
async function showApp(){
$('login').style.display='none';$('app').style.display='';
drawTop();
setInterval(()=>api('ping').catch(()=>{}),30000);
if(MUST)return renderForce();
await enter();
}
let adminTab='dicts',usersTimer=null;
const refCall=async(a,p)=>{try{setRef(await api(a,p))}catch(e){await fail(e)}renderAdmin()};
function renderAdmin(){
clearInterval(usersTimer);
const tabs=[['dicts','Справочники'],['cols','Колонки'],['users','Пользователи']];
$('m').innerHTML=`<h1>Администрирование</h1><div class="tabs">`+tabs.map(([k,t])=>`<button class="${adminTab==k?'on':''}" onclick="adminTab='${k}';renderAdmin()">${t}</button>`).join('')+`</div><div id="at"></div>`;
({dicts:admDicts,cols:admCols,users:admUsers})[adminTab]();
}
let openD=new Set();try{openD=new Set(JSON.parse(localStorage.getItem('inv_open_dicts')||'[]'))}catch(e){}
const saveOpen=()=>{try{localStorage.setItem('inv_open_dicts',JSON.stringify([...openD]))}catch(e){}};
function togDict(id){openD.has(id)?openD.delete(id):openD.add(id);saveOpen();admDicts()}
function allDicts(open){openD=new Set(open?REF.dicts.map(d=>d.id):[]);saveOpen();admDicts()}
function admDicts(){
const core=id=>id=='cats'||id=='locs'||id=='ltypes';
let h=`<p class="note">Справочники — наборы значений для выпадающих списков. Из них админ собирает колонки (вкладка «Колонки»). «Типы списков» (Коробка, Мешок, Полка…) — особый справочник: его значения пользователи выбирают для самих списков.</p>`;
h+=`<div class="bar" style="margin:0 0 10px"><button onclick="allDicts(true)">Развернуть все</button><button onclick="allDicts(false)">Свернуть все</button></div>`;
h+=REF.dicts.map((d,di)=>{const op=openD.has(d.id);return `<div class="card"><div class="dh"><button class="i" title="${op?'Свернуть':'Развернуть'}" style="font-size:16px;width:32px" onclick="togDict('${d.id}')">${op?'▾':'▸'}</button>
<input value="${esc(d.name)}" onchange="refCall('dict_op',{op:'rename_dict',id:'${d.id}',value:this.value})"><span class="badge">${d.values.length} знач.</span>
${core(d.id)?'<span class="badge">стандартный</span>':`<button class="i" title="Удалить справочник" onclick="delDict(${di})">🗑</button>`}</div>`+
(op?`<table>`+d.values.map((v,vi)=>`<tr><td><input value="${esc(v)}" onchange="valOp('rename_value',${di},${vi},this.value)"></td>
<td class="a"><button class="i" title="Удалить" onclick="valOp('delete_value',${di},${vi})">🗑</button></td></tr>`).join('')+
`<tr class="ed"><td><input id="av_${d.id}" placeholder="Новое значение" onkeydown="if(event.key=='Enter')addVal('${d.id}')"></td><td class="a"><button class="p" onclick="addVal('${d.id}')">+</button></td></tr></table>`:'')+`</div>`}).join('');
h+=`<div class="card"><div class="dh"><input id="nd" placeholder="Название нового справочника" onkeydown="if(event.key=='Enter')addDict()"><button class="p" onclick="addDict()">+ Справочник</button></div></div>`;
$('at').innerHTML=h;
}
const valOp=(op,di,vi,value)=>refCall('dict_op',{op,id:REF.dicts[di].id,old:REF.dicts[di].values[vi],value});
const delDict=async di=>{if(await ask('Удалить справочник «'+REF.dicts[di].name+'»?'))refCall('dict_op',{op:'delete_dict',id:REF.dicts[di].id})};
const delCol=async id=>{if(await ask('Удалить колонку?'))refCall('col_op',{op:'delete',id})};
async function addVal(id){const v=$('av_'+id).value;await refCall('dict_op',{op:'add_value',id,value:v});const e=$('av_'+id);if(e)e.focus()}
async function addDict(){const before=new Set(REF.dicts.map(d=>d.id));await refCall('dict_op',{op:'add_dict',value:$('nd').value});REF.dicts.filter(d=>!before.has(d.id)).forEach(d=>openD.add(d.id));saveOpen();admDicts()}
function admCols(){
const typeOpts=t=>Object.entries(TYPES).map(([k,v])=>`<option value="${k}"${k==t?' selected':''}>${v}</option>`).join('');
const dictOpts=d=>`<option value="">— справочник —</option>`+REF.dicts.filter(x=>x.id!='ltypes').map(x=>`<option value="${x.id}"${x.id==d?' selected':''}>${esc(x.name)}</option>`).join('');
$('at').innerHTML=`<p class="note">Колонки, которые пользователи смогут выбирать при создании «пользовательского» списка. Тип и справочник нельзя менять, пока колонка используется в списках.</p>
<div class="card"><table><tr><th>Название</th><th>Тип ввода</th><th>Справочник</th><th></th></tr>`+
REF.columns.map(c=>`<tr><td><input id="cn_${c.id}" value="${esc(c.name)}" onchange="colUpd('${c.id}')"></td>
<td><select id="ct_${c.id}" onchange="colType('${c.id}')">${typeOpts(c.type)}</select></td>
<td><select id="cd_${c.id}" onchange="colUpd('${c.id}')" ${c.type=='select'?'':'disabled'}>${dictOpts(c.dict)}</select></td>
<td class="a"><button class="i" title="Удалить" onclick="delCol('${c.id}')">🗑</button></td></tr>`).join('')+
(REF.columns.length?'':`<tr><td colspan=4 class="empty">Колонок пока нет — добавьте первую</td></tr>`)+
`<tr class="ed"><td><input id="nc_n" placeholder="Название новой колонки"></td><td><select id="nc_t" onchange="$('nc_d').disabled=this.value!='select'">${typeOpts('text')}</select></td>
<td><select id="nc_d" disabled>${dictOpts('')}</select></td><td class="a"><button class="p" onclick="colAdd()">+</button></td></tr></table></div>
<p class="note">Стандартные колонки (для «стандартных» списков, не редактируются): ${STD.map(c=>`<span class="badge">${esc(c.name)}</span>`).join(' ')}</p>`;
}
const colAdd=()=>refCall('col_op',{op:'add',name:$('nc_n').value,type:$('nc_t').value,dict:$('nc_d').value||null});
function colType(id){const t=$('ct_'+id).value;$('cd_'+id).disabled=t!='select';if(t=='select'&&!$('cd_'+id).value)return;colUpd(id)}
const colUpd=id=>refCall('col_op',{op:'update',id,name:$('cn_'+id).value,type:$('ct_'+id).value,dict:$('cd_'+id).value||null});
async function admUsers(){
$('at').innerHTML=`<p class="note">Все пользователи системы. «В сети» — были активны в последние 2 минуты. Список обновляется автоматически.</p>`+
`<div class="card"><h2 style="margin-bottom:10px;font-size:16px">Добавить пользователя</h2><div class="dh" style="flex-wrap:wrap">
<input id="u_l" placeholder="Логин (латиницей)" autocomplete="off" style="flex:1;min-width:150px" onkeydown="if(event.key=='Enter')$('u_p').focus()">
<input id="u_p" placeholder="Пароль (от 8 символов)" autocomplete="off" style="flex:1;min-width:150px" onkeydown="if(event.key=='Enter')userAdd()">
<button class="p" onclick="userAdd()">+ Добавить</button></div>
<p class="note" style="margin:8px 0 0">Пароль виден при вводе, чтобы вы могли передать его человеку. Сменить его пользователь сможет сам.</p></div><div id="ul"></div>`;
usersTimer=setInterval(drawUsers,15000);await drawUsers();
}
async function drawUsers(){
let u;try{u=(await api('users')).users}catch(e){return}
const el=$('ul');if(!el)return;
el.innerHTML=`<div class="card"><table><tr><th>Логин</th><th>Статус</th></tr>`+
u.map(x=>`<tr><td>${esc(x.login)}</td><td><span class="dot${x.online?' on':''}"></span>${x.online?'в сети':'не в сети'}</td></tr>`).join('')+`</table></div>`;
}
async function userAdd(){
try{await api('user_add',{login:$('u_l').value,password:$('u_p').value});$('u_l').value='';$('u_p').value='';await drawUsers();toast('Пользователь добавлен');$('u_l').focus()}
catch(e){fail(e)}
}
const listCols=l=>l.cols.map(id=>COLS[id]).filter(Boolean);
const hasName=l=>l.cols.includes('name');
const colNames=ids=>ids.map(c=>'«'+(COLS[c]?COLS[c].name:c)+'»').join(', ');
function colDiff(src,dst){
const lost=src.cols.filter(c=>!dst.cols.includes(c)),miss=dst.cols.filter(c=>!src.cols.includes(c));
const std=dst.kind=='standard',fill=std?miss.filter(c=>c=='cat'||c=='loc'):[];
return {lost,fill,empty:miss.filter(c=>!fill.includes(c)),same:!lost.length&&!miss.length};
}
function mapV(v,dst){
const o={};dst.cols.forEach(c=>o[c]=v[c]??'');
if(dst.kind=='standard'){if(!o.name)o.name='Без названия';if(!o.cat)o.cat=dvals('cats')[0]||'';if(!o.loc)o.loc=dvals('locs')[0]||''}
return o;
}
const confirmDiff=(d,verb)=>new Promise(r=>dlgOpen(`<h2>Колонки списков отличаются</h2><div>Значения сопоставляются по совпадающим колонкам.</div>`+
(d.lost.length?`<div><b>Не будут ${verb}</b> (в списке назначения нет таких колонок):<br>${esc(colNames(d.lost))}</div>`:'')+
(d.fill.length?`<div><b>Получат значение по умолчанию</b> (в исходном списке нет таких данных):<br>${esc(colNames(d.fill))}</div>`:'')+
(d.empty.length?`<div><b>Останутся пустыми</b> (в исходном списке нет таких данных):<br>${esc(colNames(d.empty))}</div>`:'')+
`<div class="row"><button onclick="dlgClose(false)">Отмена</button><button class="p" onclick="dlgClose(true)">Подтвердить</button></div>`,v=>r(v===true)));
const persist=async(l=cur())=>{try{await api('list_save',{list:l})}catch(e){fail(e);try{await load();render()}catch(_){}}};
const kindName=l=>l.kind=='custom'?'Пользовательский':'Стандартный';
function sorted(l){
const cols=listCols(l),sc=cols.find(c=>c.type=='select'),tc=cols.find(c=>c.type=='text');
const rank=it=>{if(!sc)return 0;const i=dvals(sc.dict).indexOf(it.v[sc.id]);return i<0?9999:i};
return [...l.items].sort((a,b)=>rank(a)-rank(b)||(tc?String(a.v[tc.id]||'').localeCompare(String(b.v[tc.id]||''),'ru'):0));
}
function nextName(type){
const re=new RegExp('^'+type.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')+' (\\d+)$');
return type+' '+(Math.max(0,...S.lists.map(l=>+((l.name.match(re)||[])[1]||0)))+1);
}
async function pick(id){editing=null;sel.clear();try{await load()}catch(e){fail(e)}S.cur=S.lists.some(x=>x.id==id)?id:null;history.replaceState(null,'',S.cur?'#list='+S.cur:location.pathname);render()}
function hashPick(){const m=location.hash.match(/^#list=([a-z0-9]+)$/);if(m&&S.lists.some(x=>x.id==m[1])){S.cur=m[1];sel.clear();editing=null}}
window.addEventListener('hashchange',()=>{if(me&&me.role!='admin'){hashPick();render()}});
async function delList(id){
if(!await ask('Удалить список?'))return;
try{await api('list_delete',{id})}catch(e){return fail(e)}
S.lists=S.lists.filter(x=>x.id!=id);if(S.cur==id)S.cur=null;render();
}
let nl=null;
function dlgNew(){nl={name:'Список '+today(),kind:'standard',cols:[],from:''};renderNew()}
const compatSources=()=>nl.kind=='standard'||nl.cols.includes('name')?S.lists.filter(hasName):[];
function renderNew(){
const cs=compatSources(),custom=nl.kind=='custom';
if(!cs.some(l=>l.id==nl.from))nl.from='';
openModal(`<h2>Новый список</h2>
<label>Название<input id="nl_n" value="${esc(nl.name)}" oninput="nl.name=this.value" onkeydown="if(event.key=='Enter')createList()"></label>
<label>Вид списка</label>
<label class="opt${custom?'':' on'}"><input type="radio" name="k" ${custom?'':'checked'} onchange="nl.kind='standard';renderNew()"><span>Стандартный<small>Название, кол-во, категория, расположение</small></span></label>
<label class="opt${custom?' on':''}"><input type="radio" name="k" ${custom?'checked':''} onchange="nl.kind='custom';renderNew()"><span>Пользовательский<small>Вы сами выбираете колонки: стандартные и созданные администратором</small></span></label>
${custom?`<label>Колонки таблицы</label>`+
`<div class="note" style="margin:0">Стандартные</div>`+STD.map(colChk).join('')+
(REF.columns.length?`<div class="note" style="margin:6px 0 0">Созданные администратором</div>`+REF.columns.map(colChk).join(''):''):''}
${custom&&!nl.cols.includes('name')?`<div class="note" style="margin:0">Чтобы взять предметы из другого списка, отметьте колонку «Название».</div>`:`<label>Взять предметы из существующего списка<select onchange="nl.from=this.value"><option value="">Не копировать</option>${cs.map(l=>`<option value="${l.id}"${l.id==nl.from?' selected':''}>${esc(l.name)} (${l.items.length} шт.)</option>`).join('')}</select></label>`}
<div class="row"><button onclick="closeModal()">Отмена</button><button class="p" onclick="createList()">Создать</button></div>`);
$('nl_n').focus();
}
const colChk=c=>`<label class="chk"><input type="checkbox" ${nl.cols.includes(c.id)?'checked':''} onchange="togNlCol('${c.id}',this.checked)">${esc(c.name)} <span class="tag">${TYPES[c.type]}</span></label>`;
function togNlCol(id,on){nl.cols=STD.concat(REF.columns).map(c=>c.id).filter(c=>c==id?on:nl.cols.includes(c));renderNew()}
async function createList(){
const name=nl.name.trim();
if(!name)return say('Укажите название списка');
if(nl.kind=='custom'&&!nl.cols.length)return say('Выберите хотя бы одну колонку');
const src=S.lists.find(x=>x.id==nl.from);
const l={id:uid(),name,kind:nl.kind,ltype:'',cols:nl.kind=='custom'?nl.cols:['name','qty','cat','loc'],final:false,items:[]};
if(src){
const d=colDiff(src,l);
if(!d.same&&!await confirmDiff(d,'скопированы'))return;
l.items=src.items.map(i=>({id:uid(),v:mapV(i.v,l)}));
}
S.lists.unshift(l);S.cur=l.id;editing=null;sel.clear();closeModal();render();await persist(l);
}
function inp(c,v,req){
const a=`data-c="${c.id}"`;
if(c.type=='select')return `<select ${a}>${req?'':'<option value="">—</option>'}${dvals(c.dict).map(x=>`<option${x==v?' selected':''}>${esc(x)}</option>`).join('')}</select>`;
if(c.type=='check')return `<input type="checkbox" ${a} ${v?'checked':''}>`;
return `<input ${a} type="${c.type=='number'?'number':c.type=='date'?'date':'text'}" ${c.type=='number'?'step="any"':''} value="${esc(v)}" ${c.type=='text'?`placeholder="${esc(c.name)}"`:''}>`;
}
function editRow(l,it){
const std=l.kind=='standard',v=it?it.v:(std?{cat:dvals('cats')[0],loc:dvals('locs')[0]}:{});
return `<tr class="ed" onkeydown="if(event.key=='Enter'&&event.target.tagName!='SELECT')okRow()"><td class="c"></td>`+
listCols(l).map(c=>`<td>${inp(c,v[c.id]??'',std&&(c.id=='cat'||c.id=='loc'))}</td>`).join('')+
`<td class="a"><button class="p" onclick="okRow()">ОК</button><button class="i" title="Отмена" onclick="editing=null;render()">✕</button>${it?`<button class="i" title="Удалить" onclick="delItem('${it.id}')">🗑</button>`:''}</td></tr>`;
}
const focusEd=()=>{const e=document.querySelector('tr.ed [data-c]');if(e)e.focus()};
async function okRow(){
const l=cur(),v={};
document.querySelectorAll('tr.ed [data-c]').forEach(e=>v[e.dataset.c]=e.type=='checkbox'?(e.checked?'1':''):e.value.trim());
if(l.kind=='standard'){if(!v.name){document.querySelector('[data-c=name]').focus();return}}
else if(!Object.values(v).some(x=>x))return say('Заполните хотя бы одно поле');
const again=editing=='new';
if(again)l.items.push({id:uid(),v});else l.items.find(i=>i.id==editing).v=v;
editing=again?'new':null;render();if(again)focusEd();
await persist();
}
async function delItem(id){
if(!await ask('Удалить предмет?'))return;
const l=cur();l.items=l.items.filter(i=>i.id!=id);sel.delete(id);editing=null;render();await persist();
}
async function finish(){const l=cur();if(!l.items.length)return say('Список пуст');l.final=true;editing=null;sel.clear();render();await persist()}
async function reopen(){cur().final=false;render();await persist()}
async function setLtype(v){const l=cur();l.ltype=v;render();await persist()}
async function rename(v){const l=cur();l.name=v.trim()||'Список '+today();render();await persist()}
function toggleMenu(e){e.stopPropagation();$('menu').classList.toggle('open')}
document.addEventListener('click',()=>{const m=$('menu');if(m)m.classList.remove('open')});
const chosen=()=>cur().items.filter(i=>sel.has(i.id));
function tog(id,on){on?sel.add(id):sel.delete(id);updAct()}
function setAll(on){document.querySelectorAll('.cb').forEach(c=>c.checked=on);sel=new Set(on?cur().items.map(i=>i.id):[]);updAct()}
function updAct(){const n=chosen().length,d=$('a_del');if(!d)return;['a_del','a_new','a_ex'].forEach(i=>$(i).disabled=!n);d.textContent='Удалить выбранные'+(n?` (${n})`:'')}
async function actDel(){
const n=chosen().length;if(!n||!await ask('Удалить выбранные предметы: '+n+'?'))return;
const l=cur();l.items=l.items.filter(i=>!sel.has(i.id));sel.clear();editing=null;render();await persist();
}
let tr=null;
function actNew(){
if(!chosen().length)return;
tr={type:LT[0],name:nextName(LT[0]),edited:false,move:false};renderTrNew();
}
function renderTrNew(){
openModal(`<h2>Занести в новый список</h2><div class="note">Предметов: ${chosen().length}</div>
<label>Название списка<input id="tr_n" value="${esc(tr.name)}" oninput="tr.name=this.value;tr.edited=true" onkeydown="if(event.key=='Enter')doTrNew()"></label>
<label>Тип списка<select onchange="tr.type=this.value;if(!tr.edited)tr.name=nextName(tr.type);renderTrNew()">${LT.map(t=>`<option${t==tr.type?' selected':''}>${esc(t)}</option>`).join('')}</select></label>
<label class="chk"><input type="checkbox" ${tr.move?'checked':''} onchange="tr.move=this.checked">Удалить из исходного списка (перенос вместо копирования)</label>
<div class="row"><button onclick="closeModal()">Отмена</button><button class="p" onclick="doTrNew()">Создать</button></div>`);
$('tr_n').focus();
}
const doTrNew=()=>{if(!tr.name.trim())return say('Укажите название списка');doTransfer({name:tr.name.trim(),ltype:tr.type},tr.move)};
function actEx(){
const l=cur();if(!chosen().length)return;
const cands=hasName(l)?S.lists.filter(x=>x.id!=l.id&&!x.final&&hasName(x)):[];
tr={to:cands[0]?cands[0].id:'',move:false};
const why=hasName(l)?'Подходящих списков нет. Нужен другой открытый список с колонкой «Название». Можно создать новый список или воспользоваться пунктом «Занести в новый список».':'В этом списке нет колонки «Название», поэтому перенос в другие списки недоступен. Используйте «Занести в новый список».';
openModal(`<h2>Занести в существующий список</h2><div class="note">Предметов: ${chosen().length}. Показаны открытые списки с колонкой «Название»; если колонки отличаются, перед переносом будет предупреждение.</div>`+
(cands.length?`<label>Список назначения<select onchange="tr.to=this.value">${cands.map(x=>`<option value="${x.id}">${esc(x.name)}${x.ltype?' · '+esc(x.ltype):''} (${x.items.length} шт.)${colDiff(l,x).same?'':' · колонки отличаются'}</option>`).join('')}</select></label>
<label class="chk"><input type="checkbox" onchange="tr.move=this.checked">Удалить из исходного списка (перенос вместо копирования)</label>`:`<div class="empty">${why}</div>`)+
`<div class="row"><button onclick="closeModal()">Отмена</button>${cands.length?`<button class="p" onclick="doExisting()">Перенести</button>`:''}</div>`);
}
async function doExisting(){
const d=colDiff(cur(),S.lists.find(x=>x.id==tr.to));
if(!d.same&&!await confirmDiff(d,'перенесены'))return;
doTransfer({existing:tr.to},tr.move,!d.same);
}
async function doTransfer(to,move,confirm=false){
const l=cur(),n=chosen().length;
try{
const j=await api('transfer',{from:l.id,ids:[...sel],to,move,confirm});
S.lists=j.lists;sel.clear();editing=null;closeModal();render();
const t=S.lists.find(x=>x.id==j.target);
toast(`Перенесено: ${n} → «${t?t.name:''}». Открыть список`,()=>pick(j.target));
}catch(e){fail(e)}
}
function render(){
const l=cur();
$('s').innerHTML=`<h2>Списки</h2>`+(S.lists.length?'':'<div class="tag" style="padding:4px 8px 8px">Списков пока нет</div>')+
S.lists.map(x=>`<div class="li${x.id==S.cur?' on':''}" onclick="pick('${x.id}')">
<span>${esc(x.name)}<br><span class="tag">${x.ltype?esc(x.ltype)+' · ':''}${x.kind=='custom'?'польз. · ':''}${x.items.length} шт. · ${x.final?'итог':'черновик'}</span></span>
<button class="i" title="Удалить список" onclick="event.stopPropagation();delList('${x.id}')">✕</button></div>`).join('')+
`<div style="margin-top:10px;border-top:1px solid var(--bd);padding-top:10px"><button class="p" style="width:100%" onclick="dlgNew()">+ Новый список</button></div>`;
if(!l){$('m').innerHTML='';return}
const cols=listCols(l),lts=LT.includes(l.ltype)||!l.ltype?LT:[...LT,l.ltype],
meta=`<span class="badge">${kindName(l)}</span> <label class="tag" style="display:flex;gap:4px;align-items:center">Тип:<select style="width:auto" onchange="setLtype(this.value)"><option value="">без типа</option>${lts.map(t=>`<option${t==l.ltype?' selected':''}>${esc(t)}</option>`).join('')}</select></label>`;
let h=`<div class="title">`+(l.final?`<h2 style="flex:1;font-size:18px">${esc(l.name)}</h2>${meta}`:
`<input value="${esc(l.name)}" onchange="rename(this.value)">${meta}
<div class="dd"><button onclick="toggleMenu(event)">Действия ▾</button><div class="menu" id="menu">
<button id="a_del" onclick="actDel()">Удалить выбранные</button><button id="a_new" onclick="actNew()">Занести в новый список…</button><button id="a_ex" onclick="actEx()">Занести в существующий список…</button></div></div>`)+`</div><div class="card" style="margin-bottom:0">`;
if(l.final){
h+=`<table><tr><th>№</th>${cols.map(c=>`<th>${esc(c.name)}</th>`).join('')}</tr>`+
sorted(l).map((i,k)=>`<tr><td>${k+1}</td>${cols.map(c=>`<td>${esc(show(c,i.v[c.id]))}</td>`).join('')}</tr>`).join('')+`</table></div>${l.ltype?qrBlock(l):'<p class="note" style="margin-top:12px">QR-код показывается у списков с типом (Коробка, Мешок, Полка…). Выберите тип в заголовке списка, и здесь появится QR-код, он же попадёт в Word-отчёт.</p>'}<div class="bar"><button class="p" onclick="exp('xls')">Скачать XLS</button><button class="p" onclick="exp('doc')">Скачать Word</button>${l.ltype?`<button class="p" onclick="exp('qr')">Скачать QR (PNG)</button>`:''}<span class="sp"></span><button onclick="reopen()">Редактировать</button></div>`;
}else{
h+=`<table><tr><th class="c"><input type="checkbox" onchange="setAll(this.checked)"></th>${cols.map(c=>`<th>${esc(c.name)}</th>`).join('')}<th></th></tr>`+
l.items.map(i=>editing==i.id?editRow(l,i):`<tr><td class="c"><input class="cb" type="checkbox" ${sel.has(i.id)?'checked':''} onchange="tog('${i.id}',this.checked)"></td>${cols.map(c=>`<td>${esc(show(c,i.v[c.id]))}</td>`).join('')}
<td class="a"><button class="i" title="Редактировать" onclick="editing='${i.id}';render();focusEd()">✏️</button></td></tr>`).join('')+
(editing=='new'?editRow(l):'')+
(!l.items.length&&!editing?`<tr><td colspan="${cols.length+2}" class="empty">Пока пусто — нажмите «+ Предмет»</td></tr>`:'')+
`</table></div><div class="bar"><button class="p" onclick="editing='new';render();focusEd()">+ Предмет</button><span class="sp"></span><button class="p" onclick="finish()">ИТОГ</button></div>`;
}
$('m').innerHTML=h;updAct();
}
const listUrl=l=>location.origin+location.pathname+'#list='+l.id;
function qrBlock(l){
const u=listUrl(l);let q=null,err='';
try{q=QR.make(u,{label:l.name});if(!q)err='Адрес списка слишком длинный для QR-кода ('+u.length+' символов).'}catch(e){err='Не удалось построить QR-код: '+e.message}
return `<div class="card qr">${q?`<img src="${q.url}" alt="QR">`:''}${err?`<div style="color:var(--er)">${esc(err)}</div>`:''}<div><b>QR-код списка «${esc(l.name)}»</b><div class="tag">Открывает этот список после входа в систему. Название списка напечатано под кодом.</div><div class="tag" style="overflow-wrap:anywhere">${esc(u)}</div></div></div>`}
function dl(content,mime,name){
const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([content],{type:mime}));
a.download=name.replace(/[\\/:*?"<>|]/g,'_');document.body.appendChild(a);a.click();a.remove();
}
function exp(ext){
const l=cur(),u=listUrl(l),cols=listCols(l);
if(ext=='qr'){
let q=null;try{q=QR.make(u,{label:l.name})}catch(e){return say('Не удалось построить QR-код: '+e.message)}
if(!q)return say('Адрес списка слишком длинный для QR-кода');
const bin=atob(q.url.split(',')[1]),b=new Uint8Array(bin.length);for(let i=0;i<bin.length;i++)b[i]=bin.charCodeAt(i);
return dl(b,'image/png','QR '+l.name+'.png');
}
const tbl=`<table border="1" cellspacing="0" cellpadding="4"><tr><th>№</th>${cols.map(c=>`<th>${esc(c.name)}</th>`).join('')}</tr>`+
sorted(l).map((i,k)=>`<tr><td>${k+1}</td>${cols.map(c=>`<td>${esc(show(c,i.v[c.id]))}</td>`).join('')}</tr>`).join('')+`</table>`;
const head=`<h2>${esc(l.name)}</h2><p>${kindName(l)} список${l.ltype?' · '+esc(l.ltype):''}</p>`;
if(ext=='xls')return dl(''+`<html><head><meta charset="utf-8"></head><body>${head}${tbl}${l.ltype?`<p>Ссылка на список: <a href="${esc(u)}">${esc(u)}</a></p>`:''}</body></html>`,'application/vnd.ms-excel;charset=utf-8',l.name+'.xls');
const b64=t=>t.replace(/.{76}/g,'$&\r\n'),qm=l.ltype?QR.make(u,{label:l.name}):null,qr=qm?qm.url:'';
const html=`<html><head><meta charset="utf-8"></head><body>${head}${tbl}${l.ltype?`<p>${qr?`<img src="qr.png" width="200" height="${Math.round(200*qm.h/qm.w)}"><br>`:''}${esc(u)}</p>`:''}</body></html>`;
let m='MIME-Version: 1.0\r\nContent-Type: multipart/related; boundary="BND"; type="text/html"\r\n\r\n--BND\r\nContent-Type: text/html; charset="utf-8"\r\nContent-Transfer-Encoding: base64\r\n\r\n'+b64(btoa(unescape(encodeURIComponent(html))))+'\r\n';
if(qr)m+='--BND\r\nContent-Location: qr.png\r\nContent-Transfer-Encoding: base64\r\nContent-Type: image/png\r\n\r\n'+b64(qr.split(',')[1])+'\r\n';
dl(m+'--BND--','application/msword',l.name+'.doc');
}
(async()=>{try{const j=await api('me');applyInfo(j);await showApp()}catch(e){$('lg').focus()}})();
</script></body></html>