Files
inventorEZ/index.html
T

642 lines
64 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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|AAAAADgDgDgDgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","a":"15|AAAAAAAAAAAAAAAAAAAAAAA/wP/BB8AHg/8P/j48eHjw8fPh/8H3gAAAAAAAAAAAAAAA","b":"16|AAAAAAAAAAA8ADwAPAA8ADwAPfA/+D48PDw8HjwePB48Hjw8Pjw/+D3wAAAAAAAAAAAAAAAA","c":"13|AAAAAAAAAAAAAAAAAAAAPwf8fGfAPAHgDwB4A+APjD/gfgAAAAAAAAAAAAA","d":"16|AAAAAAAAAAAAPAA8ADwAPAA8Dzwf/Dx8eHx4PHg8eDx4PHh8PHwf/A88AAAAAAAAAAAAAAAA","e":"15|AAAAAAAAAAAAAAAAAAAAAAAfgH/B48eDjwef/z/+eADwAPBg/8B/AAAAAAAAAAAAAAAA","f":"10|AAAAAAAPx/Pg8Dw///zwPA8DwPA8DwPA8DwAAAAAAAAAAA","g":"16|AAAAAAAAAAAAAAAAAAAAAAAADzwf/Dx8fHx4PHg8eDx4PHh8PHwf/A88ADwAeDD4P/APwAAA","h":"16|AAAAAAAAAAA8ADwAPAA8ADwAPfA/+D58PDw8PDw8PDw8PDw8PDw8PDw8AAAAAAAAAAAAAAAA","i":"8|AAAAADw8PDwAPDw8PDw8PDw8PDw8AAAAAAAA","j":"8|AAAAADw8PDwAPDw8PDw8PDw8PDw8PDx4+PAA","k":"16|AAAAAAAAAAA8ADwAPAA8ADwAPDw8eDzwPeA/wD+AP8A/4D3wPPg8fDw+AAAAAAAAAAAAAAAA","l":"8|AAAAADw8PDw8PDw8PDw8PDw8PDw8AAAAAAAA","m":"23|AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB748D//8Hz88PHx4ePjw8PHh4ePDw8eHh48PDx4eHjw8PHgAAAAAAAAAAAAAAAAAAAAAAA","n":"16|AAAAAAAAAAAAAAAAAAAAAAAAPfA/+D58PDw8PDw8PDw8PDw8PDw8PDw8AAAAAAAAAAAAAAAA","o":"15|AAAAAAAAAAAAAAAAAAAAAAAfwH/B48fDzweeDzweeDz4ePHg/4D+AAAAAAAAAAAAAAAA","p":"16|AAAAAAAAAAAAAAAAAAAAAAAAPfA/+D48PDw8HjwePB48Hjw8Pjw/+D3wPAA8ADwAPAA8AAAA","q":"16|AAAAAAAAAAAAAAAAAAAAAAAADzwf/Dx8eHx4PHg8eDx4PHh8PHwf/A88ADwAPAA8ADwAPAAA","r":"11|AAAAAAAAAAAAAAAAB7z/nwPAeA8B4DwHgPAeA8AAAAAAAAAAAAA","s":"13|AAAAAAAAAAAAAAAAAAAA/g/48MeAP4H/h/4D8AeYPP/B/AAAAAAAAAAAAAA","t":"11|AAAAAAAAAAAPAeA8H/v/HgPAeA8B4DwHgPAP4PwAAAAAAAAAAAA","u":"16|AAAAAAAAAAAAAAAAAAAAAAAAPDw8PDw8PDw8PDw8PDw8PDx8PHw//A88AAAAAAAAAAAAAAAA","v":"15|AAAAAAAAAAAAAAAAAAAAAAHgeeDjw8PHh54PPA94H+AfwD8AfgB8AAAAAAAAAAAAAAAA","w":"20|AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAePDnjx548eOPnj35w928PdvB+fwfn4H4+B+PgPD4AAAAAAAAAAAAAAAAAAAAA","x":"14|AAAAAAAAAAAAAAAAAAAAAeHjz488H+A/AHwD8A/gf4PPHx54fAAAAAAAAAAAAAAA","y":"14|AAAAAAAAAAAAAAAAAAAAA8D3g54ePHjxwc8HvB/gP4D+AfAHwB4AeAHgPwD4AAAA","z":"13|AAAAAAAAAAAAAAAAAAAD/5/8A+A+A+A+A/A/A/AfAP/n/wAAAAAAAAAAAAA","{":"16|AAAAAAAAAAAA+AH4A8ADwAPAA8ADwAPAB4AfgB+AB8ADwAPAA8ADwAPAA8AB+AD4AAAAAAAA","|":"8|AAAAABgYGBgYGBgYGBgYGBgYGBgYGBgYGBgA","}":"16|AAAAAAAAAAAfAB+AB8ADwAPAA8ADwAPAA+AB+AH4A+ADwAPAA8ADwAPAB4AfgB8AAAAAAAAA","~":"18|AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD8MP/8P/8MH4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","А":"17|AAAAAAAAAAAAAAAfAB/AD+AH8Af8A94B7wHjwPHg+Ph//D/+PAeeA88B7wB4AAAAAAAAAAAAAAAAAA","Б":"17|AAAAAAAAAAAAAAH/4P/weAA8AB4ADwAHgAP/Af/g8Ph4PDweHg8PD4f/g/8AAAAAAAAAAAAAAAAAAA","В":"17|AAAAAAAAAAAAAAH/gP/weHg8Ph4fDw8Hh4P/gf/g8Ph4PDweHg8PD4f/g/+AAAAAAAAAAAAAAAAAAA","Г":"14|AAAAAAAAAAAA/+P/jwA8APADwA8APADwA8APADwA8APADwA8AAAAAAAAAAAAAAAA","Д":"20|AAAAAAAAAAAAAAAAAP/wD/8A8PAPDwDw8A8PAPDwDw8A8PAPDwDw8A8PAPDwHw8H//x//8eAHHgBx4AcAAAAAAAAAAA","Е":"15|AAAAAAAAAAAAB/8P/h4APAB4APAB4AP/h/8PAB4APAB4APAB/8P/gAAAAAAAAAAAAAAA","Ж":"27|AAAAAAAAAAAAAAAAAAAAAAD4Pg+Ph8Pg+Pj4D58+APvvgA9/4AD/+AA//4AP//gB//8AfPnwHx8fA8Ph4Ph8Pj4Pg+eB8DwAAAAAAAAAAAAAAAAAAAAAAAAAAAA","З":"16|AAAAAAAAAAAAAB/gP/ggeAB8ADwAfAB4D/AP+AB8ADwAPAA8YHx/+B/gAAAAAAAAAAAAAAAA","И":"18|AAAAAAAAAAAAAAAPB8PB8PD8PD8PH8PH8PP8PO8Pe8Pc8P88P48P48Pw8Pw8Pg8AAAAAAAAAAAAAAAAAAA","Й":"18|BjAA/AAeAAAAAAAPB8PB8PD8PD8PH8PH8PP8PO8Pe8Pc8P88P48P48Pw8Pw8Pg8AAAAAAAAAAAAAAAAAAA","К":"18|AAAAAAAAAAAAAAAPA+PB8PD4PHwPPgPfAP+AP/AP/AP/gPnwPD4PB4PB8PA+PAeAAAAAAAAAAAAAAAAAAA","Л":"18|AAAAAAAAAAAAAAAB/8B/8B48B48B48B48B48B48B48B48Dw8Dw8Dw8Hg8fA8cA8AAAAAAAAAAAAAAAAAAA","М":"22|AAAAAAAAAAAAAAAAAAD8D8PwPw/B/D+H8P4fw/z/D3O8Pf7w8/PDz88PHjw8ePDx48PADw8APDwA8AAAAAAAAAAAAAAAAAAAAAAA","Н":"18|AAAAAAAAAAAAAAAPA8PA8PA8PA8PA8PA8PA8P/8P/8PA8PA8PA8PA8PA8PA8PA8AAAAAAAAAAAAAAAAAAA","О":"19|AAAAAAAAAAAAAAAAB/AD/4D4+D4Ph4Dx8B4+A+eAfPAPnwHz4Dw8B4fB8Hx8B/8AP4AAAAAAAAAAAAAAAAAAAAA","П":"18|AAAAAAAAAAAAAAAP/8P/8PA8PA8PA8PA8PA8PA8PA8PA8PA8PA8PA8PA8PA8PA8AAAAAAAAAAAAAAAAAAA","Р":"16|AAAAAAAAAAAAAD/wP/w8PjwePB48HjwePD4//D/wPAA8ADwAPAA8ADwAAAAAAAAAAAAAAAAA","С":"16|AAAAAAAAAAAAAAP8D/4fDj4CPAB8AHwAeAB4AHwAfAA8AD4CHw4P/gP8AAAAAAAAAAAAAAAA","Т":"15|AAAAAAAAAAAAH////4PgB8APgB8APgB8APgB8APgB8APgB8APgB8AAAAAAAAAAAAAAAA","У":"17|AAAAAAAAAAAAAAPAefB8eDw+Pg8eB98D7wD/gH+AH8APwAPgAeAA8APwAfAAAAAAAAAAAAAAAAAAAA","Ф":"22|AAAAAAAAAAAAAAAAAAAB4AAHgAD/wB//4Pnnw8ePHx4+eHj54ePnx4+PHjw+efB//4A/8AAeAAB4AAAAAAAAAAAAAAAAAAAAAAAA","Х":"17|AAAAAAAAAAAAAAPAePB4fHwePAe8A/4A/gA+AD8AH8Af8B98Dx4Pj4+D54DwAAAAAAAAAAAAAAAAAA","Ц":"20|AAAAAAAAAAAAAAAAA8DwPA8DwPA8DwPA8DwPA8DwPA8DwPA8DwPA8DwPA8DwPA8D//4//+AADgAA4AAOAAAAAAAAAAA","Ч":"18|AAAAAAAAAAAAAAAPA8PA8PA8PA8PA8PA8Pg8H/8D/8AA8AA8AA8AA8AA8AA8AA8AAAAAAAAAAAAAAAAAAA","Ш":"27|AAAAAAAAAAAAAAAAAAAAAAB4Pg8PB8Hh4Pg8PB8Hh4Pg8PB8Hh4Pg8PB8Hh4Pg8PB8Hh4Pg8PB8Hh4Pg8PB8Hh///8P///gAAAAAAAAAAAAAAAAAAAAAAAAAAAA","Щ":"29|AAAAAAAAAAAAAAAAAAAAAAAAHg+DwPB8HgeD4PA8HweB4Pg8DwfB4Hg+DwPB8HgeD4PA8HweB4Pg8DwfB4Hg+DwPB8Hgf///4////wAAADgAAAHAAAAOAAAAAAAAAAAAAAA","Ъ":"21|AAAAAAAAAAAAAAAAAD/wAf+AD/wAA+AAHwAA+AAH/gA//AH/8A+PgHw+A+HwHw8A//gH/4A/+AAAAAAAAAAAAAAAAAAAAAA","Ы":"23|AAAAAAAAAAAAAAAAAAAHgA8PAB4eADw8AHh4APDwAeH/g8P/x4eHzw8Hnh4PPDweeHg88PD54f/jw/8HgAAAAAAAAAAAAAAAAAAAAAAA","Ь":"17|AAAAAAAAAAAAAAHgAPAAeAA8AB4ADwAH/gP/weHw8Hh4PDweHg8PD4f/g/8AAAAAAAAAAAAAAAAAAA","Э":"16|AAAAAAAAAAAAAD/gf/B/+GB8ADwAPg/+H/4f/g/+AD4APGB8f/h/8D/gAAAAAAAAAAAAAAAA","Ю":"26|AAAAAAAAAAAAAAAAAAAAAA8B/APB/8Dw+Pg8fB8PHgPDz4Dw8+A+P/APj/wD48+A+PPgPDx4Dw8fB8PD4+Dwf/A8B/AAAAAAAAAAAAAAAAAAAAAAAAAAAA","Я":"17|AAAAAAAAAAAAAAA/8H/4PDw+Hh4PDweDw8Hh4H/wH/gPPA+eD48Hh4fDw8HgAAAAAAAAAAAAAAAAAA","а":"15|AAAAAAAAAAAAAAAAAAAAAAA/wP/BB8AHg/8P/j48eHjw8fPh/8H3gAAAAAAAAAAAAAAA","б":"15|AAAAAAAAAAA/A/8PABwAcADvwf/j48fDzweeDzweeDx4ePHg/8B+AAAAAAAAAAAAAAAA","в":"14|AAAAAAAAAAAAAAAAAAAAAP+D/w8cPHD/w/4PHDx48ePHj/w/4AAAAAAAAAAAAAAA","г":"12|AAAAAAAAAAAAAAAAAAP+P+PAPAPAPAPAPAPAPAPAPAAAAAAAAAAAAAA","д":"18|AAAAAAAAAAAAAAAAAAAAAAAAAAAD/4D/4Dx4Dx4Dx4Dx4Dx4Dx4Hx4Hh4f/+f/+cAOcAOcAOAAAAAAAAAA","е":"15|AAAAAAAAAAAAAAAAAAAAAAAfgH/B48eDjwef/z/+eADwAPBg/8B/AAAAAAAAAAAAAAAA","ж":"22|AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAfHj4+efB974D//AH/4Af/gD/+AP/8B554PHjx8ePHh4eAAAAAAAAAAAAAAAAAAAAAAA","з":"13|AAAAAAAAAAAAAAAAAAAA/A/4Q8AeD/B/ADwA4AcYeP/D+AAAAAAAAAAAAAA","и":"15|AAAAAAAAAAAAAAAAAAAAAAB4+PHx5+Pfx7+P/x/+P7x+ePjx8ePDwAAAAAAAAAAAAAAA","й":"15|AAAAAAAAAADGAPwA8AAAAAB4+PHx5+Pfx7+P/x/+P7x+ePjx8ePDwAAAAAAAAAAAAAAA","к":"15|AAAAAAAAAAAAAAAAAAAAAAB4+PPh74P+B/gP8B/gPeB54PHh4+PDwAAAAAAAAAAAAAAA","л":"16|AAAAAAAAAAAAAAAAAAAAAAAAD/wP/A88DzwPPA88DzwOPB48Pjx8PHA8AAAAAAAAAAAAAAAA","м":"18|AAAAAAAAAAAAAAAAAAAAAAAAAAAPh8Ph8Pj8Pz8Pz8P/8P/8Pe8Pe8Pe8PA8PA8AAAAAAAAAAAAAAAAAAA","н":"15|AAAAAAAAAAAAAAAAAAAAAAB48PHh48PHh48P/h/8PHh48PHh48PHgAAAAAAAAAAAAAAA","о":"15|AAAAAAAAAAAAAAAAAAAAAAAfwH/B48fDzweeDzweeDz4ePHg/4D+AAAAAAAAAAAAAAAA","п":"15|AAAAAAAAAAAAAAAAAAAAAAB/8P/h48PHh48PHh48PHh48PHh48PHgAAAAAAAAAAAAAAA","р":"16|AAAAAAAAAAAAAAAAAAAAAAAAPfA/+D48PDw8HjwePB48Hjw8Pjw/+D3wPAA8ADwAPAA8AAAA","с":"13|AAAAAAAAAAAAAAAAAAAAPwf8fGfAPAHgDwB4A+APjD/gfgAAAAAAAAAAAAA","т":"13|AAAAAAAAAAAAAAAAAAAH///+HgDwB4A8AeAPAHgDwB4A8AAAAAAAAAAAAAA","у":"14|AAAAAAAAAAAAAAAAAAAAA8D3g54ePHjxwc8HvB/gP4D+AfAHwB4AeAHgPwD4AAAA","ф":"22|AAAAAAAAAAAAAAAAeAAB4AAHgAAeAAB4AD3vAf/+Dz58PHjx4ePnh4eeHh54ePjx48PPnwf/+A97wAHgAAeAAB4AAHgAAeAAAAAA","х":"14|AAAAAAAAAAAAAAAAAAAAAeHjz488H+A/AHwD8A/gf4PPHx54fAAAAAAAAAAAAAAA","ц":"16|AAAAAAAAAAAAAAAAAAAAAAAAPHg8eDx4PHg8eDx4PHg8eDx4PHg//j/+AAYABgAGAAAAAAAA","ч":"15|AAAAAAAAAAAAAAAAAAAAAADw8eHjw8eHh48P/g/8AHgA8AHgA8AHgAAAAAAAAAAAAAAA","ш":"23|AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB4ePDw8eHh48PDx4eHjw8PHh4ePDw8eHh48PDx4f//w///gAAAAAAAAAAAAAAAAAAAAAAA","щ":"24|AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPDx4PDx4PDx4PDx4PDx4PDx4PDx4PDx4PDx4PDx4P//+P//+AAAGAAAGAAAGAAAAAAAAAAAA","ъ":"17|AAAAAAAAAAAAAAAAAAAAAAAAAH+AP8AB4ADwAH8AP/Af/A8PB4eD58H/wP/AAAAAAAAAAAAAAAAAAA","ы":"20|AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPAPDwDw8A8PAPDwDw/48P/PDx7w8e8PHvD/zw/48AAAAAAAAAAAAAAAAAAAAA","ь":"14|AAAAAAAAAAAAAAAAAAAAAPADwA8APADwA/4P/Dx48ePHj/w/4AAAAAAAAAAAAAAA","э":"13|AAAAAAAAAAAAAAAAAAAB/B/w/8AfAHh/w/4A8A+f+P+D+AAAAAAAAAAAAAA","ю":"21|AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB4PwPH/h548PPDx7weP+Dx/wePeDx54ePPHh4/8PB+AAAAAAAAAAAAAAAAAAAAAA","я":"14|AAAAAAAAAAAAAAAAAAAAAH/D/w88OPDjw88H/B/we8PPDjx48AAAAAAAAAAAAAAA","Ё":"15|DuAdwDuAAAAAB/8P/h4APAB4APAB4AP/h/8PAB4APAB4APAB/8P/gAAAAAAAAAAAAAAA","ё":"15|AAAAAAAAAADuAdwDuAAAAAAfgH/B48eDjwef/z/+eADwAPBg/8B/AAAAAAAAAAAAAAAA","№":"26|AAAAAAAAAAAAAAAAAAAAAA+A8APwfAD8HgA/jwAP48ED/PDw9zxmPe8ZjzvGY8/xmPH8fjx/Dw8PwAfD8AHwfP54Hz+AAAAAAAAAAAAAAAAAAAAAAAAAAA","«":"14|AAAAAAAAAAAAAAAAAAAAAAAAIQGMDnD3g5wOcD3gOcBjAIQAAAAAAAAAAAAAAAAA","»":"14|AAAAAAAAAAAAAAAAAAAAAAACEAxgOeB7wOODjh7w54MYCEAAAAAAAAAAAAAAAAAA","–":"11|AAAAAAAAAAAAAAAAAAAAAAAA/5/z/gAAAAAAAAAAAAAAAAAAAAA","—":"22|AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB///n//+f//4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","…":"22|AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADx48PHjw8ePDx48AAAAAAAAAAAAAAAAAAAAAAA","·":"8|AAAAAAAAAAAAAAA8PDw8AAAAAAAAAAAAAAAA","°":"11|AAAAAAAAABwPwYwxhjD8BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}};
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>