8423 lines
730 KiB
HTML
8423 lines
730 KiB
HTML
<!doctype html><html lang="ru"><head><script src="core/banquet-menu.js?v=20260917-banquet-menu"></script><script src="core/access-policy.js?v=20260917-banquet-menu"></script><style>body.sun-cloud-auth-required>header,body.sun-cloud-auth-required>.view{visibility:hidden!important;pointer-events:none!important}</style><meta charset="utf-8"><script defer src="core/import-archive.js?v=20260917-banquet-menu"></script><script src="core/company-branding.js?v=20260917-banquet-menu"></script><script src="core/brand-theme.js?v=20260917-banquet-menu"></script><script id="sun-startup-stability-guard">
|
||
(()=>{
|
||
'use strict';
|
||
const stamp=()=>new Date().toISOString().replace(/[:.]/g,'-');
|
||
const schemas={
|
||
sunBoxes:'array',sunOrders:'array',sunStock:'array',sunStockMoves:'array',
|
||
sunEmployees:'array',sunSuppliers:'array',sunFinanceRecordsV2:'array',
|
||
sunRoutePlansV1:'object',sunClientLoyaltyV1:'object',sunEnterpriseSettingsV1:'object',
|
||
sunStaticMapGeocodeCacheV1:'object'
|
||
};
|
||
try{
|
||
Object.entries(schemas).forEach(([key,type])=>{
|
||
const raw=localStorage.getItem(key);if(raw==null||raw==='')return;
|
||
let value,valid=true;
|
||
try{value=JSON.parse(raw);}catch(_){valid=false;}
|
||
if(valid){valid=type==='array'?Array.isArray(value):Boolean(value)&&typeof value==='object'&&!Array.isArray(value);}
|
||
if(valid)return;
|
||
try{localStorage.setItem(`sunRecoveryCorrupt.${key}.${stamp()}`,raw.slice(0,250000));}catch(_){ }
|
||
try{localStorage.removeItem(key);}catch(_){ }
|
||
});
|
||
}catch(_){ }
|
||
|
||
window.sunFetchWithTimeout=window.sunFetchWithTimeout||async function(url,options={},timeout=8000){
|
||
const controller=new AbortController();
|
||
const timer=setTimeout(()=>controller.abort(),Math.max(1000,Number(timeout)||8000));
|
||
try{
|
||
return await fetch(url,{...options,signal:options.signal||controller.signal});
|
||
}finally{clearTimeout(timer);}
|
||
};
|
||
})();
|
||
</script><meta name="viewport" content="width=device-width,initial-scale=1"><title>Caterium</title><meta name="theme-color" content="#15364c"><link rel="icon" href="caterium-mark-light.svg" type="image/svg+xml"><style>:root{--n:#15364c;--g:#c99a32;--p:#f5f2eb;--l:#d9dde0}*{box-sizing:border-box}body{margin:0;background:var(--p);font:15px Arial;color:#203040}header{height:70px;background:var(--n);color:#fff;display:flex;align-items:center;padding:0 24px;gap:40px}.brand{font-size:22px;font-weight:bold}.sun{color:#f7d45d;font-size:32px;margin-right:8px}nav button{height:46px;background:transparent;color:#dce8ec;border:0;border-bottom:3px solid transparent;padding:0 15px;font-weight:bold}nav button.on{color:#fff;border-color:var(--g)}button,input,select,textarea{font:inherit}button{cursor:pointer}.view{display:none}.view.on{display:block}.work{min-height:calc(100vh - 70px);padding:14px;display:grid;grid-template-columns:minmax(320px,1.2fr) 180px minmax(360px,1fr);gap:10px}.card,.page{background:#fff;border-radius:9px;padding:16px;box-shadow:0 1px 3px #0001}.catalog-head{display:flex;justify-content:space-between;align-items:center}.catalog h1,.page h1{margin:0;color:var(--n);font-size:23px}.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:10px;margin-top:14px}.tile{min-height:152px;border:1px solid var(--l);background:#f0f2f4;border-radius:6px;padding:7px;font-weight:bold;color:#203040}.tile:hover{border-color:var(--g)}.tile img,.ph{width:100%;height:88px;object-fit:cover;border-radius:4px}.ph{display:grid;place-items:center;color:var(--g);background:#e2e9eb;font-size:36px}.add{background:#fffaf0;color:var(--n);font-size:16px}.cats{padding:12px;background:#e9efe8;border-radius:9px}.cats button{width:100%;border:0;padding:15px 7px;border-radius:5px;background:#c9d0d8;color:#fff;font-weight:bold;margin-bottom:8px}.cats button:first-child{background:var(--g)}.cats p{color:#68717a;font-size:13px}.subnav{border-bottom:1px solid var(--l);margin-bottom:14px}.subnav button{background:#fff;border:0;padding:9px 14px;color:#68717a;font-weight:bold}.subnav button.on{color:var(--n);border-bottom:3px solid var(--g)}label{display:flex;flex-direction:column;gap:5px;color:#68717a;font-size:13px}input,select,textarea{border:1px solid #cfd5da;border-radius:4px;padding:8px;color:#203040;background:#fff}.form{display:grid;grid-template-columns:1fr 135px 90px;gap:9px}.money-form{display:grid;grid-template-columns:repeat(3,1fr);gap:9px;margin-top:14px}.order-summary{margin-top:14px;padding:12px;border:1px solid var(--l);border-radius:7px;background:#fafafa}.order-summary p{display:flex;justify-content:space-between;margin:5px 0}.order-summary .balance{font-size:18px;font-weight:bold;color:var(--n)}.order-actions{display:flex;gap:6px;align-items:center}.mini{padding:6px 9px;border-radius:4px;border:1px solid var(--l);background:#fff;color:var(--n)}.lines{margin-top:18px;border-top:1px solid var(--l);min-height:230px}.line{display:grid;grid-template-columns:1fr 30px 48px 30px;align-items:center;gap:5px;padding:8px 0;border-bottom:1px solid var(--l)}.line button{height:28px;border:1px solid #c8cdd1;background:#f1f3f4;border-radius:4px}.line input{text-align:center;padding:4px}.actions{display:flex;gap:8px;margin-top:15px}.primary,.outline,.danger{padding:9px 13px;border-radius:5px;font-weight:bold}.primary{background:var(--n);color:#fff;border:1px solid var(--n)}.outline{background:#fff;color:var(--n);border:1px solid var(--g)}.danger{color:#a33;border:1px solid #dca;background:#fff4f4}.empty{color:#879198;padding:18px 0}.page{margin:18px;min-height:calc(100vh - 106px)}.row,.head{display:grid;grid-template-columns:65px 1.2fr 150px 1.2fr 175px;gap:8px;align-items:center;padding:10px;border-bottom:1px solid var(--l)}.head{color:#68717a;font-size:13px;font-weight:bold}.row{background:#fff;border:0;width:100%;text-align:left}.row:hover{background:#f8f8f7}.modal{display:none;position:fixed;inset:0;background:#102c3d88;place-items:center;padding:18px}.modal.on{display:grid}.dialog{width:min(820px,100%);max-height:90vh;overflow:auto;background:#fff;padding:20px;border-radius:10px}.dialog-head{display:flex;justify-content:space-between;align-items:center}.dialog-head h2{margin:0;color:var(--n)}.dialog-head button{border:0;background:#fff;font-size:28px}.manager{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:8px;margin-top:14px}.manager button{border:1px solid var(--l);background:#fff;text-align:left;padding:9px;display:grid;grid-template-columns:48px 1fr;gap:7px}.manager img,.manager .ph{width:46px;height:46px}.manager small{grid-column:2;color:#68717a}.ingredient{display:grid;grid-template-columns:1fr 90px 85px 30px;gap:5px;margin:6px 0}.price-field{margin:10px 0}.ingredient button{border:1px solid #e3bcbc;color:#a33;background:#fff4f4}.editphoto{max-height:150px;max-width:220px;margin:8px 0;border-radius:5px}.prep{display:grid;grid-template-columns:1fr 1fr;gap:0 24px}.prep p{display:flex;justify-content:space-between;border-bottom:1px solid var(--l);padding:8px 0;margin:0}.hint{color:#68717a;font-size:13px}@media(max-width:900px){.money-form{grid-template-columns:1fr}.order-actions{flex-wrap:wrap}header{height:auto;flex-direction:column;align-items:flex-start;padding:12px 16px;gap:4px}nav{overflow:auto;white-space:nowrap}.work{grid-template-columns:1fr;min-height:0}.cats{display:flex;gap:8px}.cats button{margin:0;min-width:150px}.cats p{display:none}.form{grid-template-columns:1fr}.row,.head{grid-template-columns:55px 1fr 125px}.row span:nth-child(4),.head span:nth-child(4){display:none}.row select{grid-column:2/4}.prep{grid-template-columns:1fr}}@media print{header,.catalog,.cats,.subnav,.form,.lines,.actions,.modal:not(#preview){display:none!important}.work{display:block;padding:0}.card{box-shadow:none}.modal#preview{display:block;position:static;background:#fff;padding:0}.modal#preview .dialog{width:100%;max-height:none}.modal#preview .dialog button{display:none}}</style><script>function sunUUID(){const c=globalThis.crypto,b=new Uint8Array(16);if(c&&typeof c.getRandomValues==='function')c.getRandomValues(b);else for(let i=0;i<16;i++)b[i]=Math.floor(Math.random()*256);b[6]=(b[6]&15)|64;b[8]=(b[8]&63)|128;const h=[...b].map(x=>x.toString(16).padStart(2,'0')).join('');return `${h.slice(0,8)}-${h.slice(8,12)}-${h.slice(12,16)}-${h.slice(16,20)}-${h.slice(20)}`}</script>
|
||
<style id="sun-delivery-details-style">
|
||
.sun-delivery-details{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px 12px;margin-bottom:10px}.sun-delivery-details label{margin:0}.sun-delivery-details .wide{grid-column:1/-1}.sun-address-hidden{display:none!important}@media(max-width:680px){.sun-delivery-details{grid-template-columns:1fr}.sun-delivery-details .wide{grid-column:auto}}
|
||
</style>
|
||
<style id="sun-order-lines-table-style">
|
||
#orderForm .lines{border-top:0!important;margin-top:16px!important;min-height:180px!important}
|
||
.sun-order-lines-table{border:1px solid #e2e6ea;border-radius:12px;overflow:hidden;background:#fff;box-shadow:0 1px 3px #102c3d0a}
|
||
.sun-order-line-head,.sun-order-line-row{display:grid;grid-template-columns:minmax(0,1fr) 150px 112px 126px;gap:10px;align-items:center}
|
||
.sun-order-line-head{padding:10px 12px;background:#f6f8fa;border-bottom:1px solid #e2e6ea;color:#77818a;font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.045em}
|
||
.sun-order-line-head span:nth-child(n+2){text-align:right}
|
||
.sun-order-line-row{padding:10px 12px;border-bottom:1px solid #edf0f2;min-height:54px}
|
||
.sun-order-line-row:last-child{border-bottom:0}
|
||
.sun-order-line-name{font-weight:700;color:#243746;line-height:1.28;min-width:0}
|
||
.sun-order-line-qty{display:grid;grid-template-columns:30px 54px 30px;justify-content:end;gap:4px;align-items:center}
|
||
.sun-order-line-qty button{width:30px;height:30px;padding:0;border:1px solid #d7dde2;border-radius:7px;background:#f7f9fa;color:#15364c;font-size:17px;font-weight:800}
|
||
.sun-order-line-qty input{width:54px;height:30px;padding:3px 4px;text-align:center;border-radius:7px;font-weight:700}
|
||
.sun-order-line-price,.sun-order-line-sum{text-align:right;white-space:nowrap;color:#52616d}
|
||
.sun-order-line-sum{color:#15364c;font-size:14px}
|
||
.sun-order-line-total{display:grid;grid-template-columns:minmax(0,1fr) 126px;gap:10px;align-items:center;padding:12px;background:linear-gradient(90deg,#fafbfc,#f5f7f8);border-top:1px solid #dfe4e7}
|
||
.sun-order-line-total span{text-align:right;color:#69747e;font-size:13px;font-weight:800}
|
||
.sun-order-line-total b{text-align:right;color:#15364c;font-size:18px;white-space:nowrap}
|
||
#clients .clients-toolbar{margin-top:14px!important}
|
||
@media(max-width:760px){
|
||
.sun-order-line-head{display:none}
|
||
.sun-order-line-row{grid-template-columns:minmax(0,1fr) 112px;gap:7px 10px;padding:10px}
|
||
.sun-order-line-name{grid-column:1/-1}
|
||
.sun-order-line-qty{justify-content:start;grid-template-columns:28px 48px 28px}
|
||
.sun-order-line-qty button{width:28px;height:28px}
|
||
.sun-order-line-qty input{width:48px;height:28px}
|
||
.sun-order-line-price{align-self:center;text-align:right;font-size:12px}
|
||
.sun-order-line-price::before{content:'Цена: ';color:#8a939a;font-weight:400}
|
||
.sun-order-line-sum{grid-column:2;text-align:right}
|
||
.sun-order-line-sum::before{content:'Стоимость: ';color:#8a939a;font-weight:400;font-size:12px}
|
||
.sun-order-line-total{grid-template-columns:1fr auto;padding:11px}
|
||
}
|
||
</style>
|
||
<link rel="manifest" href="manifest.webmanifest"><link rel="apple-touch-icon" href="pwa-icon-192.png"><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"><meta name="apple-mobile-web-app-title" content="Caterium"><style id="sun-mobile-pwa-inline">/* Classic responsive layer: preserve the original app design on phones. */
|
||
@media screen and (max-width:900px){
|
||
html{-webkit-text-size-adjust:100%}
|
||
input,select,textarea{font-size:16px!important}
|
||
button{touch-action:manipulation}
|
||
#sunGlobalSearchBtn kbd{display:none!important}
|
||
#sunSyncSettingsCard>div[style*="grid-template-columns"]{grid-template-columns:1fr!important}
|
||
}</style><style id="sun-composition-v1757">#sunBoxCompositionLabel{display:flex;flex-direction:column;gap:6px;margin-top:10px}#sunBoxCompositionLabel textarea{width:100%;min-height:120px;resize:vertical;font:inherit;line-height:1.4;padding:10px;border:1px solid #dfe3ea;border-radius:10px;box-sizing:border-box}#sunBoxCompositionLabel small{color:#7b8390;font-size:11px}</style></head><body class="sun-cloud-auth-required"><header><div class="brand"><img class="real-logo caterium-product-mark" src="caterium-mark-light.svg" alt="Caterium"><span>Caterium</span></div><nav><button class="on" onclick="show('new',this)">Новый заказ</button><button onclick="show('orders',this)">Заказы</button><button onclick="show('stock',this)">Склад</button><button onclick="show('stats',this)">Статистика</button></nav></header><section id="new" class="view on"><div class="work"><aside class="card catalog"><div class="catalog-head"><h1>Боксы</h1><button class="outline" onclick="openManager()">Настроить</button></div><div class="tiles" id="tiles"></div></aside><aside class="cats"><button>Боксы</button><button>Посуда и упаковка</button><button>Дополнительно</button><p>Для создания и изменения боксов нажмите «Настроить».</p></aside><section class="card"><div class="subnav"><button class="on" id="orderTab" onclick="details(false)">Заказ</button><button id="detailTab" onclick="details(true)">Детали</button></div><div id="orderForm"><div class="form"><label>Мероприятие<input id="event" value=""></label><label>Выдача<input id="date" type="date"></label><label>Время<select id="time"></select></label></div><div class="lines" id="lines"></div></div><div id="details" style="display:none"><div class="sun-delivery-details"><label>Имя<input id="contact" autocomplete="name"></label><label>Телефон<input id="phone" autocomplete="tel"></label><label class="wide">Улица<input id="deliveryStreet"></label><label>Дом<input id="deliveryHouse"></label><label>Подъезд<input id="deliveryEntrance"></label><label>Этаж<input id="deliveryFloor"></label><label>Квартира<input id="deliveryApartment"></label><label>Офис<input id="deliveryOffice"></label></div><label class="sun-address-hidden">Адрес доставки<input id="address" type="hidden"></label><label>Примечание<textarea id="note" rows="4"></textarea></label></div><div class="money-form"><label>Сумма заказа<input id="orderTotal" type="number" min="0" step="1" readonly></label><label>Предоплата<input id="prepayment" type="number" min="0" step="1" value="0" oninput="updateOrderSummary()"></label><label>Остаток<input id="balance" type="number" readonly></label></div><div class="order-summary"><p><span>Стоимость позиций</span><b id="summaryTotal">0 ₽</b></p><p><span>Предоплата</span><b id="summaryPrepayment">0 ₽</b></p><p class="balance"><span>К оплате</span><b id="summaryBalance">0 ₽</b></p></div><div class="actions"><button class="primary" onclick="saveOrder()">Сохранить</button><button class="outline" onclick="openPreview()">Заготовки</button><button id="deleteOrderBtn" class="danger" onclick="deleteOrder()" style="display:none">Удалить заказ</button></div></section></div></section><section id="orders" class="view page"><h1>Заказы</h1><div class="head" style="grid-template-columns:65px 1.15fr 145px 1fr 110px 175px 150px"><span>№</span><span>Мероприятие</span><span>Дата и время</span><span>Адрес</span><span>Сумма</span><span>Статус</span><span>Действия</span></div><div id="ordersList"></div><p class="hint">Нажмите «Изменить», чтобы открыть заказ. Сумма, предоплата и остаток сохраняются вместе с заказом.</p></section><section id="stock" class="view page"><h1>Склад</h1><p>В этой версии каталог продуктов пополняется из составов боксов. Остатки и цены добавим следующим шагом.</p></section><section id="stats" class="view page"><h1>Статистика</h1><div id="statsList"></div></section><div class="modal" id="manager"><div class="dialog"><div class="dialog-head"><h2>Боксы</h2><button onclick="closeModal('manager')">×</button></div><button class="primary" onclick="editBox(null)">Добавить бокс</button><div class="manager" id="managerList"></div></div></div><div class="modal" id="editor"><div class="dialog"><div class="dialog-head"><h2 id="editTitle">Бокс</h2><button onclick="closeModal('editor')">×</button></div><label>Название<input id="boxName"></label><label class="price-field"><span id="boxPriceCaption">Цена, ₽</span><input id="boxPrice" type="number" min="0" step="1" value="0"></label><label class="price-field"><span>Цена до скидки, ₽ <small style="font-weight:400;color:#8a91a0">(необязательно)</small></span><input id="boxOldPrice" type="number" min="0" step="1" value="0" placeholder="Перечёркнутая цена"></label><label>Фотография<input id="boxPhoto" type="file" accept="image/*" onchange="readPhoto(this)"></label><img id="editPhoto" class="editphoto" style="display:none"><h3>Состав / ТТК</h3><div id="ingredients"></div><button class="outline" onclick="addIngredient()">Добавить продукт</button><div class="actions"><button class="primary" id="saveBoxButton" onclick="saveBox()">Сохранить</button><button id="deleteBox" class="danger" onclick="removeBox()">Удалить</button></div></div></div><div class="modal" id="preview"><div class="dialog"><div class="dialog-head"><h2>Документ компании</h2><button onclick="closeModal('preview')">×</button></div><h3 id="prepTitle"></h3><div class="prep" id="prep"></div><div class="actions"><button class="primary" onclick="print()">Печать</button></div></div></div><link rel="stylesheet" href="core/stability-v1760.css?v=20260917-banquet-menu"><script src="core/sun-safe.js?v=20260917-banquet-menu"></script><script src="core/performance.js?v=20260917-banquet-menu"></script><script src="core/pdf-engine.js?v=20260917-banquet-menu"></script><script src="core/classic-offer-pdf-v1767.js?v=20260917-banquet-menu"></script><script src="core/signature-offer-pdf-v18.js?v=20260917-banquet-menu"></script><script src="legacy/bootstrap.js?v=20260917-banquet-menu"></script><script>
|
||
/* BEGIN category-fix.js — PORTABLE INLINE */
|
||
// Compatibility for local/offline browsers where crypto.randomUUID is unavailable.
|
||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID !== 'function') {
|
||
crypto.randomUUID = () => 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||
const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 3 | 8);
|
||
return v.toString(16);
|
||
});
|
||
}
|
||
|
||
(() => {
|
||
const categories = [
|
||
{ name: "Боксы", singular: "бокс", accent: "#d49a2a", prep: true },
|
||
{ name: "Посуда", singular: "позицию", accent: "#a78bfa", prep: false },
|
||
{ name: "Дополнения", singular: "позицию", accent: "#56a889", prep: false },
|
||
{ name: "Напитки", singular: "напиток", accent: "#4f9bd8", prep: false },
|
||
{ name: "Доставка", singular: "вариант доставки", accent: "#cf9f83", prep: false },
|
||
{ name: "Премиум", singular: "премиум позицию", accent: "#7a5aa6", prep: true }
|
||
];
|
||
// Visual order: Premium sits between Boxes and Drinks. Existing category ids stay unchanged.
|
||
const categoryDisplayOrder = [0, 5, 3, 2, 1, 4];
|
||
|
||
// --- Branding ---
|
||
const extraStyle = document.createElement("style");
|
||
extraStyle.textContent = `
|
||
.brand .real-logo,#preview .real-logo{width:42px;height:42px;object-fit:contain;vertical-align:middle;margin-right:9px}
|
||
#preview .real-logo{width:38px;height:38px}
|
||
.catalog .tile{position:relative;display:flex;flex-direction:column;gap:7px;text-align:left}
|
||
.catalog .tile span{line-height:1.2}
|
||
.catalog .tile .tile-price{margin-top:auto;color:#15364c;font-size:13px}.catalog .tile .tile-meta{display:block;color:#68717a;font-size:11px;line-height:1.25}.catalog .tile .tile-section{display:block;color:#8a6a25;font-size:10px;text-transform:uppercase;letter-spacing:.03em}.manager-card-meta{grid-column:2;color:#68717a;font-size:11px}
|
||
.catalog .tile.add{align-items:center;justify-content:center;text-align:center;min-height:152px}
|
||
.catalog-empty{grid-column:1/-1;border:1px dashed #cfd5da;border-radius:7px;padding:26px;text-align:center;color:#68717a;background:#fafbfb}
|
||
.category-badge{display:inline-flex;align-items:center;gap:6px;padding:5px 9px;border-radius:999px;background:#f2f4f5;color:#68717a;font-size:12px;margin:6px 0 12px}
|
||
.manager-card-price{font-weight:bold;color:#15364c}
|
||
.tile-price-wrap,.manager-price-wrap{display:flex;align-items:baseline;gap:7px;flex-wrap:wrap}.tile-price-wrap{margin-top:auto}.tile-price-wrap .tile-price{margin-top:0}.old-price{text-decoration:line-through;color:#9aa0aa!important;font-weight:500!important;font-size:.88em}.sale-badge{position:absolute;top:7px;right:7px;border-radius:999px;background:#b9473f;color:#fff;padding:4px 7px;font-size:9px;font-weight:800;letter-spacing:.04em;z-index:2}
|
||
.editor-category{margin:10px 0}
|
||
.ingredient-zone{margin-top:12px;padding-top:2px}
|
||
.catalog-help{margin-top:10px;color:#68717a;font-size:13px}
|
||
.clients-toolbar{display:flex;gap:10px;align-items:center;justify-content:space-between;margin:14px 0}
|
||
.clients-toolbar input{width:min(420px,100%)}
|
||
.client-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px}
|
||
.client-card{border:1px solid #d9dde0;border-radius:9px;padding:14px;background:#fff;box-shadow:0 1px 2px #0000000d}
|
||
.client-card h3{margin:0 0 5px;color:#15364c;font-size:17px}
|
||
.client-card p{margin:4px 0;color:#68717a}
|
||
.client-card .client-metrics{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin:12px 0}
|
||
.client-card .metric{background:#f7f8f8;border-radius:7px;padding:9px}
|
||
.client-card .metric b{display:block;color:#15364c;font-size:16px}
|
||
.client-card .metric small{color:#7c858b}
|
||
.client-card .actions{margin-top:10px}
|
||
.client-summary{display:grid;grid-template-columns:repeat(4,minmax(120px,1fr));gap:10px;margin:14px 0 18px}
|
||
.client-summary .summary-box{background:#f7f8f8;border-radius:8px;padding:12px}
|
||
.client-summary b{display:block;font-size:19px;color:#15364c;margin-top:4px}
|
||
.client-detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}
|
||
.client-detail-grid h3{margin:4px 0 10px;color:#15364c}
|
||
.client-address{padding:8px 0;border-bottom:1px solid #e3e6e8}
|
||
.client-order{display:grid;grid-template-columns:70px 1fr 130px 120px 110px;gap:8px;align-items:center;padding:9px 0;border-bottom:1px solid #e3e6e8}
|
||
.client-order small{color:#68717a}
|
||
.client-empty{color:#879198;padding:12px 0}
|
||
.client-loyalty-mini{margin-top:10px;padding:8px 10px;border-radius:9px;background:#f5f4fb;color:#756f87;font-size:11px;display:flex;align-items:center;justify-content:space-between;gap:8px}.client-loyalty-mini.on{background:linear-gradient(135deg,#f2effd,#fff9e9);color:#4f466f}.client-loyalty-mini b{font-size:13px;color:#5b49ad}.client-loyalty-panel{margin:4px 0 18px;border:1px solid #e1def0;border-radius:14px;background:linear-gradient(145deg,#fcfbff,#fffdf8);padding:15px}.client-loyalty-top{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;flex-wrap:wrap}.client-loyalty-kicker{text-transform:uppercase;letter-spacing:.11em;font-size:10px;color:#8b83a8;font-weight:800}.client-loyalty-current{font-size:30px;line-height:1;color:#4e3f92;margin:7px 0 4px}.client-loyalty-sub{color:#73798a;font-size:12px;margin:0}.client-loyalty-switch{display:flex;flex-direction:row;align-items:center;gap:7px;padding:7px 10px;border-radius:999px;background:#f1eff8;color:#4d4760;font-weight:700}.client-loyalty-switch input{width:17px;height:17px}.client-loyalty-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:14px}.client-loyalty-grid.three{grid-template-columns:repeat(3,1fr)}.client-loyalty-cardline{display:grid;grid-template-columns:1fr auto;gap:6px}.client-loyalty-cardline button{white-space:nowrap}.client-loyalty-progress{margin-top:12px;padding:10px 12px;border-radius:10px;background:#f5f6f8;color:#626a78;font-size:12px}.client-loyalty-rule{margin-top:8px;color:#81798e;font-size:11px}.client-loyalty-panel .actions{justify-content:flex-end}.client-loyalty-panel h3{margin:0}.client-loyalty-off{color:#979dab;font-weight:700}.client-loyalty-fields[hidden]{display:none!important}
|
||
@media(max-width:900px){.cats{overflow:auto}.cats button{flex:0 0 auto}.client-summary{grid-template-columns:1fr 1fr}.client-detail-grid{grid-template-columns:1fr}.client-order{grid-template-columns:60px 1fr 100px}.client-order .client-order-date,.client-order .client-order-status{display:none}.client-loyalty-grid,.client-loyalty-grid.three{grid-template-columns:1fr}}
|
||
`;
|
||
document.head.appendChild(extraStyle);
|
||
const brand = document.querySelector(".brand");
|
||
if (brand) window.CateriumBranding.renderSidebar();
|
||
const receiptLogo = document.querySelector("#preview h2");
|
||
if (receiptLogo) receiptLogo.innerHTML = ("<img class=\"real-logo\" src=\""+window.CateriumBranding.logoHTML()+"\" alt=\"Логотип компании\">"+window.CateriumBranding.nameHTML());
|
||
|
||
// --- Catalog migration: all positions live in the existing boxes array for compatibility ---
|
||
boxes = boxes.map(item => ({
|
||
...item,
|
||
category: Number.isInteger(item.category) ? item.category : 0,
|
||
price: Number(item.price || 0),
|
||
ingredients: Array.isArray(item.ingredients) ? item.ingredients : []
|
||
}));
|
||
|
||
|
||
// Catalogs belong to a workspace; newly created companies start empty.
|
||
window.SUN_OFFICIAL_CATALOG_VERSION = 'workspace-owned-v1';
|
||
window.SUN_OFFICIAL_CATALOG = [];
|
||
|
||
// Pull current human-readable box compositions from the public Solnce Catering catalog.
|
||
// The server endpoint is fixed to solnce-keytering.ru; custom URLs are never accepted.
|
||
function sunCatalogNormalizeName(value){
|
||
return String(value||'').toLowerCase().replace(/ё/g,'е').replace(/[«»“”"'’]/g,' ').replace(/(?:на\s+)?\d+\s*[-–—]\s*\d+\s*(?:гост(?:ей|я)|персон)/g,' ').replace(/\b\d+\s*(?:гост(?:ей|я)|персон)\b/g,' ').replace(/\bфуршетн(?:ый|ая|ое|ые)\b/g,' ').replace(/\b(?:бокс|набор)\b/g,' ').replace(/[^a-zа-я0-9№]+/gi,' ').replace(/\s+/g,' ').trim();
|
||
}
|
||
function sunCatalogBoxNo(value){const m=String(value||'').match(/№\s*0*(\d+)/i);return m?Number(m[1]):null;}
|
||
function sunCatalogTokens(value){return new Set(sunCatalogNormalizeName(value).split(' ').filter(x=>x.length>1 && !/^№?\d+$/.test(x)));}
|
||
function sunCatalogNameScore(a,b){
|
||
const na=sunCatalogNormalizeName(a),nb=sunCatalogNormalizeName(b); if(!na||!nb)return 0; if(na===nb)return 100;
|
||
const A=sunCatalogTokens(a),B=sunCatalogTokens(b),inter=[...A].filter(x=>B.has(x)).length,uni=new Set([...A,...B]).size;
|
||
let score=uni?inter/uni*70:0; const an=sunCatalogBoxNo(a),bn=sunCatalogBoxNo(b); if(an!==null&&bn!==null)score+=an===bn?25:-35;
|
||
if(na.includes(nb)||nb.includes(na))score+=12; return score;
|
||
}
|
||
function sunCatalogPieceCount(lines){let total=0;for(const line of lines||[]){const re=/(\d+(?:[.,]\d+)?)\s*(?:шт\.?|пор\.?|кус\.?)/ig;let m;while((m=re.exec(String(line||''))))total+=Number(String(m[1]).replace(',','.'))||0;}return Math.round(total);}
|
||
function sunFindCatalogItemForSiteProduct(product){
|
||
let best=null,bestScore=0; for(const item of boxes){
|
||
if(![0,5].includes(Number(item.category||0)))continue; if(!String(item.id||'').startsWith('catalog-20260902-'))continue;
|
||
const score=sunCatalogNameScore(item.name,product.title||product.name); if(score>bestScore){best=item;bestScore=score;}
|
||
}
|
||
return bestScore>=58?best:null;
|
||
}
|
||
async function sunSyncCatalogCompositionsFromSite(force=false){
|
||
if(window.SunCloudV2?.getWorkspace?.()?.id!=='30f65746-0c47-4cfe-9939-7f7c1501ae55')return {ok:true,skipped:true};
|
||
const now=Date.now(),last=Number(localStorage.sunCatalogCompositionSyncAt||0);
|
||
const premiumMissing=boxes.some(item=>Number(item.category||0)===5&&String(item.id||'').startsWith('catalog-20260902-')&&item.compositionSource!=='manual'&&!(Array.isArray(item.composition)&&item.composition.filter(Boolean).length));
|
||
if(!force&&!premiumMissing&&last&&now-last<6*60*60*1000)return {ok:true,skipped:true};
|
||
try{
|
||
const response=await fetch('/api/catalog-compositions?ts='+now+(force?'&force=1':''),{cache:'no-store'}); if(!response.ok)throw new Error('HTTP '+response.status);
|
||
const data=await response.json(); const products=Array.isArray(data.products)?data.products:[]; let changed=0,matched=0,matchedPremium=0,changedPremium=0;
|
||
for(const product of products){
|
||
const comp=Array.isArray(product.composition)?product.composition.map(x=>String(x||'').trim()).filter(Boolean):[]; if(!comp.length)continue;
|
||
const item=sunFindCatalogItemForSiteProduct(product); if(!item)continue; matched++; if(Number(item.category||0)===5)matchedPremium++;
|
||
if(item.compositionSource==='manual')continue;
|
||
let itemChanged=false;
|
||
if(JSON.stringify(item.composition||[])!==JSON.stringify(comp)){item.composition=comp;changed++;itemChanged=true;if(Number(item.category||0)===5)changedPremium++;}
|
||
const pc=sunCatalogPieceCount(comp); if(pc&&Number(item.pieces||0)!==pc){item.pieces=pc;itemChanged=true;}
|
||
item.compositionSource='solnce-keytering.ru'; item.compositionSyncedAt=new Date().toISOString();
|
||
if(itemChanged)window.sunClientOfferCatalogChanged?.(item.id);
|
||
}
|
||
localStorage.sunCatalogCompositionSyncAt=String(now); localStorage.sunCatalogCompositionSyncStatus=JSON.stringify({at:now,matched,changed,matchedPremium,changedPremium,received:products.length,source:data.source||'solnce-keytering.ru'});
|
||
if(changed)persist();
|
||
if(force) alert(changed?('Составы обновлены с сайта: '+changed+'. Премиум: '+changedPremium+'.'):('Сайт проверен. Новых изменений состава нет. Совпало позиций: '+matched+', Премиум: '+matchedPremium+'.'));
|
||
return {ok:true,changed,matched,matchedPremium,changedPremium,received:products.length};
|
||
}catch(error){
|
||
localStorage.sunCatalogCompositionSyncStatus=JSON.stringify({at:now,error:String(error?.message||error)});
|
||
if(force)alert('Не удалось получить составы с сайта. Проверьте интернет и запустите приложение через start-local-test.bat / server.js.\n\n'+String(error?.message||error));
|
||
return {ok:false,error};
|
||
}
|
||
}
|
||
window.sunSyncCatalogCompositionsFromSite=sunSyncCatalogCompositionsFromSite;
|
||
// Keep catalog composition updates, but defer network-heavy work until the first screen is idle.
|
||
// This avoids a noticeable startup pause on slower computers/connections.
|
||
const sunScheduleCatalogCompositionSync=()=>{
|
||
if(!navigator.onLine||document.hidden)return;
|
||
const run=()=>{if(navigator.onLine&&!document.hidden)sunSyncCatalogCompositionsFromSite(false).catch(()=>{});};
|
||
if('requestIdleCallback' in window)requestIdleCallback(run,{timeout:15000});else setTimeout(run,2500);
|
||
};
|
||
setTimeout(sunScheduleCatalogCompositionSync,8000);
|
||
|
||
let activeCategory = 0;
|
||
const tileArea = document.getElementById("tiles");
|
||
const categoryPanel = document.querySelector(".cats");
|
||
const catalogTitle = document.querySelector(".catalog h1");
|
||
const managerTitle = document.querySelector("#manager .dialog-head h2");
|
||
const managerAddButton = document.querySelector("#manager .primary");
|
||
// Manual composition refresh control removed from catalog manager.
|
||
const catalogCompositionSyncButton = document.createElement("span");
|
||
catalogCompositionSyncButton.hidden = true;
|
||
const editorDialog = document.querySelector("#editor .dialog");
|
||
const editorTitle = document.getElementById("editTitle");
|
||
const ingredientsHeading = [...editorDialog.querySelectorAll("h3")].find(x => ["Состав","Состав / ТТК"].includes(x.textContent.trim()));
|
||
if(ingredientsHeading) ingredientsHeading.textContent = "Состав / ТТК";
|
||
const ingredientsList = document.getElementById("ingredients");
|
||
const addIngredientButton = [...editorDialog.querySelectorAll("button")].find(x => x.textContent.includes("Добавить продукт"));
|
||
|
||
// Add category selector into the editor so a position can be moved between sections.
|
||
const categoryLabel = document.createElement("label");
|
||
categoryLabel.className = "editor-category";
|
||
categoryLabel.innerHTML = `Категория<select id="itemCategory">${categoryDisplayOrder.map(i => `<option value="${i}">${categories[i].name}</option>`).join("")}</select>`;
|
||
const nameLabel = document.getElementById("boxName").closest("label");
|
||
nameLabel.insertAdjacentElement("afterend", categoryLabel);
|
||
|
||
// Human-readable composition shown in client offer/PDF. Kept separate from prep ingredients.
|
||
const compositionLabel = document.createElement("label");
|
||
compositionLabel.id = "sunBoxCompositionLabel";
|
||
compositionLabel.className = "sun-box-composition-field";
|
||
compositionLabel.innerHTML = '<span>Состав для клиента</span><textarea id="boxCompositionText" rows="6" placeholder="Каждая позиция с новой строки"></textarea><small>Этот текст показывается в предложении клиенту и PDF. Заготовочные ингредиенты редактируются ниже отдельно.</small>';
|
||
const priceLabel = document.getElementById("boxOldPrice")?.closest("label") || document.getElementById("boxPrice")?.closest("label");
|
||
priceLabel?.insertAdjacentElement("afterend", compositionLabel);
|
||
|
||
// Wrap ingredient controls so they can be hidden for non-food positions.
|
||
const ingredientZone = document.createElement("div");
|
||
ingredientZone.className = "ingredient-zone";
|
||
window.SunSafe.insertBefore(ingredientsHeading.parentNode,ingredientZone,ingredientsHeading);
|
||
ingredientZone.appendChild(ingredientsHeading);
|
||
ingredientZone.appendChild(ingredientsList);
|
||
ingredientZone.appendChild(addIngredientButton);
|
||
const ingredientHelp = document.createElement("p");
|
||
ingredientHelp.className = "hint";
|
||
ingredientHelp.textContent = "Состав / ТТК используется для кухни и автоматического расчёта заготовок по заказу.";
|
||
ingredientZone.appendChild(ingredientHelp);
|
||
|
||
categoryPanel.innerHTML = categoryDisplayOrder.map(i => `<button type="button" data-category="${i}">${categories[i].name}</button>`).join("") + '<p>Выберите раздел. Любую позицию можно добавить, изменить или удалить.</p>';
|
||
const categoryButtons = [...categoryPanel.querySelectorAll("button")];
|
||
|
||
function moneyLocal(value) {
|
||
return typeof money === "function" ? money(value) : `${Math.round(Number(value || 0)).toLocaleString("ru-RU")} ₽`;
|
||
}
|
||
|
||
function activeItems() {
|
||
return boxes.filter(item => Number(item.category || 0) === activeCategory);
|
||
}
|
||
|
||
function syncCategoryUI() {
|
||
const cat = categories[activeCategory];
|
||
if (catalogTitle) catalogTitle.textContent = cat.name;
|
||
categoryButtons.forEach(button => {
|
||
const categoryIndex = Number(button.dataset.category);
|
||
const isActive = categoryIndex === activeCategory;
|
||
button.style.background = categories[categoryIndex].accent;
|
||
button.style.color = "#fff";
|
||
button.style.opacity = isActive ? "1" : ".72";
|
||
button.style.transform = isActive ? "translateY(-1px)" : "none";
|
||
button.style.boxShadow = isActive ? "0 4px 10px #0002" : "none";
|
||
button.style.outline = isActive ? "2px solid #15364c" : "none";
|
||
button.style.outlineOffset = isActive ? "2px" : "0";
|
||
});
|
||
}
|
||
|
||
function compactOrderRowName(name) {
|
||
let text = String(name || "").trim().replace(/\s+/g, " ");
|
||
text = text.replace(/^(?:Фуршетный\s+)?бокс\s*№\s*(\d+)\s*(?:[—–-]\s*)?/i, "№ $1 ");
|
||
text = text.replace(/^№\s*(\d+)\s*(?:[—–-]\s*)?/i, "№ $1 ");
|
||
return text.trim();
|
||
}
|
||
|
||
function renderCatalog() {
|
||
syncCategoryUI();
|
||
const items = activeItems();
|
||
const cat = categories[activeCategory];
|
||
const addText = [0,5].includes(activeCategory) ? (activeCategory===0?"Добавить бокс":"Добавить премиум") : "Добавить позицию";
|
||
const cards = items.map(item => `
|
||
<button class="tile" type="button" onclick="add('${item.id}')" title="Добавить в заказ">
|
||
${item.photo ? `<img src="${item.photo}" alt="" loading="lazy" decoding="async">` : ("<div class=\"ph\"><img src=\""+window.CateriumBranding.logoHTML()+"\" class=\"sun-ph-logo\" alt=\"Логотип "+window.CateriumBranding.nameHTML()+"\"></div>")}
|
||
<span>${sunEsc(item.name)}</span>
|
||
${item.catalogSection ? `<small class="tile-section">${sunEsc(item.catalogSection)}</small>` : ""}
|
||
${item.weight ? `<small class="tile-meta">${sunEsc(item.weight)}</small>` : ""}
|
||
<span class="tile-price-wrap"><small class="tile-price">${moneyLocal(item.price || 0)}</small>${Number(item.oldPrice||0)>Number(item.price||0)?`<small class="old-price">${moneyLocal(item.oldPrice)}</small>`:""}</span>${Number(item.oldPrice||0)>Number(item.price||0)?`<span class="sale-badge">АКЦИЯ</span>`:""}
|
||
</button>`).join("");
|
||
const empty = items.length ? "" : `<div class="catalog-empty">В разделе «${cat.name}» пока нет позиций.<br>Нажмите «${addText}».</div>`;
|
||
tileArea.innerHTML = `<button class="tile add" type="button" onclick="editBox(null)">+<br>${addText}</button>${cards}${empty}`;
|
||
|
||
if (draft.lines.length) {
|
||
const orderRows = draft.lines.map(line => {
|
||
const item = boxes.find(x => x.id == line.id);
|
||
if (!item) return "";
|
||
const qtyValue = Math.max(1, Number(line.qty || 1));
|
||
const priceValue = Number(item.price || 0);
|
||
const sum = priceValue * qtyValue;
|
||
return `<div class="sun-order-line-row">
|
||
<div class="sun-order-line-name" title="${sunAttr(item.name)}">${sunEsc(compactOrderRowName(item.name))}</div>
|
||
<div class="sun-order-line-qty"><button type="button" onclick="qty('${line.id}',${qtyValue - 1})">−</button><input type="number" min="1" value="${qtyValue}" onchange="qty('${line.id}',+this.value)"><button type="button" onclick="qty('${line.id}',${qtyValue + 1})">+</button></div>
|
||
<div class="sun-order-line-price">${moneyLocal(priceValue)}</div>
|
||
<div class="sun-order-line-sum"><b>${moneyLocal(sum)}</b></div>
|
||
</div>`;
|
||
}).join("");
|
||
lines.innerHTML = `<div class="sun-order-lines-table">
|
||
<div class="sun-order-line-head"><span>Наименование</span><span>Кол.</span><span>Цена</span><span>Стоимость</span></div>
|
||
<div class="sun-order-line-body">${orderRows}</div>
|
||
<div class="sun-order-line-total"><span>Всего</span><b>${moneyLocal(calcOrderTotal())}</b></div>
|
||
</div>`;
|
||
} else {
|
||
lines.innerHTML = '<p class="empty">Выберите позиции из каталога слева.</p>';
|
||
}
|
||
updateOrderSummary();
|
||
}
|
||
|
||
window.render = renderCatalog;
|
||
|
||
categoryButtons.forEach(button => {
|
||
button.onclick = () => {
|
||
activeCategory = Number(button.dataset.category);
|
||
renderCatalog();
|
||
};
|
||
});
|
||
|
||
window.openManager = () => {
|
||
const cat = categories[activeCategory];
|
||
managerTitle.textContent = cat.name;
|
||
managerAddButton.textContent = activeCategory === 0 ? "Добавить бокс" : (activeCategory===5 ? "Добавить премиум" : "Добавить позицию");
|
||
catalogCompositionSyncButton.style.display = "none";
|
||
const items = activeItems();
|
||
managerList.innerHTML = items.length ? items.map(item => `
|
||
<button type="button" onclick="editBox('${item.id}')">
|
||
${item.photo ? `<img src="${item.photo}" alt="" loading="lazy" decoding="async">` : ("<div class=\"ph\"><img src=\""+window.CateriumBranding.logoHTML()+"\" class=\"sun-ph-logo\" alt=\"Логотип "+window.CateriumBranding.nameHTML()+"\"></div>")}
|
||
<b>${sunEsc(item.name)}</b>
|
||
<span class="manager-price-wrap"><small class="manager-card-price">${moneyLocal(item.price || 0)}</small>${Number(item.oldPrice||0)>Number(item.price||0)?`<small class="old-price">${moneyLocal(item.oldPrice)}</small>`:""}</span>
|
||
${item.weight ? `<small class="manager-card-meta">${item.weight}</small>` : ""}
|
||
${categories[activeCategory].prep ? `<small>${item.ingredients.length} позиций в составе</small>` : ""}
|
||
</button>`).join("") : `<p class="empty">В разделе «${cat.name}» пока нет позиций.</p>`;
|
||
modal("manager");
|
||
};
|
||
|
||
window.editBox = (id) => {
|
||
edited = id ? structuredClone(boxes.find(x => x.id == id)) : {
|
||
id: "",
|
||
name: activeCategory === 0 ? "Новый бокс" : (activeCategory===5 ? "Новая премиум позиция" : "Новая позиция"),
|
||
category: activeCategory,
|
||
price: 0,
|
||
oldPrice: 0,
|
||
ingredients: []
|
||
};
|
||
if (!edited) return;
|
||
if (!Number.isInteger(edited.category)) edited.category = activeCategory;
|
||
if (!Array.isArray(edited.ingredients)) edited.ingredients = [];
|
||
const cat = categories[edited.category];
|
||
editorTitle.textContent = edited.id ? `Изменить: ${cat.name}` : (edited.category === 0 ? "Новый бокс" : (edited.category===5 ? "Новая премиум позиция" : "Новая позиция"));
|
||
boxName.value = edited.name || "";
|
||
boxPrice.value = Number(edited.price || 0);
|
||
boxOldPrice.value = Number(edited.oldPrice || 0);
|
||
itemCategory.value = String(edited.category);
|
||
const compositionText = document.getElementById("boxCompositionText");
|
||
if (compositionText) compositionText.value = (Array.isArray(edited.composition) ? edited.composition : []).join("\n");
|
||
compositionLabel.style.display = [0,5].includes(Number(edited.category)) ? "flex" : "none";
|
||
editPhoto.style.display = edited.photo ? "block" : "none";
|
||
editPhoto.src = edited.photo || "";
|
||
deleteBox.style.display = edited.id ? "block" : "none";
|
||
ingredientZone.style.display = cat.prep ? "block" : "none";
|
||
renderIngredients();
|
||
modal("editor");
|
||
};
|
||
|
||
itemCategory.onchange = () => {
|
||
edited.category = Number(itemCategory.value);
|
||
const cat = categories[edited.category];
|
||
ingredientZone.style.display = cat.prep ? "block" : "none";
|
||
compositionLabel.style.display = [0,5].includes(Number(edited.category)) ? "flex" : "none";
|
||
editorTitle.textContent = edited.id ? `Изменить: ${cat.name}` : (edited.category === 0 ? "Новый бокс" : (edited.category===5 ? "Новая премиум позиция" : "Новая позиция"));
|
||
};
|
||
|
||
window.saveBox = () => {
|
||
edited.name = boxName.value.trim();
|
||
edited.price = Math.max(0, Number(boxPrice.value || 0));
|
||
edited.oldPrice = Math.max(0, Number(boxOldPrice.value || 0));
|
||
if (!(edited.oldPrice > edited.price)) delete edited.oldPrice;
|
||
edited.sale = Boolean(edited.oldPrice && edited.oldPrice > edited.price);
|
||
edited.category = Number(itemCategory.value);
|
||
edited.ingredients = Array.isArray(edited.ingredients) ? edited.ingredients : [];
|
||
const compositionText = document.getElementById("boxCompositionText");
|
||
if ([0,5].includes(Number(edited.category)) && compositionText) {
|
||
const nextComposition = compositionText.value.split(/\r?\n/).map(x=>x.trim()).filter(Boolean);
|
||
const beforeComposition = JSON.stringify(Array.isArray(edited.composition)?edited.composition:[]);
|
||
edited.composition = nextComposition;
|
||
if (JSON.stringify(nextComposition) !== beforeComposition) edited.compositionSource = "manual";
|
||
}
|
||
if (!edited.name) return alert("Введите название.");
|
||
if (!edited.id) {
|
||
edited.id = sunUUID();
|
||
boxes.push(edited);
|
||
} else {
|
||
const index = boxes.findIndex(x => x.id == edited.id);
|
||
if (index >= 0) boxes[index] = edited;
|
||
}
|
||
activeCategory = edited.category;
|
||
persist();
|
||
closeModal("editor");
|
||
closeModal("manager");
|
||
renderCatalog();
|
||
renderOrders();
|
||
};
|
||
|
||
window.removeBox = () => {
|
||
if (!edited?.id) return;
|
||
if (!confirm(`Удалить «${edited.name}» из каталога?`)) return;
|
||
boxes = boxes.filter(x => x.id != edited.id);
|
||
// Keep old orders readable: lines referencing a deleted item are removed only from the current draft.
|
||
draft.lines = draft.lines.filter(x => x.id != edited.id);
|
||
persist();
|
||
closeModal("editor");
|
||
closeModal("manager");
|
||
renderCatalog();
|
||
renderOrders();
|
||
};
|
||
|
||
// Make preparation view safe for non-food catalog positions.
|
||
window.openPreview = () => {
|
||
const prepMap = {};
|
||
draft.lines.forEach(line => {
|
||
const item = boxes.find(x => x.id == line.id);
|
||
if (!item || !Array.isArray(item.ingredients)) return;
|
||
item.ingredients.forEach(part => {
|
||
const key = `${part[0]}|${part[2]}`;
|
||
prepMap[key] = (prepMap[key] || 0) + Number(part[1] || 0) * Number(line.qty || 0);
|
||
});
|
||
});
|
||
prepTitle.textContent = "Заготовки для: " + document.getElementById('event').value;
|
||
const entries = Object.entries(prepMap);
|
||
prep.innerHTML = entries.length ? entries.map(([key, value]) => {
|
||
const [product, unit] = key.split("|");
|
||
return `<p><b>${product}</b><span>${value} ${unit}</span></p>`;
|
||
}).join("") : '<p class="empty">В заказе нет позиций с составом для заготовок.</p>';
|
||
modal("preview");
|
||
};
|
||
|
||
// --- Clients ---
|
||
const nav = document.querySelector("nav");
|
||
const clientButton = document.createElement("button");
|
||
clientButton.textContent = "Клиенты";
|
||
nav.appendChild(clientButton);
|
||
|
||
const clientView = document.createElement("section");
|
||
clientView.id = "clients";
|
||
clientView.className = "view page";
|
||
clientView.innerHTML = `
|
||
<div class="catalog-head">
|
||
<div><h1>Клиенты</h1><p class="hint" style="margin:5px 0 0">База формируется автоматически из сохранённых заказов.</p></div>
|
||
</div>
|
||
<div class="clients-toolbar"><input id="client-search" type="search" placeholder="Поиск по имени, телефону или адресу"><span class="hint" id="client-count"></span></div>
|
||
<div class="client-grid" id="client-list"></div>`;
|
||
document.body.appendChild(clientView);
|
||
|
||
const clientModal = document.createElement("div");
|
||
clientModal.id = "client-card";
|
||
clientModal.className = "modal";
|
||
clientModal.innerHTML = `<div class="dialog"><div class="dialog-head"><h2 id="client-card-title">Клиент</h2><button type="button" onclick="closeModal('client-card')">×</button></div><div id="client-card-content"></div></div>`;
|
||
document.body.appendChild(clientModal);
|
||
|
||
function normalizePhone(value) {
|
||
return String(value || "").replace(/\D/g, "");
|
||
}
|
||
|
||
function clientKey(order) {
|
||
const phoneKey = normalizePhone(order.phone);
|
||
if (phoneKey) return `p:${phoneKey}`;
|
||
const nameKey = String(order.contact || "").trim().replace(/\s+/g, " ").toLowerCase();
|
||
return nameKey ? `n:${nameKey}` : "";
|
||
}
|
||
|
||
// --- Loyalty cards / personal cumulative rules ---
|
||
const CLIENT_LOYALTY_KEY = "sunClientLoyaltyV1";
|
||
const loyaltyDefaults = () => ({enabled:false,cardNumber:"",mode:"fixed",fixedDiscount:0,baseDiscount:0,stepAmount:50000,stepPercent:1,maxDiscount:10});
|
||
function readLoyaltyStore(){ try { const v=JSON.parse(localStorage.getItem(CLIENT_LOYALTY_KEY)||"{}"); return v&&typeof v==="object"?v:{}; } catch(_) { return {}; } }
|
||
function writeLoyaltyStore(value){ localStorage.setItem(CLIENT_LOYALTY_KEY,JSON.stringify(value||{})); }
|
||
function clientLoyalty(key){ return {...loyaltyDefaults(),...(readLoyaltyStore()[key]||{})}; }
|
||
function clampDiscount(value){ return Math.max(0,Math.min(100,Number(value||0))); }
|
||
function loyaltyDiscountFor(person, loyalty=clientLoyalty(person.key)){
|
||
if(!loyalty.enabled) return 0;
|
||
if(loyalty.mode!=="cumulative") return clampDiscount(loyalty.fixedDiscount);
|
||
const turnover=Math.max(0,Number(person.total||0));
|
||
const base=clampDiscount(loyalty.baseDiscount);
|
||
const step=Math.max(1,Number(loyalty.stepAmount||0));
|
||
const plus=Math.max(0,Number(loyalty.stepPercent||0));
|
||
const max=Math.max(base,clampDiscount(loyalty.maxDiscount));
|
||
return Math.min(max,base+Math.floor(turnover/step)*plus);
|
||
}
|
||
function loyaltyProgressText(person, loyalty){
|
||
if(!loyalty.enabled) return "Карта выключена. Скидка не рассчитывается.";
|
||
const discount=loyaltyDiscountFor(person,loyalty);
|
||
if(loyalty.mode!=="cumulative") return `Постоянная скидка ${discount.toLocaleString("ru-RU",{maximumFractionDigits:2})}%.`;
|
||
const turnover=Math.max(0,Number(person.total||0)),step=Math.max(1,Number(loyalty.stepAmount||0)),plus=Math.max(0,Number(loyalty.stepPercent||0)),max=Math.max(clampDiscount(loyalty.baseDiscount),clampDiscount(loyalty.maxDiscount));
|
||
if(discount>=max || plus<=0) return `Накоплено ${moneyLocal(turnover)} · достигнута максимальная скидка ${discount.toLocaleString("ru-RU",{maximumFractionDigits:2})}%.`;
|
||
const next=(Math.floor(turnover/step)+1)*step;
|
||
return `Накоплено ${moneyLocal(turnover)} · до следующего уровня осталось ${moneyLocal(Math.max(0,next-turnover))}.`;
|
||
}
|
||
|
||
function orderValue(order) {
|
||
if (typeof orderTotalValue === "function") return Number(orderTotalValue(order) || 0);
|
||
if (Number.isFinite(Number(order.total))) return Number(order.total);
|
||
return (order.lines || []).reduce((sum, line) => {
|
||
const item = boxes.find(x => x.id == line.id);
|
||
return sum + Number(item?.price || 0) * Number(line.qty || 0);
|
||
}, 0);
|
||
}
|
||
|
||
function collectClients() {
|
||
const repo=window.CateriumDataV1773?.clients||window.CateriumDataV1772?.clients||window.CateriumData?.clients;
|
||
const profiles=repo?.list?.({source:'auto'});
|
||
if(Array.isArray(profiles)&&profiles.length){
|
||
const result=[];
|
||
profiles.forEach(profile=>{
|
||
const key=String(profile?.key||'');if(!key)return;
|
||
const linked=orders.filter(order=>clientKey(order)===key);
|
||
if(!linked.length&&key.startsWith('o:'))return;
|
||
const valid=linked.filter(order=>order.status!=='Отменён');
|
||
const addresses=new Set([...(Array.isArray(profile.addresses)?profile.addresses:[]),profile.latestAddress,...linked.map(order=>order.address)].map(v=>String(v||'').trim()).filter(Boolean));
|
||
const lastDate=linked.reduce((max,order)=>{const stamp=`${order.date||''}T${order.time||'00:00'}`;return stamp>max?stamp:max},'');
|
||
const latest=[...linked].sort((a,b)=>`${b.date||''}T${b.time||''}`.localeCompare(`${a.date||''}T${a.time||''}`))[0]||{};
|
||
result.push({key,name:String(profile.name||latest.contact||'Без имени').trim()||'Без имени',phone:String(profile.phone||latest.phone||'').trim()||'—',addresses:[...addresses],orders:linked,total:valid.reduce((sum,order)=>sum+orderValue(order),0),paid:valid.reduce((sum,order)=>sum+Math.max(0,Number(order.prepayment||0)),0),activeOrders:valid.length,lastDate});
|
||
});
|
||
return result.sort((a,b)=>(b.lastDate||'').localeCompare(a.lastDate||''));
|
||
}
|
||
const people = {};
|
||
orders.forEach(order => {
|
||
const key = clientKey(order);
|
||
if (!key) return;
|
||
if (!people[key]) people[key] = {key,name:String(order.contact || "Без имени").trim() || "Без имени",phone:String(order.phone || "").trim() || "—",addresses:new Set(),orders:[],total:0,paid:0,activeOrders:0,lastDate:""};
|
||
const person = people[key];
|
||
if (order.contact && String(order.contact).trim()) person.name = String(order.contact).trim();
|
||
if (order.phone && String(order.phone).trim()) person.phone = String(order.phone).trim();
|
||
if (order.address && String(order.address).trim()) person.addresses.add(String(order.address).trim());
|
||
person.orders.push(order);
|
||
if (order.status !== "Отменён") { person.total += orderValue(order); person.paid += Math.max(0, Number(order.prepayment || 0)); person.activeOrders++; }
|
||
const dateKey = `${order.date || ""}T${order.time || "00:00"}`; if (dateKey > person.lastDate) person.lastDate = dateKey;
|
||
});
|
||
return Object.values(people).map(person => ({ ...person, addresses: [...person.addresses] })).sort((a, b) => (b.lastDate || "").localeCompare(a.lastDate || ""));
|
||
}
|
||
|
||
function renderClientSummary(list) {
|
||
// Верхняя сводка удалена: список клиентов начинается сразу с поиска и карточек.
|
||
}
|
||
|
||
function renderClients(filter = "") {
|
||
const all = collectClients();
|
||
renderClientSummary(all);
|
||
const query = String(filter || "").trim().toLowerCase();
|
||
const list = query ? all.filter(person => {
|
||
const haystack = [person.name, person.phone, ...person.addresses].join(" ").toLowerCase();
|
||
return haystack.includes(query);
|
||
}) : all;
|
||
document.getElementById("client-count").textContent = query ? `Найдено: ${list.length}` : `Клиентов: ${list.length}`;
|
||
document.getElementById("client-list").innerHTML = list.length ? list.map(person => {
|
||
const loyalty=clientLoyalty(person.key),discount=loyaltyDiscountFor(person,loyalty);
|
||
return `
|
||
<article class="client-card">
|
||
<h3>${person.name}</h3>
|
||
<p>${person.phone}</p>
|
||
<p>${person.addresses[0] || "Адрес пока не указан"}${person.addresses.length > 1 ? ` · ещё ${person.addresses.length - 1}` : ""}</p>
|
||
<div class="client-metrics">
|
||
<div class="metric"><b>${person.activeOrders}</b><small>заказов</small></div>
|
||
<div class="metric"><b>${moneyLocal(person.total)}</b><small>на сумму</small></div>
|
||
</div>
|
||
<div class="client-loyalty-mini ${loyalty.enabled?'on':''}"><span>${loyalty.enabled?`Карта ${loyalty.cardNumber||'без номера'}`:'Лояльность не настроена'}</span>${loyalty.enabled?`<b>${discount.toLocaleString('ru-RU',{maximumFractionDigits:2})}%</b>`:''}</div>
|
||
<div class="actions"><button class="outline" type="button" onclick="openClientCard('${encodeURIComponent(person.key)}')">Открыть карточку</button></div>
|
||
</article>`;
|
||
}).join("") : '<p class="empty">Клиенты не найдены.</p>';
|
||
}
|
||
|
||
window.addEventListener('caterium:clients-server-refresh',()=>{if(clientView.classList.contains('on'))renderClients(document.getElementById("client-search")?.value||'')});
|
||
|
||
window.openClientCard = encodedKey => {
|
||
const key = decodeURIComponent(encodedKey);
|
||
const person = collectClients().find(x => x.key === key);
|
||
if (!person) return;
|
||
const loyalty=clientLoyalty(key),discount=loyaltyDiscountFor(person,loyalty);
|
||
document.getElementById("client-card-title").textContent = person.name;
|
||
const ordersHtml = [...person.orders].sort((a, b) => `${b.date || ""}T${b.time || ""}`.localeCompare(`${a.date || ""}T${a.time || ""}`)).map(order => `
|
||
<div class="client-order">
|
||
<b>№ ${order.id}</b>
|
||
<span>${order.event || "Заказ"}<small style="display:block">${order.address || "Без адреса"}</small></span>
|
||
<span class="client-order-date">${order.date || "—"} ${order.time || ""}</span>
|
||
<span class="client-order-status">${order.status || "Новый"}</span>
|
||
<button class="outline" type="button" onclick="openClientOrder(${order.id})">Открыть</button>
|
||
</div>`).join("");
|
||
document.getElementById("client-card-content").innerHTML = `
|
||
<div class="client-summary">
|
||
<div class="summary-box"><small>Телефон</small><b style="font-size:16px">${person.phone}</b></div>
|
||
<div class="summary-box"><small>Заказов</small><b>${person.activeOrders}</b></div>
|
||
<div class="summary-box"><small>Общий оборот</small><b>${moneyLocal(person.total)}</b></div>
|
||
<div class="summary-box"><small>Текущая скидка</small><b>${loyalty.enabled?discount.toLocaleString('ru-RU',{maximumFractionDigits:2})+'%':'—'}</b></div>
|
||
</div>
|
||
<section class="client-loyalty-panel">
|
||
<div class="client-loyalty-top"><div><div class="client-loyalty-kicker">Карта лояльности</div><div class="client-loyalty-current">${loyalty.enabled?discount.toLocaleString('ru-RU',{maximumFractionDigits:2})+'%':'—'}</div><p class="client-loyalty-sub">${loyalty.enabled?(loyalty.mode==='cumulative'?'Накопительная система':'Постоянная скидка'):'Правило пока не включено'}</p></div><label class="client-loyalty-switch"><input id="clientLoyaltyEnabled" type="checkbox" ${loyalty.enabled?'checked':''}> Активна</label></div>
|
||
<div class="client-loyalty-grid"><label>Номер карты<div class="client-loyalty-cardline"><input id="clientLoyaltyCard" value="${loyalty.cardNumber||''}" placeholder="Например SUN-00001234"><button class="outline" type="button" onclick="generateClientLoyaltyCard()">Создать</button></div></label><label>Тип программы<select id="clientLoyaltyMode" onchange="toggleClientLoyaltyFields()"><option value="fixed" ${loyalty.mode==='fixed'?'selected':''}>Постоянная скидка</option><option value="cumulative" ${loyalty.mode==='cumulative'?'selected':''}>Накопительная</option></select></label></div>
|
||
<div id="clientLoyaltyFixedFields" class="client-loyalty-fields client-loyalty-grid"><label>Скидка, %<input id="clientLoyaltyFixed" type="number" min="0" max="100" step="0.5" value="${Number(loyalty.fixedDiscount||0)}"></label></div>
|
||
<div id="clientLoyaltyCumulativeFields" class="client-loyalty-fields client-loyalty-grid three"><label>Стартовая скидка, %<input id="clientLoyaltyBase" type="number" min="0" max="100" step="0.5" value="${Number(loyalty.baseDiscount||0)}"></label><label>Каждые, ₽<input id="clientLoyaltyStepAmount" type="number" min="1" step="1000" value="${Number(loyalty.stepAmount||50000)}"></label><label>Добавлять, %<input id="clientLoyaltyStepPercent" type="number" min="0" max="100" step="0.5" value="${Number(loyalty.stepPercent||1)}"></label><label>Максимальная скидка, %<input id="clientLoyaltyMax" type="number" min="0" max="100" step="0.5" value="${Number(loyalty.maxDiscount||10)}"></label></div>
|
||
<div class="client-loyalty-progress">${loyaltyProgressText(person,loyalty)}<div class="client-loyalty-rule">Для накопительной системы учитывается весь оборот клиента по заказам без статуса «Отменён». Правило задаётся отдельно для этого клиента.</div></div>
|
||
<div class="actions"><button class="primary" type="button" onclick="saveClientLoyalty('${encodeURIComponent(key)}')">Сохранить правило</button></div>
|
||
</section>
|
||
<div class="client-detail-grid">
|
||
<div><h3>Адреса</h3>${person.addresses.length ? person.addresses.map(address => `<div class="client-address">${address}</div>`).join("") : '<p class="client-empty">Адресов пока нет.</p>'}</div>
|
||
<div><h3>История заказов</h3><div>${ordersHtml || '<p class="client-empty">Заказов пока нет.</p>'}</div></div>
|
||
</div>`;
|
||
modal("client-card");
|
||
toggleClientLoyaltyFields();
|
||
};
|
||
|
||
window.toggleClientLoyaltyFields = () => {
|
||
const mode=document.getElementById('clientLoyaltyMode')?.value||'fixed';
|
||
const fixed=document.getElementById('clientLoyaltyFixedFields'),cum=document.getElementById('clientLoyaltyCumulativeFields');
|
||
if(fixed) fixed.hidden=mode!=='fixed'; if(cum) cum.hidden=mode!=='cumulative';
|
||
};
|
||
window.generateClientLoyaltyCard = () => {
|
||
const input=document.getElementById('clientLoyaltyCard'); if(!input)return;
|
||
const rnd=String(Date.now()).slice(-6)+String(Math.floor(Math.random()*100)).padStart(2,'0'); input.value=`SUN-${rnd}`;
|
||
};
|
||
window.saveClientLoyalty = encodedKey => {
|
||
const key=decodeURIComponent(encodedKey),store=readLoyaltyStore();
|
||
const val=id=>document.getElementById(id)?.value||'',num=id=>Number(val(id)||0);
|
||
const mode=val('clientLoyaltyMode')==='cumulative'?'cumulative':'fixed';
|
||
store[key]={enabled:Boolean(document.getElementById('clientLoyaltyEnabled')?.checked),cardNumber:val('clientLoyaltyCard').trim(),mode,fixedDiscount:clampDiscount(num('clientLoyaltyFixed')),baseDiscount:clampDiscount(num('clientLoyaltyBase')),stepAmount:Math.max(1,num('clientLoyaltyStepAmount')||50000),stepPercent:Math.max(0,num('clientLoyaltyStepPercent')),maxDiscount:clampDiscount(num('clientLoyaltyMax'))};
|
||
if(store[key].maxDiscount<store[key].baseDiscount) store[key].maxDiscount=store[key].baseDiscount;
|
||
writeLoyaltyStore(store);
|
||
renderClients(document.getElementById('client-search')?.value||'');
|
||
window.openClientCard(encodedKey);
|
||
try{window.SunEnterprise?.toast('Правило лояльности сохранено.','success');}catch(_){}
|
||
};
|
||
|
||
window.openClientOrder = id => {
|
||
closeModal("client-card");
|
||
if (typeof openOrder === "function") openOrder(id);
|
||
};
|
||
|
||
document.getElementById("client-search").addEventListener("input", event => renderClients(event.target.value));
|
||
|
||
clientButton.onclick = () => {
|
||
document.querySelectorAll(".view").forEach(x => x.classList.remove("on"));
|
||
clientView.classList.add("on");
|
||
[...nav.querySelectorAll("button")].forEach(x => x.classList.remove("on"));
|
||
clientButton.classList.add("on");
|
||
document.getElementById("client-search").value = "";
|
||
renderClients();
|
||
window.CateriumDataV1773?.clients?.refreshServer?.({reason:'clients-open'}).catch(()=>{});
|
||
};
|
||
|
||
// --- Money / finance module ---
|
||
const FINANCE_KEY = 'sunFinanceRecordsV2';
|
||
let financeRecords = (() => {
|
||
try { return JSON.parse(localStorage.getItem(FINANCE_KEY) || '[]'); }
|
||
catch (_) { return []; }
|
||
})();
|
||
let moneyTab = 'income';
|
||
let editingFinanceId = null;
|
||
|
||
const moneyButton = document.createElement("button");
|
||
moneyButton.textContent = "Деньги";
|
||
nav.appendChild(moneyButton);
|
||
|
||
const moneyView = document.createElement("section");
|
||
moneyView.id = "money";
|
||
moneyView.className = "view page";
|
||
moneyView.innerHTML = `
|
||
<div class="catalog-head money-title-row">
|
||
<div><h1>Деньги</h1><p class="hint" style="margin:5px 0 0">Доходы по заказам, закупки со склада, расходы, зарплаты и прочие операции.</p></div>
|
||
<button class="primary" id="moneyAddRecord">Добавить операцию</button>
|
||
</div>
|
||
<div class="money-period">
|
||
<label>Период<select id="moneyPeriod"><option value="month">Этот месяц</option><option value="all">Всё время</option><option value="custom">Свой период</option></select></label>
|
||
<label>С<input id="moneyFrom" type="date"></label>
|
||
<label>По<input id="moneyTo" type="date"></label>
|
||
</div>
|
||
<div class="money-summary" id="moneySummary"></div>
|
||
<div class="subnav" id="money-tabs">
|
||
<button data-money="income" class="on">Доходы</button>
|
||
<button data-money="expenses">Расходы</button>
|
||
<button data-money="salary">Зарплаты</button>
|
||
<button data-money="other">Прочее</button>
|
||
</div>
|
||
<div id="money-content"></div>`;
|
||
document.body.appendChild(moneyView);
|
||
|
||
const financeStyle = document.createElement('style');
|
||
financeStyle.textContent = `
|
||
.money-title-row{gap:12px;align-items:flex-start}.money-period{display:grid;grid-template-columns:180px 160px 160px;gap:10px;margin:16px 0}
|
||
.money-summary{display:grid;grid-template-columns:repeat(6,minmax(120px,1fr));gap:9px;margin:0 0 16px}.money-card{border:1px solid var(--l);border-radius:8px;padding:11px;background:#fafafa;min-height:72px}.money-card small{display:block;color:#68717a;margin-bottom:6px}.money-card b{font-size:18px;color:var(--n)}.money-card.profit b{font-size:21px}.money-card.negative b{color:#a33}
|
||
.finance-toolbar{display:flex;justify-content:space-between;align-items:center;gap:10px;margin:8px 0 12px;flex-wrap:wrap}.finance-table{width:100%;border-collapse:collapse;min-width:760px}.finance-table th,.finance-table td{padding:9px 8px;border-bottom:1px solid var(--l);text-align:left;vertical-align:top}.finance-table th{font-size:12px;color:#68717a}.finance-table .num{text-align:right;white-space:nowrap}.finance-source{display:block;color:#879198;font-size:11px;margin-top:2px}.finance-pos{color:#287a49}.finance-neg{color:#a33}.finance-actions{display:flex;gap:5px;justify-content:flex-end}.finance-form{display:grid;grid-template-columns:1fr 1fr;gap:10px}.finance-form .wide{grid-column:1/-1}.money-note{padding:10px 12px;background:#fffaf0;border-left:3px solid var(--g);margin:12px 0;color:#68717a;font-size:13px}
|
||
@media(max-width:1100px){.money-summary{grid-template-columns:repeat(3,1fr)}}
|
||
@media(max-width:700px){.money-title-row{display:block}.money-title-row>button{margin-top:10px;width:100%}.money-period{grid-template-columns:1fr 1fr}.money-period label:first-child{grid-column:1/-1}.money-summary{grid-template-columns:1fr 1fr}.finance-form{grid-template-columns:1fr}.finance-form .wide{grid-column:auto}}
|
||
`;
|
||
document.head.appendChild(financeStyle);
|
||
|
||
const financeModal = document.createElement('div');
|
||
financeModal.id = 'finance-modal';
|
||
financeModal.className = 'modal';
|
||
financeModal.innerHTML = `<div class="dialog"><div class="dialog-head"><h2 id="financeModalTitle">Финансовая операция</h2><button type="button" onclick="closeModal('finance-modal')">×</button></div>
|
||
<div class="finance-form">
|
||
<label>Раздел<select id="financeKind"><option value="expense">Расход</option><option value="salary">Зарплата</option><option value="other_income">Прочий доход</option><option value="other_expense">Прочий расход</option></select></label>
|
||
<label>Дата<input id="financeDate" type="date"></label>
|
||
<label>Сумма<input id="financeAmount" type="number" min="0" step="0.01"></label>
|
||
<label>Категория<input id="financeCategory" placeholder="Продукты, реклама, аренда…"></label>
|
||
<label class="wide">Описание<input id="financeDescription" placeholder="Что это за операция"></label>
|
||
<label class="wide">Комментарий<textarea id="financeNote" rows="3"></textarea></label>
|
||
</div>
|
||
<div class="actions"><button class="primary" id="financeSave">Сохранить</button><button class="danger" id="financeDelete" style="display:none">Удалить</button></div></div>`;
|
||
document.body.appendChild(financeModal);
|
||
const financeModalTitle = document.getElementById('financeModalTitle');
|
||
const financeKind = document.getElementById('financeKind');
|
||
const financeDate = document.getElementById('financeDate');
|
||
const financeAmount = document.getElementById('financeAmount');
|
||
const financeCategory = document.getElementById('financeCategory');
|
||
const financeDescription = document.getElementById('financeDescription');
|
||
const financeNote = document.getElementById('financeNote');
|
||
const financeSave = document.getElementById('financeSave');
|
||
const financeDelete = document.getElementById('financeDelete');
|
||
|
||
const moneyEsc = value => String(value ?? '').replace(/[&<>"']/g, ch => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch]));
|
||
const moneyDateLabel = raw => {
|
||
if (!raw) return '—';
|
||
const d = new Date(raw + 'T00:00:00');
|
||
return Number.isNaN(d.getTime()) ? raw : d.toLocaleDateString('ru-RU');
|
||
};
|
||
const financeMoney = value => moneyLocal(Number(value || 0));
|
||
const saveFinance = () => localStorage.setItem(FINANCE_KEY, JSON.stringify(financeRecords));
|
||
const orderFinanceTotal = order => typeof orderTotalValue === 'function' ? Number(orderTotalValue(order) || 0) : Number(order.total || 0);
|
||
const orderPaid = order => Math.min(orderFinanceTotal(order), Math.max(0, Number(order.prepayment || 0)));
|
||
|
||
function stockPurchaseRows() {
|
||
let moves = [];
|
||
try { moves = JSON.parse(localStorage.getItem('sunStockMoves') || '[]'); } catch (_) {}
|
||
return moves.filter(m => m.type === 'in' && Number(m.qty || 0) > 0 && Number(m.cost || 0) > 0).map(m => ({
|
||
id: `stock:${m.id}`, source: 'stock', date: m.date || (m.createdAt || '').slice(0,10), amount: Number(m.qty || 0) * Number(m.cost || 0),
|
||
category: 'Закупка на склад', description: m.productName || 'Продукт', note: m.note || '', qty: Number(m.qty || 0), unit: m.unit || '', unitCost: Number(m.cost || 0)
|
||
}));
|
||
}
|
||
|
||
function currentMoneyRange() {
|
||
const mode = document.getElementById('moneyPeriod')?.value || 'month';
|
||
if (mode === 'all') return { from:'', to:'' };
|
||
if (mode === 'custom') return { from: document.getElementById('moneyFrom')?.value || '', to: document.getElementById('moneyTo')?.value || '' };
|
||
const now = new Date();
|
||
const y = now.getFullYear(), m = String(now.getMonth()+1).padStart(2,'0');
|
||
const last = new Date(y, now.getMonth()+1, 0).getDate();
|
||
return { from:`${y}-${m}-01`, to:`${y}-${m}-${String(last).padStart(2,'0')}` };
|
||
}
|
||
const inRange = (dateValue, range = currentMoneyRange()) => (!range.from || dateValue >= range.from) && (!range.to || dateValue <= range.to);
|
||
|
||
function financeSnapshot() {
|
||
const range = currentMoneyRange();
|
||
const validOrders = orders.filter(o => (o.status || 'Новый') !== 'Отменён' && inRange(o.date || '', range));
|
||
const sales = validOrders.reduce((s,o)=>s+orderFinanceTotal(o),0);
|
||
const received = validOrders.reduce((s,o)=>s+orderPaid(o),0);
|
||
const stockPurchases = stockPurchaseRows().filter(r=>inRange(r.date || '', range));
|
||
const stockCost = stockPurchases.reduce((s,r)=>s+r.amount,0);
|
||
const manual = financeRecords.filter(r=>inRange(r.date || '', range));
|
||
const expenses = manual.filter(r=>r.kind==='expense');
|
||
const salaries = manual.filter(r=>r.kind==='salary');
|
||
const otherIncome = manual.filter(r=>r.kind==='other_income');
|
||
const otherExpense = manual.filter(r=>r.kind==='other_expense');
|
||
const expenseTotal = stockCost + expenses.reduce((s,r)=>s+Number(r.amount||0),0);
|
||
const salaryTotal = salaries.reduce((s,r)=>s+Number(r.amount||0),0);
|
||
const otherIncomeTotal = otherIncome.reduce((s,r)=>s+Number(r.amount||0),0);
|
||
const otherExpenseTotal = otherExpense.reduce((s,r)=>s+Number(r.amount||0),0);
|
||
const accruedProfit = sales + otherIncomeTotal - expenseTotal - salaryTotal - otherExpenseTotal;
|
||
const cashBalance = received + otherIncomeTotal - expenseTotal - salaryTotal - otherExpenseTotal;
|
||
return { range, validOrders, sales, received, stockPurchases, stockCost, manual, expenses, salaries, otherIncome, otherExpense, expenseTotal, salaryTotal, otherIncomeTotal, otherExpenseTotal, accruedProfit, cashBalance };
|
||
}
|
||
|
||
function renderMoneySummary(data) {
|
||
const pending = Math.max(0, data.sales - data.received);
|
||
document.getElementById('moneySummary').innerHTML = `
|
||
<div class="money-card"><small>Продажи по заказам</small><b>${financeMoney(data.sales)}</b></div>
|
||
<div class="money-card"><small>Получено предоплат</small><b>${financeMoney(data.received)}</b></div>
|
||
<div class="money-card"><small>К получению</small><b>${financeMoney(pending)}</b></div>
|
||
<div class="money-card"><small>Расходы + закупки</small><b>${financeMoney(data.expenseTotal)}</b></div>
|
||
<div class="money-card"><small>Зарплаты</small><b>${financeMoney(data.salaryTotal)}</b></div>
|
||
<div class="money-card profit ${data.accruedProfit < 0 ? 'negative' : ''}"><small>Расчётная прибыль</small><b>${financeMoney(data.accruedProfit)}</b></div>`;
|
||
}
|
||
|
||
function financeTable(rows, columns, emptyText) {
|
||
if (!rows.length) return `<p class="empty">${emptyText}</p>`;
|
||
return `<div style="overflow:auto"><table class="finance-table"><thead><tr>${columns.map(c=>`<th class="${c.num?'num':''}">${c.label}</th>`).join('')}<th></th></tr></thead><tbody>${rows.map(row=>`<tr>${columns.map(c=>`<td class="${c.num?'num':''}">${c.render(row)}</td>`).join('')}<td>${row.source==='manual'?`<div class="finance-actions"><button class="mini" onclick="editFinanceRecord('${row.id}')">Изменить</button><button class="mini danger" onclick="deleteFinanceRecord('${row.id}')">Удалить</button></div>`:''}</td></tr>`).join('')}</tbody></table></div>`;
|
||
}
|
||
|
||
function renderIncome(data) {
|
||
const rows = [...data.validOrders].sort((a,b)=>(b.date||'').localeCompare(a.date||''));
|
||
const columns = [
|
||
{label:'Дата',render:o=>moneyDateLabel(o.date)},
|
||
{label:'Заказ',render:o=>`<b>№${moneyEsc(o.id)} · ${moneyEsc(o.event||'Без названия')}</b><span class="finance-source">${moneyEsc(o.contact||'Клиент не указан')} · ${moneyEsc(o.status||'Новый')}</span>`},
|
||
{label:'Сумма',num:true,render:o=>`<b>${financeMoney(orderFinanceTotal(o))}</b>`},
|
||
{label:'Получено',num:true,render:o=>`<span class="finance-pos">${financeMoney(orderPaid(o))}</span>`},
|
||
{label:'Остаток',num:true,render:o=>financeMoney(Math.max(0,orderFinanceTotal(o)-orderPaid(o)))}
|
||
];
|
||
document.getElementById('money-content').innerHTML = `<div class="money-note">Доходы по заказам считаются по полной стоимости всех неотменённых заказов. «Получено» сейчас равно сохранённой предоплате; остаток показан отдельно, чтобы не считать его уже полученными деньгами.</div>${financeTable(rows,columns,'Заказов за выбранный период нет.')}`;
|
||
}
|
||
|
||
function renderExpenses(data) {
|
||
const manualRows = data.expenses.map(r=>({...r,source:'manual'}));
|
||
const rows = [...data.stockPurchases, ...manualRows].sort((a,b)=>(b.date||'').localeCompare(a.date||''));
|
||
const columns = [
|
||
{label:'Дата',render:r=>moneyDateLabel(r.date)},
|
||
{label:'Категория',render:r=>`<b>${moneyEsc(r.category||'Расход')}</b><span class="finance-source">${r.source==='stock'?'Автоматически со склада':'Ручная запись'}</span>`},
|
||
{label:'Описание',render:r=>`${moneyEsc(r.description||'—')}${r.note?`<span class="finance-source">${moneyEsc(r.note)}</span>`:''}`},
|
||
{label:'Сумма',num:true,render:r=>`<b class="finance-neg">− ${financeMoney(r.amount)}</b>`}
|
||
];
|
||
document.getElementById('money-content').innerHTML = `<div class="finance-toolbar"><span class="hint">Приход товара на склад с указанной закупочной ценой автоматически попадает сюда как закупка.</span><button class="outline" onclick="newFinanceRecord('expense')">Добавить расход</button></div>${financeTable(rows,columns,'Расходов за выбранный период нет.')}`;
|
||
}
|
||
|
||
function renderSalary(data) {
|
||
const rows = data.salaries.map(r=>({...r,source:'manual'})).sort((a,b)=>(b.date||'').localeCompare(a.date||''));
|
||
const columns = [
|
||
{label:'Дата',render:r=>moneyDateLabel(r.date)},
|
||
{label:'Сотрудник / категория',render:r=>`<b>${moneyEsc(r.category||'Зарплата')}</b>`},
|
||
{label:'Описание',render:r=>`${moneyEsc(r.description||'—')}${r.note?`<span class="finance-source">${moneyEsc(r.note)}</span>`:''}`},
|
||
{label:'Сумма',num:true,render:r=>`<b class="finance-neg">− ${financeMoney(r.amount)}</b>`}
|
||
];
|
||
document.getElementById('money-content').innerHTML = `<div class="finance-toolbar"><span class="hint">Здесь учитываются выплаты сотрудникам и подрядчикам.</span><button class="outline" onclick="newFinanceRecord('salary')">Добавить зарплату</button></div>${financeTable(rows,columns,'Выплат зарплат за выбранный период нет.')}`;
|
||
}
|
||
|
||
function renderOther(data) {
|
||
const rows = [...data.otherIncome.map(r=>({...r,source:'manual'})), ...data.otherExpense.map(r=>({...r,source:'manual'}))].sort((a,b)=>(b.date||'').localeCompare(a.date||''));
|
||
const columns = [
|
||
{label:'Дата',render:r=>moneyDateLabel(r.date)},
|
||
{label:'Тип',render:r=>r.kind==='other_income'?'<b class="finance-pos">Прочий доход</b>':'<b class="finance-neg">Прочий расход</b>'},
|
||
{label:'Категория',render:r=>moneyEsc(r.category||'Прочее')},
|
||
{label:'Описание',render:r=>`${moneyEsc(r.description||'—')}${r.note?`<span class="finance-source">${moneyEsc(r.note)}</span>`:''}`},
|
||
{label:'Сумма',num:true,render:r=>`<b class="${r.kind==='other_income'?'finance-pos':'finance-neg'}">${r.kind==='other_income'?'+':'−'} ${financeMoney(r.amount)}</b>`}
|
||
];
|
||
document.getElementById('money-content').innerHTML = `<div class="finance-toolbar"><span class="hint">Например: возвраты, компенсации, аренда оборудования, разовые поступления.</span><button class="outline" onclick="newFinanceRecord('other_income')">Добавить операцию</button></div>${financeTable(rows,columns,'Прочих операций за выбранный период нет.')}`;
|
||
}
|
||
|
||
function renderMoney() {
|
||
const period = document.getElementById('moneyPeriod');
|
||
const custom = period?.value === 'custom';
|
||
document.getElementById('moneyFrom').disabled = !custom;
|
||
document.getElementById('moneyTo').disabled = !custom;
|
||
const data = financeSnapshot();
|
||
renderMoneySummary(data);
|
||
[...document.querySelectorAll('#money-tabs button')].forEach(b=>b.classList.toggle('on',b.dataset.money===moneyTab));
|
||
if (moneyTab === 'income') renderIncome(data);
|
||
else if (moneyTab === 'expenses') renderExpenses(data);
|
||
else if (moneyTab === 'salary') renderSalary(data);
|
||
else renderOther(data);
|
||
}
|
||
|
||
window.newFinanceRecord = (kind = 'expense') => {
|
||
editingFinanceId = null;
|
||
financeModalTitle.textContent = 'Новая финансовая операция';
|
||
financeKind.value = kind;
|
||
financeDate.value = new Date().toISOString().slice(0,10);
|
||
financeAmount.value = '';
|
||
financeCategory.value = kind === 'salary' ? 'Сотрудник' : '';
|
||
financeDescription.value = '';
|
||
financeNote.value = '';
|
||
financeDelete.style.display = 'none';
|
||
modal('finance-modal');
|
||
};
|
||
window.editFinanceRecord = id => {
|
||
const r = financeRecords.find(x=>x.id===id); if (!r) return;
|
||
editingFinanceId = id;
|
||
financeModalTitle.textContent = 'Изменить финансовую операцию';
|
||
financeKind.value = r.kind; financeDate.value = r.date || ''; financeAmount.value = Number(r.amount||0); financeCategory.value = r.category || ''; financeDescription.value = r.description || ''; financeNote.value = r.note || '';
|
||
financeDelete.style.display = 'inline-block';
|
||
modal('finance-modal');
|
||
};
|
||
window.deleteFinanceRecord = id => {
|
||
const r = financeRecords.find(x=>x.id===id); if (!r || !confirm('Удалить финансовую операцию?')) return;
|
||
financeRecords = financeRecords.filter(x=>x.id!==id); saveFinance(); renderMoney();
|
||
};
|
||
|
||
financeSave.onclick = () => {
|
||
const amount = Math.max(0,Number(financeAmount.value||0));
|
||
if (!financeDate.value) return alert('Укажите дату.');
|
||
if (amount <= 0) return alert('Введите сумму больше нуля.');
|
||
const item = { id: editingFinanceId || sunUUID(), kind: financeKind.value, date: financeDate.value, amount, category: financeCategory.value.trim(), description: financeDescription.value.trim(), note: financeNote.value.trim(), createdAt: new Date().toISOString() };
|
||
const idx = financeRecords.findIndex(x=>x.id===item.id);
|
||
if (idx >= 0) financeRecords[idx] = {...financeRecords[idx],...item}; else financeRecords.push(item);
|
||
saveFinance(); closeModal('finance-modal'); renderMoney();
|
||
};
|
||
financeDelete.onclick = () => editingFinanceId && window.deleteFinanceRecord(editingFinanceId);
|
||
|
||
document.getElementById('moneyAddRecord').onclick = () => newFinanceRecord(moneyTab==='salary'?'salary':moneyTab==='income'?'other_income':moneyTab==='salary'?'salary':moneyTab==='other'?'other_income':'expense');
|
||
document.getElementById('moneyPeriod').onchange = renderMoney;
|
||
document.getElementById('moneyFrom').onchange = renderMoney;
|
||
document.getElementById('moneyTo').onchange = renderMoney;
|
||
[...moneyView.querySelectorAll('#money-tabs button')].forEach(button => button.onclick = () => { moneyTab = button.dataset.money; renderMoney(); });
|
||
|
||
moneyButton.onclick = () => {
|
||
document.querySelectorAll(".view").forEach(x => x.classList.remove("on"));
|
||
moneyView.classList.add("on");
|
||
[...nav.querySelectorAll("button")].forEach(x => x.classList.remove("on"));
|
||
moneyButton.classList.add("on");
|
||
renderMoney();
|
||
};
|
||
|
||
// --- Order colors ---
|
||
const rowColors = { "Новый": "#f3f8fb", "В работе": "#f1effa", "Собран": "#fff6dc", "Передан курьеру": "#fff9db", "Отдан заказчику": "#eaf6ed", "Отменён": "#fbeeee" };
|
||
const savedRenderOrders = window.renderOrders;
|
||
window.renderOrders = () => {
|
||
savedRenderOrders();
|
||
[...document.querySelectorAll("#ordersList .row")].forEach((row, index) => row.style.background = rowColors[orders[index]?.status || "Новый"] || "#fff");
|
||
};
|
||
const savedStatus = window.status;
|
||
window.status = (id, value) => { savedStatus(id, value); window.renderOrders(); renderClients(document.getElementById("client-search")?.value || ""); };
|
||
|
||
// Refresh clients after saving or deleting an order.
|
||
const savedSaveOrder = window.saveOrder;
|
||
window.saveOrder = () => { savedSaveOrder(); renderClients(document.getElementById("client-search")?.value || ""); };
|
||
const savedDeleteOrderById = window.deleteOrderById;
|
||
if (typeof savedDeleteOrderById === "function") window.deleteOrderById = id => { savedDeleteOrderById(id); renderClients(document.getElementById("client-search")?.value || ""); };
|
||
|
||
// --- Branded order documents / receipt ---
|
||
const printStyle = document.createElement("style");
|
||
printStyle.textContent = "@media print { #details { display:none !important; } body{background:#fff!important} }";
|
||
document.head.appendChild(printStyle);
|
||
|
||
const documentStyle = document.createElement("style");
|
||
documentStyle.textContent = `
|
||
.sun-doc{font-family:Arial,sans-serif;color:#172f41;background:#fff;max-width:900px;margin:0 auto}
|
||
.sun-doc *{box-sizing:border-box}
|
||
.sun-doc-head{display:flex;justify-content:space-between;gap:24px;align-items:flex-start;padding:18px 0 16px;border-bottom:3px solid #c99a32}
|
||
.sun-doc-brand{display:flex;align-items:center;gap:12px}
|
||
.sun-doc-brand img{width:112px;height:72px;object-fit:contain}
|
||
.sun-doc-brand strong{display:block;font-size:22px;color:#15364c}.sun-doc-brand small{display:block;margin-top:4px;color:#6d7981}
|
||
.sun-doc-title{text-align:right}.sun-doc-title h2{margin:0;color:#15364c;font-size:26px}.sun-doc-title p{margin:6px 0 0;color:#68717a}
|
||
.sun-doc-badge{display:inline-block;margin-top:8px;padding:6px 10px;border:1px solid #d9dde0;border-radius:999px;font-size:12px;font-weight:700}
|
||
.sun-doc-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px 28px;margin:18px 0;padding:14px 16px;background:#f8f6f0;border-radius:8px}
|
||
.sun-doc-field{padding:5px 0;border-bottom:1px solid #e0ddd4}.sun-doc-field span{display:block;color:#77828a;font-size:11px;text-transform:uppercase;letter-spacing:.04em;margin-bottom:3px}.sun-doc-field b{font-size:14px;color:#203040;font-weight:600}
|
||
.sun-doc table{width:100%;border-collapse:collapse;margin-top:14px}.sun-doc th{background:#15364c;color:#fff;font-size:12px;text-align:left;padding:9px}.sun-doc td{border-bottom:1px solid #d9dde0;padding:10px 9px;vertical-align:top}.sun-doc th:nth-child(n+3),.sun-doc td:nth-child(n+3){text-align:right;white-space:nowrap}
|
||
.sun-doc .doc-num{width:44px;text-align:center!important}.sun-doc .doc-name{text-align:left!important}.sun-doc .doc-sub{display:block;font-size:11px;color:#78838b;margin-top:3px}
|
||
.sun-doc-summary{width:min(360px,100%);margin:18px 0 0 auto}.sun-doc-summary p{display:flex;justify-content:space-between;margin:0;padding:7px 0;border-bottom:1px solid #e0e3e5}.sun-doc-summary .grand{font-size:19px;font-weight:700;color:#15364c;border-top:2px solid #c99a32;border-bottom:0;padding-top:10px}
|
||
.sun-doc-note{margin-top:18px;padding:12px 14px;border-left:4px solid #c99a32;background:#fffaf0}.sun-doc-note b{display:block;margin-bottom:5px}
|
||
.sun-doc-footer{display:grid;grid-template-columns:1fr 1fr;gap:40px;margin-top:34px;padding-top:14px;border-top:1px solid #d9dde0;color:#68717a;font-size:12px}.sun-doc-sign{margin-top:30px;border-top:1px solid #50606b;padding-top:5px;color:#50606b}
|
||
.sun-doc-actions{display:flex;gap:8px;margin-top:20px;justify-content:flex-end}
|
||
.sun-receipt{max-width:720px}.sun-receipt .sun-doc-head{padding-top:4px}.sun-receipt .sun-doc-grid{grid-template-columns:1fr;margin-top:14px}.sun-receipt .sun-doc-footer{grid-template-columns:1fr 1fr}
|
||
@media(max-width:700px){.sun-doc-head{flex-direction:column}.sun-doc-title{text-align:left}.sun-doc-grid{grid-template-columns:1fr}.sun-doc-footer{grid-template-columns:1fr}.sun-doc-brand img{width:88px;height:58px}}
|
||
@media print{
|
||
@page{size:A4 portrait;margin:8mm}
|
||
.sun-doc-actions{display:none!important}
|
||
.sun-doc{max-width:none;font-size:11px}
|
||
.sun-doc-head{padding:0 0 8px;gap:12px}
|
||
.sun-doc-brand img{width:64px;height:42px}
|
||
.sun-doc-brand strong{font-size:15px}.sun-doc-brand small{margin-top:1px;font-size:10px}
|
||
.sun-doc-title h2{font-size:17px}.sun-doc-title p{margin-top:2px;font-size:11px}
|
||
.sun-doc-grid{display:grid!important;grid-template-columns:none!important;grid-auto-flow:column;grid-auto-columns:1fr;gap:4px 10px;margin:8px 0;padding:6px 8px;background:#fff;border:1px solid #e0ddd4}
|
||
.sun-doc-field{padding:0;border-bottom:0}
|
||
.sun-doc-field span{font-size:8px;margin-bottom:1px}
|
||
.sun-doc-field b{font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.sun-doc table{margin-top:6px}
|
||
.sun-doc th{background:#15364c!important;color:#fff!important;-webkit-print-color-adjust:exact;print-color-adjust:exact;font-size:9px;padding:4px 5px}
|
||
.sun-doc td{padding:3px 5px;font-size:10px}
|
||
.sun-doc .doc-sub{font-size:8px;margin-top:1px}
|
||
.sun-doc-summary{width:min(260px,100%);margin-top:8px}
|
||
.sun-doc-summary p{padding:3px 0;font-size:10px}
|
||
.sun-doc-summary .grand{font-size:13px;padding-top:5px}
|
||
.sun-doc-note{margin-top:8px;padding:6px 8px;font-size:10px;background:#fffaf0!important;-webkit-print-color-adjust:exact;print-color-adjust:exact}
|
||
.sun-doc-footer{grid-template-columns:1fr 1fr;gap:20px;margin-top:14px;padding-top:8px;font-size:9px}
|
||
.sun-doc-sign{margin-top:14px;padding-top:3px}
|
||
}
|
||
`;
|
||
document.head.appendChild(documentStyle);
|
||
|
||
const escDoc = value => String(value ?? "").replace(/[&<>\"']/g, ch => ({'&':'&','<':'<','>':'>','\"':'"',"'":'''}[ch]));
|
||
const docMoney = n => Number(n || 0).toLocaleString('ru-RU') + ' ₽';
|
||
const docDate = raw => {
|
||
if (!raw) return '—';
|
||
const d = new Date(raw + 'T00:00:00');
|
||
return Number.isNaN(d.getTime()) ? raw : d.toLocaleDateString('ru-RU');
|
||
};
|
||
const currentRows = () => (draft.lines || []).map((line,index) => {
|
||
const item = boxes.find(x => String(x.id) === String(line.id));
|
||
const direct = Number(line?.price);
|
||
const price = Number.isFinite(direct) && direct >= 0 ? direct : Number(item?.price || 0);
|
||
const qty = Math.max(0, Number(line.qty || 0));
|
||
const name = item?.name || line?.name || `Позиция ${line?.id ?? ''}`;
|
||
const category = item?.category ?? line?.category ?? 'Боксы';
|
||
return {index:index+1,name,category,qty,price,sum:price*qty};
|
||
}).filter(row => row.qty > 0);
|
||
const currentDocData = () => {
|
||
const rows=currentRows();
|
||
const total=rows.reduce((s,r)=>s+r.sum,0);
|
||
const paid=Math.max(0,Number(prepayment.value || draft.prepayment || 0));
|
||
return {rows,total,paid,balance:Math.max(0,total-paid)};
|
||
};
|
||
const docActions = () => `<div class="sun-doc-actions"><button class="primary" onclick="print()">Печать / PDF</button><button class="outline" onclick="closeModal('preview')">Закрыть</button></div>`;
|
||
|
||
function showOrderDocument(){
|
||
const {rows,total,paid,balance}=currentDocData();
|
||
const number=draft.id || 'черновик';
|
||
const dialog=document.querySelector('#preview .dialog');
|
||
dialog.innerHTML=`<div class="sun-doc">
|
||
<div class="sun-doc-head"><div class="sun-doc-brand"><img src="${window.CateriumBranding.logoHTML()}" alt="${window.CateriumBranding.nameHTML()}"><div><strong>${window.CateriumBranding.nameHTML()}</strong><small>${window.CateriumBranding.cityHTML()}</small></div></div><div class="sun-doc-title"><h2>Заказ № ${escDoc(number)}</h2><p>${escDoc(document.getElementById('event').value || '')}</p><span class="sun-doc-badge">${escDoc(draft.status || 'Новый')}</span></div></div>
|
||
<div class="sun-doc-grid">
|
||
<div class="sun-doc-field"><span>Клиент</span><b>${escDoc(contact.value || '—')}</b></div>
|
||
<div class="sun-doc-field"><span>Телефон</span><b>${escDoc(phone.value || '—')}</b></div>
|
||
<div class="sun-doc-field"><span>Дата и время выдачи</span><b>${escDoc(docDate(date.value))} · ${escDoc(time.value || '—')}</b></div>
|
||
<div class="sun-doc-field"><span>Адрес доставки</span><b>${escDoc(address.value || '—')}</b></div>
|
||
</div>
|
||
<table><thead><tr><th class="doc-num">№</th><th class="doc-name">Позиция</th><th>Цена</th><th>Кол.</th><th>Сумма</th></tr></thead><tbody>${rows.length?rows.map(r=>`<tr><td class="doc-num">${r.index}</td><td class="doc-name"><b>${escDoc(r.name)}</b><span class="doc-sub">${escDoc(r.category)}</span></td><td>${docMoney(r.price)}</td><td>${r.qty}</td><td><b>${docMoney(r.sum)}</b></td></tr>`).join(''):`<tr><td colspan="5">В заказе пока нет позиций.</td></tr>`}</tbody></table>
|
||
<div class="sun-doc-summary"><p><span>Стоимость заказа</span><b>${docMoney(total)}</b></p><p><span>Предоплата</span><b>${docMoney(paid)}</b></p><p class="grand"><span>Остаток к оплате</span><span>${docMoney(balance)}</span></p></div>
|
||
${note.value?`<div class="sun-doc-note"><b>Комментарий к заказу</b>${escDoc(note.value)}</div>`:''}
|
||
<div class="sun-doc-footer"><div><b>${window.CateriumBranding.nameHTML()}</b><br>Спасибо, что выбрали нас.<div class="sun-doc-sign">Ответственный / подпись</div></div><div>Заказчик подтверждает состав заказа, дату, время и адрес выдачи.<div class="sun-doc-sign">Заказчик / подпись</div></div></div>
|
||
${docActions()}
|
||
</div>`;
|
||
modal('preview');
|
||
}
|
||
|
||
function showReceiptDocument(){
|
||
const {rows,total,paid,balance}=currentDocData();
|
||
const number=draft.id || '—';
|
||
const dialog=document.querySelector('#preview .dialog');
|
||
dialog.innerHTML=`<div class="sun-doc sun-receipt">
|
||
<div class="sun-doc-head"><div class="sun-doc-brand"><img src="${window.CateriumBranding.logoHTML()}" alt="${window.CateriumBranding.nameHTML()}"><div><strong>${window.CateriumBranding.nameHTML()}</strong><small>${window.CateriumBranding.cityHTML()}</small></div></div><div class="sun-doc-title"><h2>Товарный чек № ${escDoc(number)}</h2><p>${escDoc(docDate(date.value))} · ${escDoc(time.value || '')}</p></div></div>
|
||
<div class="sun-doc-grid"><div class="sun-doc-field"><span>Покупатель</span><b>${escDoc(contact.value || '—')}${phone.value?' · '+escDoc(phone.value):''}</b></div>${address.value?`<div class="sun-doc-field"><span>Адрес</span><b>${escDoc(address.value)}</b></div>`:''}</div>
|
||
<table><thead><tr><th class="doc-num">№</th><th class="doc-name">Наименование</th><th>Цена</th><th>Кол.</th><th>Сумма</th></tr></thead><tbody>${rows.map(r=>`<tr><td class="doc-num">${r.index}</td><td class="doc-name">${escDoc(r.name)}</td><td>${docMoney(r.price)}</td><td>${r.qty}</td><td>${docMoney(r.sum)}</td></tr>`).join('')}</tbody></table>
|
||
<div class="sun-doc-summary"><p><span>Итого</span><b>${docMoney(total)}</b></p><p><span>Оплачено</span><b>${docMoney(paid)}</b></p><p class="grand"><span>К оплате</span><span>${docMoney(balance)}</span></p></div>
|
||
${note.value?`<div class="sun-doc-note"><b>Примечание</b>${escDoc(note.value)}</div>`:''}
|
||
<div class="sun-doc-footer"><div><div class="sun-doc-sign">Продавец / подпись</div></div><div><div class="sun-doc-sign">Покупатель / подпись</div></div></div>
|
||
${docActions()}
|
||
</div>`;
|
||
modal('preview');
|
||
}
|
||
|
||
const orderDocButton=document.createElement('button');
|
||
orderDocButton.className='outline'; orderDocButton.textContent='Бланк заказа'; orderDocButton.onclick=showOrderDocument;
|
||
// Hidden helper: the order blank remains available to the Print center, but is not shown in the New order action bar.
|
||
orderDocButton.hidden=true; orderDocButton.setAttribute('aria-hidden','true'); orderDocButton.tabIndex=-1; orderDocButton.style.setProperty('display','none','important');
|
||
const receiptButton=document.createElement('button');
|
||
receiptButton.className='outline'; receiptButton.textContent='Товарный чек'; receiptButton.onclick=showReceiptDocument;
|
||
const actionBar=[...document.querySelectorAll('#new .actions')].find(a=>[...a.querySelectorAll('button')].some(b=>b.textContent.trim()==='Сохранить'));
|
||
actionBar.appendChild(orderDocButton); actionBar.appendChild(receiptButton);
|
||
|
||
|
||
renderCatalog();
|
||
})();
|
||
|
||
/* ===== STOCK MODULE v1 ===== */
|
||
(() => {
|
||
const stockView = document.getElementById('stock');
|
||
if (!stockView || stockView.dataset.upgraded === '1') return;
|
||
stockView.dataset.upgraded = '1';
|
||
|
||
const STOCK_KEY = 'sunStock';
|
||
const MOVES_KEY = 'sunStockMoves';
|
||
const SUPPLIERS_KEY = 'sunSuppliers';
|
||
let stock = JSON.parse(localStorage.getItem(STOCK_KEY) || '[]');
|
||
let stockMoves = JSON.parse(localStorage.getItem(MOVES_KEY) || '[]');
|
||
let editingStockId = null;
|
||
let movementStockId = null;
|
||
let stockTab = 'balances';
|
||
|
||
const stockCss = document.createElement('style');
|
||
stockCss.textContent = `
|
||
.stock-toolbar{display:flex;gap:8px;align-items:center;justify-content:space-between;flex-wrap:wrap;margin:14px 0}
|
||
.stock-toolbar .left,.stock-toolbar .right{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||
.stock-toolbar input{min-width:260px}
|
||
.stock-summary{display:grid;grid-template-columns:repeat(4,minmax(150px,1fr));gap:10px;margin:14px 0}
|
||
.stock-summary .summary-box{background:#fafafa;border:1px solid var(--l);border-radius:8px;padding:12px}
|
||
.stock-summary small{display:block;color:#68717a;margin-bottom:5px}.stock-summary b{font-size:20px;color:var(--n)}
|
||
.stock-table{width:100%;border-collapse:collapse;background:#fff}.stock-table th,.stock-table td{padding:10px 8px;border-bottom:1px solid var(--l);text-align:left;vertical-align:middle}.stock-table th{font-size:12px;color:#68717a;background:#fafafa;position:sticky;top:0}.stock-table td.num,.stock-table th.num{text-align:right}.stock-table tr.low{background:#fff7ed}.stock-table tr.zero{background:#fff0f0}.stock-table .stock-actions{white-space:nowrap;display:flex;gap:5px;justify-content:flex-end}.stock-table .stock-actions button{padding:5px 8px}
|
||
.stock-name b{display:block}.stock-name small{color:#68717a}.stock-badge{display:inline-block;padding:3px 7px;border-radius:999px;background:#eef2f4;font-size:12px}.stock-badge.warn{background:#fff0d5;color:#8a5b00}.stock-badge.danger{background:#fde7e7;color:#9c2d2d}.stock-positive{color:#2b6e43;font-weight:bold}.stock-negative{color:#a33;font-weight:bold}
|
||
.stock-form{display:grid;grid-template-columns:1.4fr .65fr .8fr .75fr;gap:10px}.stock-form label{margin-bottom:8px}.movement-form{display:grid;grid-template-columns:1fr 1fr;gap:10px}.movement-form .wide{grid-column:1/-1}
|
||
.stock-supplier b{display:block}.stock-supplier small{display:block;color:#68717a;margin-top:2px}.stock-supplier .inactive{color:#a36a00}.stock-balances-table{min-width:980px}.stock-needs-table{min-width:880px}
|
||
.need-order{margin:12px 0;border:1px solid var(--l);border-radius:8px;overflow:hidden}.need-order-head{display:flex;justify-content:space-between;gap:12px;padding:10px 12px;background:#fafafa}.need-order table{margin:0}.moves-list{display:grid;gap:7px}.move-row{display:grid;grid-template-columns:120px 1fr 110px 140px 1fr;gap:8px;padding:9px;border-bottom:1px solid var(--l);align-items:center}.move-row small{color:#68717a}.stock-empty{padding:20px 0;color:#879198}
|
||
@media(max-width:900px){.stock-summary{grid-template-columns:1fr 1fr}.stock-form,.movement-form{grid-template-columns:1fr}.movement-form .wide{grid-column:auto}.stock-table{font-size:13px}.stock-balances-table th:nth-child(4),.stock-balances-table td:nth-child(4),.stock-balances-table th:nth-child(5),.stock-balances-table td:nth-child(5),.stock-balances-table th:nth-child(6),.stock-balances-table td:nth-child(6){display:none}.move-row{grid-template-columns:1fr 1fr}.move-row span:last-child{grid-column:1/-1}.stock-toolbar input{min-width:0;width:100%}}
|
||
`;
|
||
document.head.appendChild(stockCss);
|
||
|
||
const stockEsc = value => String(value ?? '').replace(/[&<>"']/g, ch => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch]));
|
||
|
||
function getSuppliers() {
|
||
try {
|
||
const list = JSON.parse(localStorage.getItem(SUPPLIERS_KEY) || '[]');
|
||
return Array.isArray(list) ? list.map(s => ({ ...s, id: String(s.id || ''), active: s.active !== false })) : [];
|
||
} catch (_) { return []; }
|
||
}
|
||
|
||
function supplierById(id) {
|
||
return getSuppliers().find(s => String(s.id) === String(id || '')) || null;
|
||
}
|
||
|
||
function supplierOptions(selected = '') {
|
||
const current = String(selected || '');
|
||
const list = getSuppliers().filter(s => s.active !== false || s.id === current).sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'ru'));
|
||
return `<option value="">Без поставщика</option>${list.map(s => `<option value="${stockEsc(s.id)}" ${s.id === current ? 'selected' : ''}>${stockEsc(s.name || 'Без названия')}${s.active === false ? ' · неактивен' : ''}</option>`).join('')}`;
|
||
}
|
||
|
||
function saveStock() {
|
||
localStorage.setItem(STOCK_KEY, JSON.stringify(stock));
|
||
localStorage.setItem(MOVES_KEY, JSON.stringify(stockMoves));
|
||
try { window.dispatchEvent(new CustomEvent('sun:stock-changed')); } catch (_) {}
|
||
}
|
||
|
||
function skuKey(name, unit) {
|
||
return `${String(name || '').trim().toLowerCase()}|${String(unit || '').trim().toLowerCase()}`;
|
||
}
|
||
|
||
function syncProductsFromCatalog() {
|
||
const existing = new Map(stock.map(p => [skuKey(p.name, p.unit), p]));
|
||
let changed = false;
|
||
boxes.forEach(item => {
|
||
(item.ingredients || []).forEach(part => {
|
||
const name = String(part[0] || '').trim();
|
||
const unit = String(part[2] || 'шт.').trim() || 'шт.';
|
||
if (!name) return;
|
||
const key = skuKey(name, unit);
|
||
if (!existing.has(key)) {
|
||
const product = { id: sunUUID(), name, unit, qty: 0, cost: 0, min: 0, supplierId: '', lastPurchaseDate: '', lastPurchasePrice: 0 };
|
||
stock.push(product);
|
||
existing.set(key, product);
|
||
changed = true;
|
||
}
|
||
});
|
||
});
|
||
if (changed) saveStock();
|
||
}
|
||
|
||
// Normalize legacy values.
|
||
stock = stock.map(p => ({
|
||
id: p.id || sunUUID(), name: String(p.name || 'Без названия'), unit: String(p.unit || 'шт.'),
|
||
qty: Number(p.qty || 0), cost: Number(p.cost || 0), min: Number(p.min || 0),
|
||
supplierId: String(p.supplierId || ''), lastPurchaseDate: String(p.lastPurchaseDate || ''), lastPurchasePrice: Number(p.lastPurchasePrice || 0)
|
||
}));
|
||
syncProductsFromCatalog();
|
||
saveStock();
|
||
|
||
stockView.innerHTML = `
|
||
<div class="catalog-head"><div><h1>Склад</h1><p class="hint" style="margin:5px 0 0">Остатки, закупочные цены, движения и автоматическая потребность по активным заказам.</p></div></div>
|
||
<div class="stock-summary" id="stockSummary"></div>
|
||
<div class="subnav" id="stockTabs">
|
||
<button class="on" data-stock-tab="balances">Остатки</button>
|
||
<button data-stock-tab="needs">Потребность по заказам</button>
|
||
<button data-stock-tab="moves">Движения</button>
|
||
</div>
|
||
<div id="stockContent"></div>`;
|
||
|
||
const productModal = document.createElement('div');
|
||
productModal.id = 'stock-product-modal';
|
||
productModal.className = 'modal';
|
||
productModal.innerHTML = `<div class="dialog"><div class="dialog-head"><h2 id="stockProductTitle">Продукт</h2><button type="button" onclick="closeModal('stock-product-modal')">×</button></div>
|
||
<div class="stock-form">
|
||
<label>Продукт<input id="stockName"></label>
|
||
<label>Ед. изм.<input id="stockUnit" placeholder="кг / г / шт."></label>
|
||
<label>Текущая закупочная цена<input id="stockCost" type="number" min="0" step="0.01"></label>
|
||
<label>Минимальный остаток<input id="stockMin" type="number" min="0" step="0.01"></label>
|
||
<label>Основной поставщик<select id="stockSupplier"><option value="">Без поставщика</option></select></label>
|
||
<label>Последняя закупка<input id="stockLastPurchaseDate" type="date"></label>
|
||
<label>Цена последней закупки<input id="stockLastPurchasePrice" type="number" min="0" step="0.01"></label>
|
||
</div>
|
||
<div class="actions"><button class="primary" id="stockSaveProduct">Сохранить</button><button class="danger" id="stockDeleteProduct">Удалить</button></div></div>`;
|
||
document.body.appendChild(productModal);
|
||
|
||
const movementModal = document.createElement('div');
|
||
movementModal.id = 'stock-movement-modal';
|
||
movementModal.className = 'modal';
|
||
movementModal.innerHTML = `<div class="dialog"><div class="dialog-head"><h2 id="stockMovementTitle">Движение</h2><button type="button" onclick="closeModal('stock-movement-modal')">×</button></div>
|
||
<div class="movement-form">
|
||
<label>Операция<select id="stockMoveType"><option value="in">Приход</option><option value="out">Списание</option><option value="set">Инвентаризация (установить остаток)</option></select></label>
|
||
<label>Количество<input id="stockMoveQty" type="number" min="0" step="0.01" value="1"></label>
|
||
<label>Цена за единицу<input id="stockMoveCost" type="number" min="0" step="0.01"></label>
|
||
<label>Дата<input id="stockMoveDate" type="date"></label>
|
||
<label>Поставщик<select id="stockMoveSupplier"><option value="">Без поставщика</option></select></label>
|
||
<label class="wide">Комментарий<input id="stockMoveNote" placeholder="Накладная, партия, причина списания…"></label>
|
||
</div>
|
||
<div class="actions"><button class="primary" id="stockSaveMove">Провести</button></div></div>`;
|
||
document.body.appendChild(movementModal);
|
||
|
||
const fmt = value => (typeof money === 'function' ? money(value) : `${Number(value || 0).toLocaleString('ru-RU')} ₽`);
|
||
const qtyFmt = value => Number(value || 0).toLocaleString('ru-RU', { maximumFractionDigits: 3 });
|
||
|
||
function productByKey(name, unit) {
|
||
return stock.find(p => skuKey(p.name, p.unit) === skuKey(name, unit));
|
||
}
|
||
|
||
function activeOrders() {
|
||
return orders.filter(o => !['Отменён', 'Отдан заказчику'].includes(o.status || 'Новый'));
|
||
}
|
||
|
||
function aggregateNeed(orderList = activeOrders()) {
|
||
const map = {};
|
||
orderList.forEach(order => {
|
||
(order.lines || []).forEach(line => {
|
||
const item = boxes.find(x => x.id == line.id);
|
||
if (!item) return;
|
||
(item.ingredients || []).forEach(part => {
|
||
const name = String(part[0] || '').trim();
|
||
const unit = String(part[2] || 'шт.').trim();
|
||
const key = skuKey(name, unit);
|
||
if (!map[key]) map[key] = { name, unit, qty: 0 };
|
||
map[key].qty += Number(part[1] || 0) * Number(line.qty || 0);
|
||
});
|
||
});
|
||
});
|
||
return Object.values(map).sort((a, b) => a.name.localeCompare(b.name, 'ru'));
|
||
}
|
||
|
||
function renderStockSummary() {
|
||
const low = stock.filter(p => Number(p.qty) <= Number(p.min) && Number(p.min) > 0).length;
|
||
const zero = stock.filter(p => Number(p.qty) <= 0).length;
|
||
const value = stock.reduce((sum, p) => sum + Number(p.qty || 0) * Number(p.cost || 0), 0);
|
||
const need = aggregateNeed().reduce((sum, n) => {
|
||
const p = productByKey(n.name, n.unit);
|
||
return sum + Math.max(0, n.qty - Number(p?.qty || 0)) * Number(p?.cost || 0);
|
||
}, 0);
|
||
document.getElementById('stockSummary').innerHTML = `
|
||
<div class="summary-box"><small>Позиций на складе</small><b>${stock.length}</b></div>
|
||
<div class="summary-box"><small>Нулевой / минусовой остаток</small><b>${zero}</b></div>
|
||
<div class="summary-box"><small>Стоимость остатков</small><b>${fmt(value)}</b></div>
|
||
<div class="summary-box"><small>Ориентир закупки под заказы</small><b>${fmt(need)}</b></div>`;
|
||
}
|
||
|
||
function renderBalances(filter = '') {
|
||
const q = String(filter || '').trim().toLowerCase();
|
||
const supplierMap = new Map(getSuppliers().map(s => [String(s.id), s]));
|
||
const list = stock.filter(p => {
|
||
const supplier = supplierMap.get(String(p.supplierId || ''));
|
||
const haystack = `${p.name} ${p.unit} ${supplier?.name || ''} ${supplier?.phone || ''}`.toLowerCase();
|
||
return !q || haystack.includes(q);
|
||
}).sort((a, b) => a.name.localeCompare(b.name, 'ru'));
|
||
document.getElementById('stockContent').innerHTML = `
|
||
<div class="stock-toolbar"><div class="left"><input id="stockSearch" type="search" placeholder="Поиск продукта или поставщика" value="${stockEsc(filter || '')}"><button class="primary" id="stockAddProduct">Добавить продукт</button></div><div class="right"><button class="outline" id="stockSync">Обновить из составов боксов</button></div></div>
|
||
${list.length ? `<div style="overflow:auto"><table class="stock-table stock-balances-table"><thead><tr><th>Продукт</th><th>Поставщик</th><th class="num">Остаток</th><th class="num">Мин.</th><th class="num">Закуп. цена</th><th class="num">Стоимость</th><th></th></tr></thead><tbody>${list.map(p => {
|
||
const isZero = p.qty <= 0, isLow = !isZero && p.min > 0 && p.qty <= p.min;
|
||
const supplier = supplierMap.get(String(p.supplierId || ''));
|
||
const supplierMeta = [supplier?.phone || '', p.lastPurchaseDate ? `закупка ${p.lastPurchaseDate}` : ''].filter(Boolean).join(' · ');
|
||
return `<tr class="${isZero ? 'zero' : isLow ? 'low' : ''}"><td class="stock-name"><b>${stockEsc(p.name)}</b><small>${stockEsc(p.unit)}</small></td><td class="stock-supplier">${supplier ? `<b>${stockEsc(supplier.name || 'Без названия')}</b><small class="${supplier.active === false ? 'inactive' : ''}">${stockEsc(supplierMeta || (supplier.active === false ? 'Неактивен' : ''))}</small>` : '<span class="hint">Не назначен</span>'}</td><td class="num"><span class="stock-badge ${isZero ? 'danger' : isLow ? 'warn' : ''}">${qtyFmt(p.qty)} ${stockEsc(p.unit)}</span></td><td class="num">${qtyFmt(p.min)} ${stockEsc(p.unit)}</td><td class="num">${fmt(p.cost)}</td><td class="num"><b>${fmt(p.qty * p.cost)}</b></td><td><div class="stock-actions"><button class="mini" onclick="stockMovement('${stockEsc(p.id)}','in')">+ Приход</button><button class="mini" onclick="stockMovement('${stockEsc(p.id)}','out')">− Списать</button><button class="mini" onclick="editStockProduct('${stockEsc(p.id)}')">Изменить</button></div></td></tr>`;
|
||
}).join('')}</tbody></table></div>` : '<p class="stock-empty">Продукты не найдены.</p>'}`;
|
||
document.getElementById('stockSearch').oninput = e => renderBalances(e.target.value);
|
||
document.getElementById('stockAddProduct').onclick = () => editStockProduct(null);
|
||
document.getElementById('stockSync').onclick = () => { syncProductsFromCatalog(); renderStock(); };
|
||
}
|
||
|
||
function renderNeeds() {
|
||
const needs = aggregateNeed();
|
||
const orderCount = activeOrders().length;
|
||
const estimated = needs.reduce((sum, n) => {
|
||
const p = productByKey(n.name, n.unit);
|
||
return sum + Math.max(0, n.qty - Number(p?.qty || 0)) * Number(p?.cost || 0);
|
||
}, 0);
|
||
const supplierMap = new Map(getSuppliers().map(s => [String(s.id), s]));
|
||
document.getElementById('stockContent').innerHTML = `
|
||
<div class="stock-toolbar"><div class="left"><b>Активных заказов: ${orderCount}</b><span class="hint">Учитываются все, кроме «Отдан заказчику» и «Отменён».</span></div><div class="right"><b>Ориентир закупки: ${fmt(estimated)}</b></div></div>
|
||
${needs.length ? `<div style="overflow:auto"><table class="stock-table stock-needs-table"><thead><tr><th>Продукт</th><th>Поставщик</th><th class="num">Нужно</th><th class="num">Есть</th><th class="num">Не хватает</th><th class="num">Цена</th><th class="num">Закупить на</th></tr></thead><tbody>${needs.map(n => {
|
||
const p = productByKey(n.name, n.unit);
|
||
const supplier = supplierMap.get(String(p?.supplierId || ''));
|
||
const have = Number(p?.qty || 0), deficit = Math.max(0, n.qty - have), cost = Number(p?.cost || 0);
|
||
return `<tr class="${deficit > 0 ? 'low' : ''}"><td class="stock-name"><b>${stockEsc(n.name)}</b><small>${stockEsc(n.unit)}</small></td><td>${supplier ? `<b>${stockEsc(supplier.name)}</b><small style="display:block;color:#68717a">${stockEsc(supplier.phone || '')}</small>` : '<span class="hint">Не назначен</span>'}</td><td class="num">${qtyFmt(n.qty)} ${stockEsc(n.unit)}</td><td class="num">${qtyFmt(have)} ${stockEsc(n.unit)}</td><td class="num"><span class="${deficit > 0 ? 'stock-negative' : 'stock-positive'}">${qtyFmt(deficit)} ${stockEsc(n.unit)}</span></td><td class="num">${fmt(cost)}</td><td class="num"><b>${fmt(deficit * cost)}</b></td></tr>`;
|
||
}).join('')}</tbody></table></div>` : '<p class="stock-empty">По активным заказам сейчас нет потребности в продуктах.</p>'}
|
||
<h3 style="margin-top:24px">По заказам</h3>${renderOrderNeeds()}`;
|
||
}
|
||
|
||
function renderOrderNeeds() {
|
||
const list = activeOrders().sort((a, b) => `${a.date || ''}T${a.time || ''}`.localeCompare(`${b.date || ''}T${b.time || ''}`));
|
||
if (!list.length) return '<p class="stock-empty">Активных заказов нет.</p>';
|
||
return list.map(order => {
|
||
const needs = aggregateNeed([order]);
|
||
return `<div class="need-order"><div class="need-order-head"><div><b>Заказ №${order.id} · ${order.event || 'Без названия'}</b><div class="hint">${order.date || '—'} ${order.time || ''} · ${order.status || 'Новый'}</div></div><button class="outline" onclick="writeOffOrderStock(${order.id})">Списать продукты по заказу</button></div>
|
||
${needs.length ? `<div style="overflow:auto"><table class="stock-table"><tbody>${needs.map(n => `<tr><td>${n.name}</td><td class="num">${qtyFmt(n.qty)} ${n.unit}</td></tr>`).join('')}</tbody></table></div>` : '<div class="stock-empty" style="padding:10px 12px">В заказе нет позиций с составом.</div>'}</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderMoves() {
|
||
const list = [...stockMoves].sort((a, b) => `${b.date || ''}${b.createdAt || ''}`.localeCompare(`${a.date || ''}${a.createdAt || ''}`));
|
||
document.getElementById('stockContent').innerHTML = `<div class="stock-toolbar"><div class="left"><b>История движений</b></div><div class="right"><button class="outline" id="clearMoves">Очистить историю</button></div></div>
|
||
<div class="moves-list">${list.length ? list.map(m => {
|
||
const p = stock.find(x => x.id === m.productId);
|
||
const supplier = supplierById(m.supplierId);
|
||
const supplierName = m.supplierName || supplier?.name || '';
|
||
const label = m.type === 'in' ? 'Приход' : m.type === 'out' ? 'Списание' : 'Инвентаризация';
|
||
const cls = m.type === 'in' ? 'stock-positive' : m.type === 'out' ? 'stock-negative' : '';
|
||
const note = [supplierName ? `Поставщик: ${supplierName}` : '', m.note || ''].filter(Boolean).join(' · ');
|
||
return `<div class="move-row"><span>${stockEsc(m.date || '—')}</span><span><b>${stockEsc(m.productName || p?.name || 'Удалённый продукт')}</b><small>${stockEsc(m.unit || p?.unit || '')}</small></span><span class="${cls}">${label}<br><b>${m.type === 'out' ? '−' : m.type === 'in' ? '+' : ''}${qtyFmt(m.qty)}</b></span><span>${m.cost ? fmt(m.cost) + '/ед.' : '—'}</span><span>${stockEsc(note)}</span></div>`;
|
||
}).join('') : '<p class="stock-empty">Движений пока нет.</p>'}</div>`;
|
||
document.getElementById('clearMoves').onclick = () => { if (confirm('Очистить историю движений склада? Остатки продуктов не изменятся.')) { stockMoves = []; saveStock(); renderStock(); } };
|
||
}
|
||
|
||
function renderStock() {
|
||
syncProductsFromCatalog();
|
||
renderStockSummary();
|
||
document.querySelectorAll('#stockTabs button').forEach(b => b.classList.toggle('on', b.dataset.stockTab === stockTab));
|
||
if (stockTab === 'balances') renderBalances();
|
||
else if (stockTab === 'needs') renderNeeds();
|
||
else renderMoves();
|
||
}
|
||
|
||
document.querySelectorAll('#stockTabs button').forEach(button => button.onclick = () => {
|
||
stockTab = button.dataset.stockTab;
|
||
renderStock();
|
||
});
|
||
|
||
window.editStockProduct = id => {
|
||
editingStockId = id;
|
||
const p = id ? stock.find(x => x.id === id) : null;
|
||
document.getElementById('stockProductTitle').textContent = p ? 'Изменить продукт' : 'Новый продукт';
|
||
stockName.value = p?.name || '';
|
||
stockUnit.value = p?.unit || 'шт.';
|
||
stockCost.value = Number(p?.cost || 0);
|
||
stockMin.value = Number(p?.min || 0);
|
||
stockSupplier.innerHTML = supplierOptions(p?.supplierId || '');
|
||
stockSupplier.value = String(p?.supplierId || '');
|
||
stockLastPurchaseDate.value = p?.lastPurchaseDate || '';
|
||
stockLastPurchasePrice.value = Number(p?.lastPurchasePrice || 0);
|
||
stockDeleteProduct.style.display = p ? 'inline-block' : 'none';
|
||
modal('stock-product-modal');
|
||
};
|
||
|
||
stockSaveProduct.onclick = () => {
|
||
const name = stockName.value.trim(), unit = stockUnit.value.trim() || 'шт.';
|
||
if (!name) return alert('Введите название продукта.');
|
||
const duplicate = stock.find(p => p.id !== editingStockId && skuKey(p.name, p.unit) === skuKey(name, unit));
|
||
if (duplicate) return alert('Такой продукт с этой единицей измерения уже есть на складе.');
|
||
const lastPrice = Math.max(0, Number(stockLastPurchasePrice.value || 0));
|
||
const fields = {
|
||
name, unit,
|
||
cost: Math.max(0, Number(stockCost.value || lastPrice || 0)),
|
||
min: Math.max(0, Number(stockMin.value || 0)),
|
||
supplierId: String(stockSupplier.value || ''),
|
||
lastPurchaseDate: String(stockLastPurchaseDate.value || ''),
|
||
lastPurchasePrice: lastPrice
|
||
};
|
||
if (editingStockId) {
|
||
const p = stock.find(x => x.id === editingStockId);
|
||
Object.assign(p, fields);
|
||
} else {
|
||
stock.push({ id: sunUUID(), qty: 0, ...fields });
|
||
}
|
||
saveStock(); closeModal('stock-product-modal'); renderStock();
|
||
};
|
||
|
||
stockDeleteProduct.onclick = () => {
|
||
const p = stock.find(x => x.id === editingStockId);
|
||
if (!p || !confirm(`Удалить «${p.name}» со склада? История движений сохранится.`)) return;
|
||
stock = stock.filter(x => x.id !== editingStockId);
|
||
saveStock(); closeModal('stock-product-modal'); renderStock();
|
||
};
|
||
|
||
function updateMovementSupplierState() {
|
||
const select = document.getElementById('stockMoveSupplier');
|
||
if (select) select.disabled = stockMoveType.value !== 'in';
|
||
}
|
||
stockMoveType.onchange = updateMovementSupplierState;
|
||
|
||
window.stockMovement = (id, type = 'in') => {
|
||
movementStockId = id;
|
||
const p = stock.find(x => x.id === id);
|
||
if (!p) return;
|
||
stockMovementTitle.textContent = `${p.name} · ${qtyFmt(p.qty)} ${p.unit}`;
|
||
stockMoveType.value = type;
|
||
stockMoveQty.value = 1;
|
||
stockMoveCost.value = Number(p.cost || 0);
|
||
stockMoveDate.value = new Date().toISOString().slice(0, 10);
|
||
stockMoveSupplier.innerHTML = supplierOptions(p.supplierId || '');
|
||
stockMoveSupplier.value = String(p.supplierId || '');
|
||
stockMoveNote.value = '';
|
||
updateMovementSupplierState();
|
||
modal('stock-movement-modal');
|
||
};
|
||
|
||
stockSaveMove.onclick = () => {
|
||
const p = stock.find(x => x.id === movementStockId);
|
||
if (!p) return;
|
||
const type = stockMoveType.value, amount = Math.max(0, Number(stockMoveQty.value || 0));
|
||
if (type !== 'set' && amount <= 0) return alert('Введите количество больше нуля.');
|
||
const oldQty = Number(p.qty || 0);
|
||
if (type === 'in') p.qty = oldQty + amount;
|
||
else if (type === 'out') p.qty = oldQty - amount;
|
||
else p.qty = amount;
|
||
const moveCost = Math.max(0, Number(stockMoveCost.value || 0));
|
||
const supplierId = type === 'in' ? String(stockMoveSupplier.value || '') : '';
|
||
const supplier = supplierById(supplierId);
|
||
if (type === 'in') {
|
||
if (moveCost > 0) p.cost = moveCost;
|
||
p.supplierId = supplierId || p.supplierId || '';
|
||
p.lastPurchaseDate = String(stockMoveDate.value || new Date().toISOString().slice(0, 10));
|
||
p.lastPurchasePrice = moveCost > 0 ? moveCost : Number(p.cost || 0);
|
||
}
|
||
stockMoves.push({ id: sunUUID(), productId: p.id, productName: p.name, unit: p.unit, type, qty: type === 'set' ? amount - oldQty : amount, setTo: type === 'set' ? amount : undefined, cost: moveCost, date: stockMoveDate.value, supplierId, supplierName: supplier?.name || '', note: stockMoveNote.value.trim(), createdAt: new Date().toISOString() });
|
||
saveStock(); closeModal('stock-movement-modal'); renderStock();
|
||
};
|
||
|
||
window.writeOffOrderStock = orderId => {
|
||
const order = orders.find(o => o.id == orderId);
|
||
if (!order) return;
|
||
if (order.stockWrittenOff) return alert(`По заказу №${orderId} склад уже был списан.`);
|
||
const needs = aggregateNeed([order]);
|
||
if (!needs.length) return alert('В заказе нет продуктов для списания.');
|
||
if (!confirm(`Списать продукты со склада по заказу №${orderId}? Остатки могут уйти в минус.`)) return;
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
needs.forEach(n => {
|
||
let p = productByKey(n.name, n.unit);
|
||
if (!p) {
|
||
p = { id: sunUUID(), name: n.name, unit: n.unit, qty: 0, cost: 0, min: 0, supplierId: '', lastPurchaseDate: '', lastPurchasePrice: 0 };
|
||
stock.push(p);
|
||
}
|
||
p.qty -= n.qty;
|
||
stockMoves.push({ id: sunUUID(), productId: p.id, productName: p.name, unit: p.unit, type: 'out', qty: n.qty, cost: p.cost, date: today, note: `Автосписание по заказу №${order.id} · ${order.event || ''}`.trim(), createdAt: new Date().toISOString() });
|
||
});
|
||
order.stockWrittenOff = true;
|
||
persist();
|
||
saveStock();
|
||
renderStock();
|
||
alert(`Продукты по заказу №${orderId} списаны.`);
|
||
};
|
||
|
||
// Keep the need list current when catalog/order data changes.
|
||
const priorSaveBoxStock = window.saveBox;
|
||
window.saveBox = () => { priorSaveBoxStock(); syncProductsFromCatalog(); if (stockView.classList.contains('on')) renderStock(); window.sunClientOfferCatalogChanged?.(edited?.id); };
|
||
const priorSaveOrderStock = window.saveOrder;
|
||
window.saveOrder = () => { priorSaveOrderStock(); if (stockView.classList.contains('on')) renderStock(); };
|
||
const priorStatusStock = window.status;
|
||
window.status = (id, value) => { priorStatusStock(id, value); if (stockView.classList.contains('on')) renderStock(); };
|
||
|
||
// The original navigation calls show(); make sure stock is refreshed when opened.
|
||
const stockNavButton = [...document.querySelectorAll('nav button')].find(b => b.textContent.trim() === 'Склад');
|
||
if (stockNavButton) {
|
||
const previousClick = stockNavButton.onclick;
|
||
stockNavButton.onclick = function(event) {
|
||
if (previousClick) previousClick.call(this, event);
|
||
setTimeout(renderStock, 0);
|
||
};
|
||
}
|
||
|
||
window.sunRefreshStock = renderStock;
|
||
window.sunUnlinkSupplier = supplierId => {
|
||
const target = String(supplierId || '');
|
||
let changed = false;
|
||
stock.forEach(product => {
|
||
if (String(product.supplierId || '') === target) { product.supplierId = ''; changed = true; }
|
||
});
|
||
if (changed) saveStock();
|
||
if (stockView.classList.contains('on')) renderStock();
|
||
return changed;
|
||
};
|
||
window.addEventListener('sun:suppliers-changed', () => {
|
||
const productSelect = document.getElementById('stockSupplier');
|
||
const moveSelect = document.getElementById('stockMoveSupplier');
|
||
if (productSelect) { const selected = productSelect.value; productSelect.innerHTML = supplierOptions(selected); productSelect.value = selected; }
|
||
if (moveSelect) { const selected = moveSelect.value; moveSelect.innerHTML = supplierOptions(selected); moveSelect.value = selected; }
|
||
if (stockView.classList.contains('on')) renderStock();
|
||
});
|
||
|
||
renderStock();
|
||
})();
|
||
|
||
/* ===== DASHBOARD MODULE v1 ===== */
|
||
(() => {
|
||
const nav = document.querySelector('nav');
|
||
if (!nav || document.getElementById('dashboard')) return;
|
||
|
||
const css = document.createElement('style');
|
||
css.textContent = `
|
||
.dash-head{display:flex;justify-content:space-between;gap:16px;align-items:flex-start;flex-wrap:wrap;margin-bottom:16px}
|
||
.dash-head h1{margin:0;color:var(--n)}.dash-head p{margin:5px 0 0;color:#68717a}
|
||
.dash-actions{display:flex;gap:8px;flex-wrap:wrap}
|
||
.dash-metrics{display:grid;grid-template-columns:repeat(4,minmax(150px,1fr));gap:10px;margin:14px 0 18px}
|
||
.dash-metric{border:1px solid var(--l);border-radius:10px;padding:14px;background:#fafafa;min-height:92px}
|
||
.dash-metric small{display:block;color:#68717a;margin-bottom:7px}.dash-metric b{display:block;color:var(--n);font-size:24px}.dash-metric span{display:block;color:#879198;font-size:12px;margin-top:4px}
|
||
.dash-grid{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(320px,.85fr);gap:14px}
|
||
.dash-panel{border:1px solid var(--l);border-radius:10px;background:#fff;overflow:hidden}
|
||
.dash-panel-head{display:flex;justify-content:space-between;gap:10px;align-items:center;padding:13px 14px;border-bottom:1px solid var(--l);background:#fafafa}
|
||
.dash-panel-head h2{font-size:17px;margin:0;color:var(--n)}.dash-panel-head small{color:#68717a}
|
||
.dash-list{padding:0 14px}.dash-order{display:grid;grid-template-columns:78px 1fr 115px 120px;gap:9px;align-items:center;padding:11px 0;border-bottom:1px solid #e7e9ea}
|
||
.dash-order:last-child{border-bottom:0}.dash-order-time{font-weight:700;color:var(--n)}.dash-order-main b{display:block}.dash-order-main small{display:block;color:#68717a;margin-top:3px}.dash-order-money{text-align:right;font-weight:700}.dash-order button{justify-self:end}
|
||
.dash-badge{display:inline-block;padding:4px 8px;border-radius:999px;background:#eef2f4;font-size:11px;color:#50606b;margin-top:4px}
|
||
.dash-empty{padding:20px 14px;color:#879198}
|
||
.dash-week{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:8px;padding:14px}
|
||
.dash-day{border:1px solid #e6e9ec;background:#f8f9fb;border-radius:10px;padding:10px 8px;min-height:94px;display:flex;flex-direction:column;justify-content:space-between}
|
||
.dash-day.today{border-color:#c9a45c;box-shadow:inset 0 0 0 1px #c9a45c;background:#fffaf0}
|
||
.dash-day small{color:#7a848c;font-size:11px;text-transform:uppercase;letter-spacing:.03em}.dash-day b{color:var(--n);font-size:20px;line-height:1}.dash-day span{color:#68717a;font-size:11px;line-height:1.25}
|
||
.dash-attention{padding:2px 14px}.dash-attn{display:grid;grid-template-columns:34px 1fr auto;gap:10px;align-items:center;padding:10px 0;border-bottom:1px solid #e7e9ea}.dash-attn:last-child{border-bottom:0}
|
||
.dash-attn-icon{width:30px;height:30px;border-radius:9px;background:#fff4e8;display:grid;place-items:center;font-size:15px}.dash-attn b{display:block;color:var(--n);font-size:13px}.dash-attn small{display:block;color:#68717a;margin-top:2px}.dash-attn .mini{white-space:nowrap}
|
||
.dash-alert{margin:0 14px 14px;padding:10px 12px;border-radius:8px;background:#fff7ed;border:1px solid #f0dcc1;color:#7a5521}
|
||
.dash-good{background:#eff8f1;border-color:#cee4d2;color:#366144}
|
||
.dash-mini-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;padding:14px}.dash-mini{border-radius:8px;background:#f7f8f8;padding:11px}.dash-mini small{display:block;color:#68717a}.dash-mini b{display:block;margin-top:4px;color:var(--n);font-size:17px}
|
||
@media(max-width:1050px){.dash-metrics{grid-template-columns:1fr 1fr}.dash-grid{grid-template-columns:1fr}.dash-order{grid-template-columns:70px 1fr 105px}.dash-order button{display:none}}
|
||
@media(max-width:650px){.dash-metrics{grid-template-columns:1fr 1fr}.dash-metric b{font-size:19px}.dash-order{grid-template-columns:58px 1fr}.dash-order-money{display:none}.dash-week{grid-template-columns:repeat(2,minmax(0,1fr))}.dash-day:last-child{grid-column:1/-1}.dash-attn{grid-template-columns:34px 1fr}.dash-attn .mini{display:none}}
|
||
`;
|
||
document.head.appendChild(css);
|
||
|
||
const button = document.createElement('button');
|
||
button.textContent = 'Главная';
|
||
nav.appendChild(button);
|
||
|
||
const view = document.createElement('section');
|
||
view.id = 'dashboard';
|
||
view.className = 'view page';
|
||
document.body.appendChild(view);
|
||
|
||
const fmt = n => (typeof money === 'function' ? money(n) : `${Number(n || 0).toLocaleString('ru-RU')} ₽`);
|
||
const orderTotal = o => (typeof orderTotalValue === 'function' ? Number(orderTotalValue(o) || 0) : Number(o.total || 0));
|
||
const todayIso = () => {
|
||
const d = new Date();
|
||
const y = d.getFullYear(), m = String(d.getMonth() + 1).padStart(2, '0'), day = String(d.getDate()).padStart(2, '0');
|
||
return `${y}-${m}-${day}`;
|
||
};
|
||
const dateLabel = raw => {
|
||
if (!raw) return '—';
|
||
const d = new Date(raw + 'T00:00:00');
|
||
return Number.isNaN(d.getTime()) ? raw : d.toLocaleDateString('ru-RU', { day:'2-digit', month:'2-digit' });
|
||
};
|
||
const active = o => (o.status || 'Новый') !== 'Отменён';
|
||
const working = o => !['Отменён','Отдан заказчику'].includes(o.status || 'Новый');
|
||
const orderStamp = o => `${o.date || '9999-99-99'}T${o.time || '23:59'}`;
|
||
|
||
function go(label) {
|
||
const target = [...nav.querySelectorAll('button')].find(b => b.textContent.trim() === label);
|
||
if (target) target.click();
|
||
}
|
||
window.dashboardGo = go;
|
||
window.dashboardOpenOrder = id => {
|
||
if (typeof openOrder === 'function') openOrder(id);
|
||
const newBtn = [...nav.querySelectorAll('button')].find(b => b.textContent.trim() === 'Новый заказ');
|
||
[...nav.querySelectorAll('button')].forEach(b => b.classList.remove('on'));
|
||
if (newBtn) newBtn.classList.add('on');
|
||
};
|
||
|
||
function stockData() {
|
||
try { return JSON.parse(localStorage.getItem('sunStock') || '[]'); } catch { return []; }
|
||
}
|
||
function skuKey(name, unit) { return `${String(name || '').trim().toLowerCase()}|${String(unit || '').trim().toLowerCase()}`; }
|
||
function aggregateNeeds(orderList) {
|
||
const map = {};
|
||
orderList.forEach(order => (order.lines || []).forEach(line => {
|
||
const item = boxes.find(x => x.id == line.id);
|
||
(item?.ingredients || []).forEach(part => {
|
||
const name = String(part[0] || '').trim(), unit = String(part[2] || 'шт.').trim() || 'шт.';
|
||
if (!name) return;
|
||
const key = skuKey(name, unit);
|
||
if (!map[key]) map[key] = { name, unit, qty:0 };
|
||
map[key].qty += Number(part[1] || 0) * Number(line.qty || 0);
|
||
});
|
||
}));
|
||
const stock = stockData();
|
||
const stockMap = new Map(stock.map(p => [skuKey(p.name,p.unit), p]));
|
||
return Object.values(map).map(n => {
|
||
const p = stockMap.get(skuKey(n.name,n.unit));
|
||
const have = Number(p?.qty || 0), deficit = Math.max(0, n.qty - have), cost = Number(p?.cost || 0);
|
||
return {...n, have, deficit, cost, buy:deficit*cost};
|
||
}).filter(n => n.deficit > 0).sort((a,b) => b.buy - a.buy || b.deficit - a.deficit);
|
||
}
|
||
|
||
function render() {
|
||
const today = todayIso();
|
||
const valid = orders.filter(active);
|
||
const todayOrders = valid.filter(o => o.date === today).sort((a,b) => orderStamp(a).localeCompare(orderStamp(b)));
|
||
const upcoming = orders.filter(working).filter(o => (o.date || '') >= today).sort((a,b) => orderStamp(a).localeCompare(orderStamp(b)));
|
||
const nextOrders = upcoming.slice(0, 7);
|
||
const totalRevenue = valid.reduce((s,o) => s + orderTotal(o), 0);
|
||
const totalPaid = valid.reduce((s,o) => s + Math.min(orderTotal(o), Math.max(0, Number(o.prepayment || 0))), 0);
|
||
const totalDebt = valid.reduce((s,o) => s + Math.max(0, orderTotal(o) - Math.max(0, Number(o.prepayment || 0))), 0);
|
||
const startDay = new Date(today + 'T00:00:00');
|
||
const weekDays = Array.from({length:7}, (_,i) => {
|
||
const d = new Date(startDay); d.setDate(startDay.getDate()+i);
|
||
const iso = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
||
const list = valid.filter(o => o.date === iso);
|
||
return {date:iso, dateObj:d, list, total:list.reduce((sum,o)=>sum+orderTotal(o),0)};
|
||
});
|
||
const weekOrders = weekDays.reduce((sum,d)=>sum+d.list.length,0);
|
||
const weekRevenue = weekDays.reduce((sum,d)=>sum+d.total,0);
|
||
const attention = [];
|
||
upcoming.slice(0,30).forEach(o => {
|
||
const debt = Math.max(0, orderTotal(o)-Math.max(0,Number(o.prepayment||0)));
|
||
const checklist = o.checklist && typeof o.checklist === 'object' ? Object.values(o.checklist) : [];
|
||
const checklistDone = checklist.length ? checklist.filter(Boolean).length : 0;
|
||
if (!String(o.address||'').trim()) attention.push({icon:'⌖',title:`Заказ №${o.id}: нет адреса`,sub:o.event||'Мероприятие',id:o.id});
|
||
else if (!String(o.responsible||'').trim()) attention.push({icon:'👤',title:`Заказ №${o.id}: нет ответственного`,sub:`${dateLabel(o.date)} · ${o.time||'—'}`,id:o.id});
|
||
else if (debt>0) attention.push({icon:'₽',title:`Заказ №${o.id}: остаток ${fmt(debt)}`,sub:o.contact||o.event||'Клиент',id:o.id});
|
||
else if (checklist.length && checklistDone < checklist.length) attention.push({icon:'✓',title:`Заказ №${o.id}: готовность ${checklistDone}/${checklist.length}`,sub:`${dateLabel(o.date)} · ${o.time||'—'}`,id:o.id});
|
||
});
|
||
|
||
const todayHtml = todayOrders.length ? todayOrders.map(o => `<div class="dash-order">
|
||
<div class="dash-order-time">${o.time || '—'}</div>
|
||
<div class="dash-order-main"><b>№${o.id} · ${o.event || 'Заказ'}</b><small>${o.contact || 'Клиент не указан'} · ${o.address || 'Без адреса'}</small><span class="dash-badge">${o.status || 'Новый'}</span></div>
|
||
<div class="dash-order-money">${fmt(orderTotal(o))}</div>
|
||
<button class="outline" type="button" onclick="dashboardOpenOrder(${o.id})">Открыть</button>
|
||
</div>`).join('') : '<div class="dash-empty">На сегодня сохранённых заказов нет.</div>';
|
||
|
||
const nextHtml = nextOrders.length ? nextOrders.map(o => `<div class="dash-order">
|
||
<div class="dash-order-time">${dateLabel(o.date)}<small style="display:block;font-weight:400;color:#68717a">${o.time || ''}</small></div>
|
||
<div class="dash-order-main"><b>№${o.id} · ${o.event || 'Заказ'}</b><small>${o.contact || 'Клиент не указан'} · ${o.address || 'Без адреса'}</small><span class="dash-badge">${o.status || 'Новый'}</span></div>
|
||
<div class="dash-order-money">${fmt(orderTotal(o))}</div>
|
||
<button class="outline" type="button" onclick="dashboardOpenOrder(${o.id})">Открыть</button>
|
||
</div>`).join('') : '<div class="dash-empty">Ближайших активных заказов нет.</div>';
|
||
|
||
const weekHtml = weekDays.map((d,i) => `<div class="dash-day ${i===0?'today':''}"><small>${d.dateObj.toLocaleDateString('ru-RU',{weekday:'short'})} · ${d.dateObj.toLocaleDateString('ru-RU',{day:'2-digit',month:'2-digit'})}</small><b>${d.list.length}</b><span>${d.list.length===1?'заказ':(d.list.length>=2&&d.list.length<=4?'заказа':'заказов')}<br>${fmt(d.total)}</span></div>`).join('');
|
||
const attentionHtml = attention.length ? attention.slice(0,6).map(a => `<div class="dash-attn"><div class="dash-attn-icon">${a.icon}</div><div><b>${a.title}</b><small>${a.sub}</small></div><button class="mini" type="button" onclick="dashboardOpenOrder(${a.id})">Открыть</button></div>`).join('') : '<div class="dash-empty">По ближайшим заказам критичных моментов нет.</div>';
|
||
|
||
view.innerHTML = `<div class="dash-head"><div><h1>Главная</h1><p>Оперативная картина по заказам, оплатам и ближайшим мероприятиям.</p></div><div class="dash-actions"><button class="primary" onclick="dashboardGo('Новый заказ')">+ Новый заказ</button><button class="outline" onclick="dashboardGo('Заказы')">Все заказы</button><button class="outline" onclick="dashboardGo('Календарь')">Календарь</button></div></div>
|
||
<div class="dash-metrics">
|
||
<div class="dash-metric"><small>Заказов сегодня</small><b>${todayOrders.length}</b><span>${todayOrders.filter(o=>working(o)).length} ещё в работе</span></div>
|
||
<div class="dash-metric"><small>Выручка по заказам</small><b>${fmt(totalRevenue)}</b><span>без отменённых</span></div>
|
||
<div class="dash-metric"><small>Получено предоплат</small><b>${fmt(totalPaid)}</b><span>${totalRevenue ? Math.round(totalPaid/totalRevenue*100) : 0}% от суммы заказов</span></div>
|
||
<div class="dash-metric"><small>Осталось получить</small><b>${fmt(totalDebt)}</b><span>по действующим заказам</span></div>
|
||
</div>
|
||
<div class="dash-grid">
|
||
<div>
|
||
<section class="dash-panel"><div class="dash-panel-head"><h2>Сегодня</h2><small>${new Date().toLocaleDateString('ru-RU',{day:'numeric',month:'long',weekday:'long'})}</small></div><div class="dash-list">${todayHtml}</div></section>
|
||
<section class="dash-panel" style="margin-top:14px"><div class="dash-panel-head"><h2>Ближайшие мероприятия</h2><button class="mini" onclick="dashboardGo('Заказы')">Все заказы</button></div><div class="dash-list">${nextHtml}</div></section>
|
||
</div>
|
||
<div>
|
||
<section class="dash-panel"><div class="dash-panel-head"><h2>План на 7 дней</h2><button class="mini" onclick="dashboardGo('Календарь')">Календарь</button></div>
|
||
<div class="dash-mini-grid"><div class="dash-mini"><small>Заказов за 7 дней</small><b>${weekOrders}</b></div><div class="dash-mini"><small>Сумма заказов</small><b>${fmt(weekRevenue)}</b></div></div>
|
||
<div class="dash-week">${weekHtml}</div>
|
||
</section>
|
||
<section class="dash-panel" style="margin-top:14px"><div class="dash-panel-head"><h2>Требуют внимания</h2><small>${attention.length ? `${attention.length} ${attention.length===1?'пункт':'пунктов'}` : 'всё спокойно'}</small></div><div class="dash-attention">${attentionHtml}</div></section>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
button.onclick = () => {
|
||
document.querySelectorAll('.view').forEach(x => x.classList.remove('on'));
|
||
view.classList.add('on');
|
||
[...nav.querySelectorAll('button')].forEach(x => x.classList.remove('on'));
|
||
button.classList.add('on');
|
||
render();
|
||
};
|
||
|
||
// Make the dashboard the start screen without changing the old navigation indexes.
|
||
document.querySelectorAll('.view').forEach(x => x.classList.remove('on'));
|
||
[...nav.querySelectorAll('button')].forEach(x => x.classList.remove('on'));
|
||
view.classList.add('on');
|
||
button.classList.add('on');
|
||
render();
|
||
})();
|
||
|
||
|
||
/* ===== ORDER CALENDAR MODULE v1 ===== */
|
||
(() => {
|
||
const nav = document.querySelector('nav');
|
||
if (!nav || document.getElementById('calendar')) return;
|
||
|
||
const css = document.createElement('style');
|
||
css.textContent = `
|
||
.cal-head{display:flex;justify-content:space-between;gap:14px;align-items:flex-start;flex-wrap:wrap;margin-bottom:14px}
|
||
.cal-head h1{margin:0;color:var(--n)}.cal-head p{margin:5px 0 0;color:#68717a}
|
||
.cal-controls{display:flex;gap:7px;align-items:center;flex-wrap:wrap}.cal-controls .outline,.cal-controls .primary{padding:8px 11px}
|
||
.cal-mode{display:flex;border:1px solid var(--l);border-radius:7px;overflow:hidden;background:#fff}.cal-mode button{border:0;border-right:1px solid var(--l);background:#fff;padding:8px 11px;color:#68717a}.cal-mode button:last-child{border-right:0}.cal-mode button.on{background:var(--n);color:#fff}
|
||
.cal-period-title{font-size:18px;font-weight:700;color:var(--n);min-width:180px;text-align:center}
|
||
.cal-metrics{display:grid;grid-template-columns:repeat(4,minmax(130px,1fr));gap:9px;margin:0 0 14px}.cal-metric{border:1px solid var(--l);border-radius:9px;padding:11px;background:#fafafa}.cal-metric small{display:block;color:#68717a;margin-bottom:5px}.cal-metric b{font-size:19px;color:var(--n)}
|
||
.cal-month{display:grid;grid-template-columns:repeat(7,minmax(120px,1fr));border:1px solid var(--l);border-radius:10px;overflow:hidden;background:#fff;min-width:850px}
|
||
.cal-weekday{padding:9px;text-align:center;font-weight:700;color:#68717a;background:#f7f8f8;border-right:1px solid var(--l);border-bottom:1px solid var(--l)}.cal-weekday:nth-child(7){border-right:0}
|
||
.cal-day{min-height:142px;padding:8px;border-right:1px solid var(--l);border-bottom:1px solid var(--l);background:#fff;overflow:hidden}.cal-day:nth-child(7n){border-right:0}.cal-day.outside{background:#fafafa;color:#a7adb1}.cal-day.today{box-shadow:inset 0 0 0 2px var(--g)}
|
||
.cal-date{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;font-weight:700}.cal-count{font-size:10px;color:#68717a;background:#eef1f2;border-radius:999px;padding:2px 6px}
|
||
.cal-event{display:block;width:100%;border:0;border-left:4px solid #c9d0d8;border-radius:5px;background:#f7f8f8;padding:6px 7px;margin:5px 0;text-align:left;font-size:11px;line-height:1.2;color:#203040}.cal-event:hover{filter:brightness(.98)}.cal-event b{display:block;font-size:11px}.cal-event small{display:block;color:#68717a;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.cal-more{font-size:11px;color:#68717a;padding-top:3px}
|
||
.cal-scroll{overflow-x:auto;-webkit-overflow-scrolling:touch;padding-bottom:3px}
|
||
.cal-agenda{display:grid;gap:9px}.cal-agenda-day{border:1px solid var(--l);border-radius:9px;background:#fff;overflow:hidden}.cal-agenda-date{padding:10px 12px;background:#fafafa;border-bottom:1px solid var(--l);font-weight:700;color:var(--n)}
|
||
.cal-agenda-order{display:grid;grid-template-columns:75px minmax(0,1fr) 120px 115px;gap:8px;align-items:center;padding:10px 12px;border-bottom:1px solid #e7e9ea}.cal-agenda-order:last-child{border-bottom:0}.cal-agenda-order .time{font-weight:700;color:var(--n)}.cal-agenda-order small{display:block;color:#68717a;margin-top:2px}.cal-agenda-order .amount{text-align:right;font-weight:700}.cal-status{display:inline-block;padding:3px 7px;border-radius:999px;font-size:10px;margin-top:4px;background:#eef2f4}
|
||
.cal-empty{padding:24px;text-align:center;color:#879198;border:1px dashed var(--l);border-radius:9px;background:#fff}
|
||
@media(max-width:900px){.cal-metrics{grid-template-columns:1fr 1fr}.cal-period-title{order:-1;width:100%;text-align:left}.cal-agenda-order{grid-template-columns:60px minmax(0,1fr) 95px}.cal-agenda-order button{display:none}}
|
||
@media(max-width:650px){.cal-controls{display:grid;grid-template-columns:auto auto 1fr;width:100%}.cal-controls .cal-mode{grid-column:1/-1}.cal-period-title{grid-column:1/-1}.cal-metrics{grid-template-columns:1fr 1fr}.cal-agenda-order{grid-template-columns:52px minmax(0,1fr)}.cal-agenda-order .amount,.cal-agenda-order button{display:none}.cal-month{min-width:760px}.cal-day{min-height:125px}.cal-print-btn{grid-column:1/-1}}
|
||
@media print{
|
||
body.sun-print-calendar *{visibility:hidden!important}
|
||
body.sun-print-calendar #calendar,body.sun-print-calendar #calendar *{visibility:visible!important}
|
||
body.sun-print-calendar #calendar{display:block!important;position:absolute!important;left:0!important;top:0!important;width:100%!important;margin:0!important;padding:8mm!important;min-height:0!important;background:#fff!important;box-shadow:none!important}
|
||
body.sun-print-calendar #calendar .cal-print-btn,body.sun-print-calendar #calendar #calPrev,body.sun-print-calendar #calendar #calToday,body.sun-print-calendar #calendar #calNext{display:none!important}
|
||
body.sun-print-calendar #calendar .cal-scroll{overflow:visible!important}
|
||
body.sun-print-calendar #calendar .cal-month{min-width:0!important;width:100%!important;grid-template-columns:repeat(7,minmax(0,1fr))!important}
|
||
body.sun-print-calendar #calendar .cal-day{min-height:32mm!important;padding:4px!important}
|
||
body.sun-print-calendar #calendar .cal-event{font-size:8pt!important;padding:3px 4px!important;margin:2px 0!important}
|
||
body.sun-print-calendar #calendar .cal-event b,body.sun-print-calendar #calendar .cal-event small{font-size:7.5pt!important}
|
||
body.sun-print-calendar #calendar .cal-agenda-order{break-inside:avoid}
|
||
@page{size:A4 landscape;margin:8mm}
|
||
}
|
||
`;
|
||
document.head.appendChild(css);
|
||
|
||
const button = document.createElement('button');
|
||
button.textContent = 'Календарь';
|
||
nav.appendChild(button);
|
||
|
||
const view = document.createElement('section');
|
||
view.id = 'calendar';
|
||
view.className = 'view page';
|
||
document.body.appendChild(view);
|
||
|
||
let mode = localStorage.getItem('sunCalendarMode') || 'month';
|
||
let cursor = new Date();
|
||
cursor.setHours(0,0,0,0);
|
||
|
||
const fmt = n => typeof money === 'function' ? money(n) : `${Number(n || 0).toLocaleString('ru-RU')} ₽`;
|
||
const total = o => typeof orderTotalValue === 'function' ? Number(orderTotalValue(o) || 0) : Number(o.total || 0);
|
||
const statusColors = { 'Новый':'#8aa7c2','В работе':'#b1a7cf','Собран':'#c99a32','Передан курьеру':'#d7b24c','Отдан заказчику':'#65a978','Отменён':'#c88787' };
|
||
const paymentDefaults={unpaid:'#d84a4a',prepaid:'#35a46b',paid:'#3b82f6',cancelled:'#9aa0aa'};
|
||
const paymentLabels={unpaid:'Без предоплаты',prepaid:'Предоплата внесена',paid:'Оплачен полностью',cancelled:'Отменён'};
|
||
const paymentState=o=>{
|
||
try{if(typeof window.sunOrderPaymentState==='function')return window.sunOrderPaymentState(o)}catch(_){}
|
||
if(String(o?.status||'').trim()==='Отменён')return'cancelled';
|
||
const paid=Math.max(0,Number(o?.prepayment||0)),sum=Math.max(0,total(o));
|
||
if(sum>0&&paid>=sum-0.5)return'paid';
|
||
if(paid>0)return'prepaid';
|
||
return'unpaid';
|
||
};
|
||
const paymentColors=()=>{try{return Object.assign({},paymentDefaults,typeof window.sunOrderPaymentColors==='function'?window.sunOrderPaymentColors():{})}catch(_){return {...paymentDefaults}}};
|
||
const paymentColor=o=>paymentColors()[paymentState(o)]||paymentDefaults.unpaid;
|
||
const tint=(color,alpha=.12)=>{const h=String(color||'').replace('#','');if(!/^[0-9a-f]{6}$/i.test(h))return'#f7f8f8';const r=parseInt(h.slice(0,2),16),g=parseInt(h.slice(2,4),16),b=parseInt(h.slice(4,6),16);return `rgba(${r},${g},${b},${alpha})`};
|
||
const pad = n => String(n).padStart(2,'0');
|
||
const iso = d => `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
|
||
const cloneDate = d => new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||
const startMonday = d => { const x=cloneDate(d), day=(x.getDay()+6)%7; x.setDate(x.getDate()-day); return x; };
|
||
const orderStamp = o => `${o.date || '9999-99-99'}T${o.time || '23:59'}`;
|
||
const liveOrders = () => orders.filter(o => (o.status || 'Новый') !== 'Отменён');
|
||
|
||
function openCalendarOrder(id){
|
||
if (typeof openOrder === 'function') openOrder(id);
|
||
const newBtn = [...nav.querySelectorAll('button')].find(b => b.textContent.trim() === 'Новый заказ');
|
||
[...nav.querySelectorAll('button')].forEach(b => b.classList.remove('on'));
|
||
if (newBtn) newBtn.classList.add('on');
|
||
}
|
||
window.calendarOpenOrder = openCalendarOrder;
|
||
|
||
function rangeForMode(){
|
||
if(mode==='day') { const s=cloneDate(cursor); return {start:s,end:s}; }
|
||
if(mode==='week') { const s=startMonday(cursor), e=cloneDate(s); e.setDate(e.getDate()+6); return {start:s,end:e}; }
|
||
const s=new Date(cursor.getFullYear(),cursor.getMonth(),1), e=new Date(cursor.getFullYear(),cursor.getMonth()+1,0); return {start:s,end:e};
|
||
}
|
||
function titleForMode(){
|
||
if(mode==='day') return cursor.toLocaleDateString('ru-RU',{day:'numeric',month:'long',year:'numeric'});
|
||
if(mode==='week') { const {start,end}=rangeForMode(); return `${start.toLocaleDateString('ru-RU',{day:'numeric',month:'short'})} — ${end.toLocaleDateString('ru-RU',{day:'numeric',month:'short',year:'numeric'})}`; }
|
||
return cursor.toLocaleDateString('ru-RU',{month:'long',year:'numeric'}).replace(/^./,c=>c.toUpperCase());
|
||
}
|
||
function inRange(o,start,end){ return (o.date || '') >= iso(start) && (o.date || '') <= iso(end); }
|
||
function ordersByDate(list){
|
||
return list.reduce((m,o)=>{ (m[o.date] ||= []).push(o); return m; },{});
|
||
}
|
||
function periodStats(list){
|
||
const revenue=list.reduce((s,o)=>s+total(o),0);
|
||
const paid=list.reduce((s,o)=>s+Math.min(total(o),Math.max(0,Number(o.prepayment||0))),0);
|
||
const debt=list.reduce((s,o)=>s+Math.max(0,total(o)-Math.max(0,Number(o.prepayment||0))),0);
|
||
return {revenue,paid,debt};
|
||
}
|
||
function eventHtml(o){
|
||
const state=paymentState(o),color=paymentColor(o),bg=tint(color,.13),label=paymentLabels[state]||'';
|
||
return `<button class="cal-event" data-payment-state="${state}" style="border-left-color:${color};background:${bg}" onclick="calendarOpenOrder(${o.id})" title="${o.event || 'Заказ'} · ${label}"><b>${o.time || '—'} · №${o.id}</b><small>${o.event || 'Заказ'}</small><small>${fmt(total(o))} · ${o.status || 'Новый'}</small></button>`;
|
||
}
|
||
function renderMonth(list){
|
||
const first=new Date(cursor.getFullYear(),cursor.getMonth(),1), gridStart=startMonday(first), today=iso(new Date());
|
||
const byDate=ordersByDate(liveOrders());
|
||
let cells='';
|
||
for(let i=0;i<42;i++){
|
||
const d=cloneDate(gridStart); d.setDate(d.getDate()+i); const key=iso(d); const dayOrders=(byDate[key]||[]).sort((a,b)=>orderStamp(a).localeCompare(orderStamp(b)));
|
||
const outside=d.getMonth()!==cursor.getMonth(), shown=dayOrders.slice(0,3);
|
||
cells += `<div class="cal-day ${outside?'outside':''} ${key===today?'today':''}"><div class="cal-date"><span>${d.getDate()}</span>${dayOrders.length?`<span class="cal-count">${dayOrders.length}</span>`:''}</div>${shown.map(eventHtml).join('')}${dayOrders.length>3?`<div class="cal-more">+ ещё ${dayOrders.length-3}</div>`:''}</div>`;
|
||
}
|
||
return `<div class="cal-scroll"><div class="cal-month">${['Пн','Вт','Ср','Чт','Пт','Сб','Вс'].map(x=>`<div class="cal-weekday">${x}</div>`).join('')}${cells}</div></div>`;
|
||
}
|
||
function renderAgenda(list){
|
||
if(!list.length) return '<div class="cal-empty">В выбранном периоде заказов нет.</div>';
|
||
const by=ordersByDate(list);
|
||
return `<div class="cal-agenda">${Object.keys(by).sort().map(dateKey=>{
|
||
const d=new Date(dateKey+'T00:00:00');
|
||
const label=d.toLocaleDateString('ru-RU',{weekday:'long',day:'numeric',month:'long'});
|
||
const rows=by[dateKey].sort((a,b)=>orderStamp(a).localeCompare(orderStamp(b))).map(o=>{const state=paymentState(o),color=paymentColor(o),bg=tint(color,.08);return `<div class="cal-agenda-order" data-payment-state="${state}" style="border-left:5px solid ${color};background:${bg}"><div class="time">${o.time||'—'}</div><div><b>№${o.id} · ${o.event||'Заказ'}</b><small>${o.contact||'Клиент не указан'} · ${o.address||'Без адреса'}</small><span class="cal-status" style="border-left:4px solid ${statusColors[o.status||'Новый']||'#c9d0d8'}">${o.status||'Новый'}</span></div><div class="amount">${fmt(total(o))}</div><button class="outline" onclick="calendarOpenOrder(${o.id})">Открыть</button></div>`}).join('');
|
||
return `<section class="cal-agenda-day"><div class="cal-agenda-date">${label}</div>${rows}</section>`;
|
||
}).join('')}</div>`;
|
||
}
|
||
function render(){
|
||
const {start,end}=rangeForMode();
|
||
const period=liveOrders().filter(o=>inRange(o,start,end)).sort((a,b)=>orderStamp(a).localeCompare(orderStamp(b)));
|
||
const stats=periodStats(period);
|
||
const today=iso(new Date());
|
||
const todayCount=liveOrders().filter(o=>o.date===today).length;
|
||
view.innerHTML=`<div class="cal-head"><div><h1>Календарь заказов</h1><p>Все мероприятия, суммы, статусы и загрузка по датам.</p></div><div class="cal-controls"><button class="outline" id="calPrev">‹</button><button class="outline" id="calToday">Сегодня</button><button class="outline" id="calNext">›</button><div class="cal-period-title">${titleForMode()}</div><div class="cal-mode"><button data-cal-mode="day" class="${mode==='day'?'on':''}">День</button><button data-cal-mode="week" class="${mode==='week'?'on':''}">Неделя</button><button data-cal-mode="month" class="${mode==='month'?'on':''}">Месяц</button></div><button class="outline cal-print-btn" id="calPrint" type="button">Печать</button></div></div>
|
||
<div class="cal-metrics"><div class="cal-metric"><small>Заказов в периоде</small><b>${period.length}</b></div><div class="cal-metric"><small>Сумма заказов</small><b>${fmt(stats.revenue)}</b></div><div class="cal-metric"><small>Предоплаты</small><b>${fmt(stats.paid)}</b></div><div class="cal-metric"><small>К получению</small><b>${fmt(stats.debt)}</b></div></div>
|
||
${mode==='month'?renderMonth(period):renderAgenda(period)}`;
|
||
document.getElementById('calPrev').onclick=()=>{ if(mode==='day')cursor.setDate(cursor.getDate()-1); else if(mode==='week')cursor.setDate(cursor.getDate()-7); else cursor.setMonth(cursor.getMonth()-1); render(); };
|
||
document.getElementById('calNext').onclick=()=>{ if(mode==='day')cursor.setDate(cursor.getDate()+1); else if(mode==='week')cursor.setDate(cursor.getDate()+7); else cursor.setMonth(cursor.getMonth()+1); render(); };
|
||
document.getElementById('calToday').onclick=()=>{ cursor=new Date(); cursor.setHours(0,0,0,0); render(); };
|
||
document.getElementById('calPrint').onclick=()=>{ document.body.classList.add('sun-print-calendar'); window.print(); setTimeout(()=>document.body.classList.remove('sun-print-calendar'),120); };
|
||
[...view.querySelectorAll('[data-cal-mode]')].forEach(b=>b.onclick=()=>{ mode=b.dataset.calMode; localStorage.setItem('sunCalendarMode',mode); render(); });
|
||
}
|
||
|
||
button.onclick=()=>{ document.querySelectorAll('.view').forEach(x=>x.classList.remove('on')); view.classList.add('on'); [...nav.querySelectorAll('button')].forEach(x=>x.classList.remove('on')); button.classList.add('on'); render(); };
|
||
|
||
const prevSave=window.saveOrder; window.saveOrder=()=>{ prevSave(); if(view.classList.contains('on')) render(); };
|
||
const prevStatus=window.status; window.status=(id,value)=>{ prevStatus(id,value); if(view.classList.contains('on')) render(); };
|
||
const prevDelete=window.deleteOrder; if(typeof prevDelete==='function') window.deleteOrder=(...args)=>{ const r=prevDelete(...args); if(view.classList.contains('on')) render(); return r; };
|
||
})();
|
||
|
||
/* ===== RESPONSIVE / MOBILE MODULE v1 ===== */
|
||
(() => {
|
||
if (document.getElementById('sun-responsive-style')) return;
|
||
|
||
const style = document.createElement('style');
|
||
style.id = 'sun-responsive-style';
|
||
style.textContent = `
|
||
html{scroll-behavior:smooth}
|
||
body{min-width:0;overflow-x:hidden}
|
||
header{position:sticky;top:0;z-index:80}
|
||
nav{min-width:0}
|
||
nav button{white-space:nowrap}
|
||
.page,.card,.dialog{min-width:0}
|
||
.tiles{align-items:stretch}
|
||
.tile{width:100%}
|
||
input,select,textarea{max-width:100%}
|
||
.dialog{overscroll-behavior:contain}
|
||
.mobile-only{display:none!important}
|
||
|
||
/* Tablet: keep the catalog useful without squeezing the order form. */
|
||
@media (min-width:901px) and (max-width:1180px){
|
||
.work{grid-template-columns:minmax(0,1fr) 170px}
|
||
.work>section.card{grid-column:1/-1}
|
||
.tiles{grid-template-columns:repeat(auto-fill,minmax(130px,1fr))}
|
||
header{gap:20px;padding-inline:16px}
|
||
nav button{padding-inline:10px}
|
||
.page{margin:12px}
|
||
}
|
||
|
||
@media (max-width:900px){
|
||
body{padding-bottom:74px}
|
||
header{position:sticky;top:0;z-index:90;display:flex;flex-direction:row;align-items:center;justify-content:space-between;height:62px;padding:8px 12px;gap:10px;box-shadow:0 2px 10px #102c3d22}
|
||
.brand{display:flex;align-items:center;font-size:18px;white-space:nowrap;min-width:0}
|
||
.brand .real-logo{width:38px!important;height:38px!important;margin-right:7px!important}
|
||
header nav{position:fixed;left:0;right:0;bottom:0;z-index:100;display:flex;align-items:stretch;overflow-x:auto;overflow-y:hidden;background:var(--n);height:68px;padding:0 max(6px,env(safe-area-inset-right)) env(safe-area-inset-bottom) max(6px,env(safe-area-inset-left));box-shadow:0 -2px 14px #102c3d2b;scrollbar-width:none;-webkit-overflow-scrolling:touch}
|
||
header nav::-webkit-scrollbar{display:none}
|
||
header nav button{flex:1 0 92px;height:62px;padding:5px 8px;border-bottom:0;border-top:3px solid transparent;font-size:12px;line-height:1.15;color:#dce8ec}
|
||
header nav button.on{border-top-color:var(--g);border-bottom-color:transparent;color:#fff;background:#ffffff0d}
|
||
.view.page{margin:10px;min-height:calc(100vh - 150px);padding:14px}
|
||
.work{display:flex!important;flex-direction:column;padding:10px;gap:10px;min-height:0}
|
||
.work>.cats{order:1;position:sticky;top:62px;z-index:50;display:flex;gap:7px;overflow-x:auto;padding:8px;background:#e9efe8;box-shadow:0 2px 6px #00000012;scrollbar-width:none;-webkit-overflow-scrolling:touch}
|
||
.work>.cats::-webkit-scrollbar{display:none}
|
||
.work>.cats button{flex:0 0 auto;width:auto;min-width:max-content;margin:0;padding:10px 13px}
|
||
.work>.cats p{display:none}
|
||
.work>.catalog{order:2}
|
||
.work>section.card{order:3}
|
||
.catalog-head{gap:10px;flex-wrap:wrap}
|
||
.catalog-head .outline{margin-left:auto}
|
||
.tiles{grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:10px}
|
||
.tile{min-height:132px;padding:6px;font-size:12px}
|
||
.tile img,.tile .ph{height:72px}
|
||
.catalog .tile.add{min-height:132px}
|
||
.form{grid-template-columns:1fr 1fr!important}
|
||
.form label:first-child{grid-column:1/-1}
|
||
.line{grid-template-columns:minmax(0,1fr) 34px 52px 34px;gap:4px}
|
||
.line button{height:34px}
|
||
.actions{flex-wrap:wrap}
|
||
.actions>button{min-height:42px}
|
||
.subnav{overflow-x:auto;white-space:nowrap;scrollbar-width:none}
|
||
.subnav::-webkit-scrollbar{display:none}
|
||
.modal{padding:10px;z-index:150}
|
||
.dialog{width:100%;max-height:calc(100dvh - 20px);border-radius:12px;padding:16px}
|
||
.manager{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||
.ingredient{grid-template-columns:minmax(0,1fr) 72px 66px 34px}
|
||
.prep{grid-template-columns:1fr}
|
||
.clients-toolbar{align-items:stretch;flex-direction:column}
|
||
.clients-toolbar input{width:100%}
|
||
.client-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||
.stock-toolbar{align-items:stretch!important;flex-direction:column!important}
|
||
.stock-toolbar input{width:100%!important}
|
||
.dash-actions{width:100%}
|
||
.dash-actions button{flex:1 1 130px}
|
||
button,.primary,.outline,.danger{touch-action:manipulation}
|
||
}
|
||
|
||
@media (max-width:640px){
|
||
body{font-size:14px;padding-bottom:70px}
|
||
header{height:58px;padding:7px 10px}
|
||
.brand{font-size:16px}
|
||
.brand .real-logo{width:34px!important;height:34px!important}
|
||
header nav{height:66px}
|
||
header nav button{flex-basis:86px;height:60px;font-size:11px;padding-inline:5px}
|
||
.view.page{margin:7px;padding:11px;border-radius:8px}
|
||
.work{padding:7px}
|
||
.work>.cats{top:58px;margin-inline:0;border-radius:7px}
|
||
.catalog h1,.page h1{font-size:20px}
|
||
.tiles{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||
.tile{min-height:142px;font-size:13px}
|
||
.tile img,.tile .ph{height:82px}
|
||
.catalog .tile.add{min-height:142px}
|
||
.form{grid-template-columns:1fr!important}
|
||
.form label:first-child{grid-column:auto}
|
||
.line{grid-template-columns:minmax(0,1fr) 32px 48px 32px;padding:9px 0}
|
||
.line span{font-size:13px;overflow-wrap:anywhere}
|
||
.line input{min-width:0}
|
||
.actions{display:grid;grid-template-columns:1fr 1fr;width:100%}
|
||
.actions>button{width:100%;padding:10px 8px}
|
||
.actions>button:only-child{grid-column:1/-1}
|
||
.manager{grid-template-columns:1fr}
|
||
.dialog{padding:14px;border-radius:10px}
|
||
.dialog-head{position:sticky;top:-14px;z-index:2;background:#fff;padding:10px 0 8px;margin-top:-10px}
|
||
.dialog-head h2{font-size:19px}
|
||
.ingredient{grid-template-columns:minmax(0,1fr) 66px 58px 32px;font-size:12px}
|
||
.ingredient input{padding:7px 5px}
|
||
.client-grid{grid-template-columns:1fr}
|
||
.client-summary,.stock-summary,.dash-metrics{grid-template-columns:1fr 1fr!important;gap:7px}
|
||
.client-summary .summary-box,.stock-summary>*,.dash-metric{padding:10px;min-height:0}
|
||
.client-summary b,.dash-metric b{font-size:18px!important}
|
||
.client-card .client-metrics{grid-template-columns:1fr 1fr}
|
||
.client-detail-grid{grid-template-columns:1fr}
|
||
.client-order{grid-template-columns:52px minmax(0,1fr) 82px!important}
|
||
.client-order .client-order-date,.client-order .client-order-status{display:none!important}
|
||
#orders .head{display:none}
|
||
#ordersList{display:grid;gap:8px}
|
||
#ordersList .row{display:grid!important;grid-template-columns:72px minmax(0,1fr)!important;gap:4px 8px;padding:11px;border:1px solid var(--l);border-radius:8px}
|
||
#ordersList .row>span:nth-child(1){grid-row:1/3;font-weight:700;color:var(--n)}
|
||
#ordersList .row>span:nth-child(2){font-weight:700}
|
||
#ordersList .row>span:nth-child(3){font-size:12px;color:#68717a}
|
||
#ordersList .row>span:nth-child(4){display:none!important}
|
||
#ordersList .row>select{grid-column:1/-1!important;width:100%;margin-top:5px}
|
||
.stock-table{display:block;overflow-x:auto;white-space:nowrap;-webkit-overflow-scrolling:touch}
|
||
.stock-table table,.stock-table{min-width:680px}
|
||
.move-row{grid-template-columns:1fr!important;gap:3px!important}
|
||
.move-row span:last-child{grid-column:auto!important}
|
||
.dash-head{margin-bottom:10px}
|
||
.dash-head p{font-size:13px}
|
||
.dash-actions{display:grid;grid-template-columns:1fr 1fr}
|
||
.dash-actions button:first-child{grid-column:1/-1}
|
||
.dash-metrics{margin:10px 0 12px}
|
||
.dash-grid{gap:10px}
|
||
.dash-panel-head{align-items:flex-start;padding:11px}
|
||
.dash-panel-head small{font-size:11px;text-align:right}
|
||
.dash-list{padding:0 10px}
|
||
.dash-order{grid-template-columns:52px minmax(0,1fr)!important;padding:10px 0}
|
||
.dash-order-money,.dash-order button{display:none!important}
|
||
.dash-mini-grid{gap:7px;padding:10px}
|
||
.dash-needs{padding-inline:10px}
|
||
.dash-need{grid-template-columns:minmax(0,1fr) 82px!important}
|
||
.dash-need .cost{display:none!important}
|
||
.dash-alert{margin:0 10px 10px}
|
||
.sun-doc{font-size:12px}
|
||
.sun-doc-table-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch}
|
||
.sun-doc table{min-width:600px}
|
||
.sun-doc-actions{display:grid!important;grid-template-columns:1fr 1fr}
|
||
.sun-doc-actions button{width:100%}
|
||
}
|
||
|
||
@media (max-width:390px){
|
||
.tiles{grid-template-columns:1fr 1fr}
|
||
.tile{min-height:130px}
|
||
.tile img,.tile .ph{height:68px}
|
||
.client-summary,.stock-summary,.dash-metrics{grid-template-columns:1fr!important}
|
||
.actions{grid-template-columns:1fr}
|
||
.actions>button{grid-column:auto!important}
|
||
header nav button{flex-basis:78px}
|
||
}
|
||
|
||
@media print{
|
||
body{padding-bottom:0!important;overflow:visible!important}
|
||
header nav{position:static!important}
|
||
}
|
||
`;
|
||
document.head.appendChild(style);
|
||
|
||
// Improve document tables on narrow screens without changing print output.
|
||
const wrapDocTables = () => {
|
||
document.querySelectorAll('.sun-doc table').forEach(table => {
|
||
if (table.parentElement?.classList.contains('sun-doc-table-wrap')) return;
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'sun-doc-table-wrap';
|
||
window.SunSafe.insertBefore(table.parentNode,wrap,table);
|
||
wrap.appendChild(table);
|
||
});
|
||
};
|
||
|
||
// Re-run only when the print/preview area changes; observing the whole app caused needless rescans.
|
||
let wrapQueued=false;
|
||
const observer = new MutationObserver(() => {if(wrapQueued)return;wrapQueued=true;requestAnimationFrame(()=>{wrapQueued=false;wrapDocTables();});});
|
||
const previewRoot=document.getElementById('preview');if(previewRoot)observer.observe(previewRoot,{childList:true,subtree:true});
|
||
wrapDocTables();
|
||
})();
|
||
|
||
/* ===== EVENT / PRODUCTION CARD MODULE v1 ===== */
|
||
(() => {
|
||
if (document.getElementById('sun-event-card-style')) return;
|
||
|
||
const detailsBox = document.getElementById('details');
|
||
if (!detailsBox) return;
|
||
|
||
const style = document.createElement('style');
|
||
style.id = 'sun-event-card-style';
|
||
style.textContent = `
|
||
.event-card-section{margin-top:18px;padding-top:16px;border-top:1px solid var(--l)}
|
||
.event-card-section h3{margin:0 0 12px;color:var(--n);font-size:17px}
|
||
.event-card-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px 12px}
|
||
.event-card-grid .wide{grid-column:1/-1}
|
||
.event-card-times{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px 12px;margin-top:10px}
|
||
.ops-notes{display:grid;grid-template-columns:1fr 1fr;gap:10px 12px;margin-top:10px}
|
||
.checklist-card{margin-top:16px;border:1px solid var(--l);border-radius:9px;background:#fafbfb;overflow:hidden}
|
||
.checklist-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:12px 14px;background:#f4f1e8;border-bottom:1px solid #e4dfd3}
|
||
.checklist-head h4{margin:0;color:var(--n);font-size:15px}.checklist-head b{color:var(--n)}
|
||
.checklist-progress{height:6px;background:#e5e7e8}.checklist-progress span{display:block;height:100%;background:var(--g);transition:width .2s ease}
|
||
.event-checklist{display:grid;grid-template-columns:1fr 1fr;gap:0;padding:4px 12px 10px}
|
||
.event-check{display:flex;flex-direction:row;align-items:center;gap:9px;padding:9px 6px;color:#203040;border-bottom:1px solid #eceeef;font-size:13px;cursor:pointer}
|
||
.event-check:nth-last-child(-n+2){border-bottom:0}.event-check input{width:18px;height:18px;accent-color:#c99a32;padding:0;flex:0 0 auto}
|
||
.event-check.done span{text-decoration:line-through;color:#879198}
|
||
.ops-status-pill{display:inline-flex;align-items:center;padding:3px 7px;border-radius:999px;font-size:10px;font-weight:700;margin-top:5px;background:#eef2f4;color:#50606b}
|
||
.ops-status-pill.complete{background:#eaf6ed;color:#39744a}
|
||
.ops-summary{margin-top:12px;display:grid;grid-template-columns:repeat(3,1fr);gap:8px}.ops-summary div{padding:9px;border-radius:7px;background:#f7f8f8}.ops-summary small{display:block;color:#7c858b}.ops-summary b{display:block;margin-top:3px;color:var(--n)}
|
||
@media(max-width:700px){.event-card-grid,.event-card-times,.ops-notes,.event-checklist{grid-template-columns:1fr}.event-check:nth-last-child(-n+2){border-bottom:1px solid #eceeef}.event-check:last-child{border-bottom:0}.ops-summary{grid-template-columns:1fr}}
|
||
`;
|
||
document.head.appendChild(style);
|
||
|
||
const section = document.createElement('div');
|
||
section.className = 'event-card-section';
|
||
section.innerHTML = `
|
||
<h3>Карточка мероприятия</h3>
|
||
<div class="event-card-grid">
|
||
<label>Количество гостей<input id="eventGuests" type="number" min="0" step="1" placeholder="Например, 25"></label>
|
||
<label>Ответственный<input id="eventResponsible" list="responsiblePeople" placeholder="Имя сотрудника"><datalist id="responsiblePeople"></datalist></label>
|
||
</div>
|
||
<div class="event-card-times">
|
||
<label>Начало приготовления<input id="eventPrepTime" type="time"></label>
|
||
<label>Время выезда<input id="eventDepartureTime" type="time"></label>
|
||
<label>Время подачи<input id="eventServingTime" type="time"></label>
|
||
</div>
|
||
<div class="ops-notes">
|
||
<label>Комментарий кухне<textarea id="eventKitchenNote" rows="4" placeholder="Особые требования, аллергии, подача, упаковка..."></textarea></label>
|
||
<label>Комментарий курьеру<textarea id="eventCourierNote" rows="4" placeholder="Контакт на месте, парковка, вход, этаж, ориентиры..."></textarea></label>
|
||
</div>
|
||
<div class="checklist-card">
|
||
<div class="checklist-head"><h4>Чек-лист готовности</h4><b id="eventChecklistText">0 / 7</b></div>
|
||
<div class="checklist-progress"><span id="eventChecklistBar" style="width:0%"></span></div>
|
||
<div class="event-checklist" id="eventChecklist">
|
||
<label class="event-check"><input type="checkbox" data-check="client"><span>Клиент, дата и адрес подтверждены</span></label>
|
||
<label class="event-check"><input type="checkbox" data-check="purchase"><span>Продукты и закупка готовы</span></label>
|
||
<label class="event-check"><input type="checkbox" data-check="kitchen"><span>Кухня завершила приготовление</span></label>
|
||
<label class="event-check"><input type="checkbox" data-check="packing"><span>Заказ упакован и промаркирован</span></label>
|
||
<label class="event-check"><input type="checkbox" data-check="equipment"><span>Посуда и дополнительный инвентарь собраны</span></label>
|
||
<label class="event-check"><input type="checkbox" data-check="courier"><span>Курьер / транспорт подтверждён</span></label>
|
||
<label class="event-check"><input type="checkbox" data-check="final"><span>Финальная проверка заказа выполнена</span></label>
|
||
</div>
|
||
</div>
|
||
<div class="ops-summary" id="eventOpsSummary"></div>
|
||
`;
|
||
detailsBox.appendChild(section);
|
||
|
||
const checklistKeys = ['client','purchase','kitchen','packing','equipment','courier','final'];
|
||
const $ = id => document.getElementById(id);
|
||
const getChecklist = source => {
|
||
const obj = source && typeof source === 'object' ? source : {};
|
||
return Object.fromEntries(checklistKeys.map(k => [k, Boolean(obj[k])]));
|
||
};
|
||
const countChecklist = obj => checklistKeys.filter(k => obj?.[k]).length;
|
||
|
||
function timeText(v){ return v || '—'; }
|
||
function refreshResponsibleList(){
|
||
const names = [...new Set(orders.map(o => (o.responsible || '').trim()).filter(Boolean))].sort((a,b)=>a.localeCompare(b,'ru'));
|
||
$('responsiblePeople').innerHTML = names.map(name => `<option value="${String(name).replace(/"/g,'"')}"></option>`).join('');
|
||
}
|
||
function readFormIntoDraft(){
|
||
draft.guestsCount = Math.max(0, Number($('eventGuests').value || 0));
|
||
draft.responsible = $('eventResponsible').value.trim();
|
||
draft.prepTime = $('eventPrepTime').value || '';
|
||
draft.departureTime = $('eventDepartureTime').value || '';
|
||
draft.servingTime = $('eventServingTime').value || '';
|
||
draft.kitchenNote = $('eventKitchenNote').value.trim();
|
||
draft.courierNote = $('eventCourierNote').value.trim();
|
||
draft.checklist = getChecklist(Object.fromEntries([...document.querySelectorAll('#eventChecklist [data-check]')].map(input => [input.dataset.check, input.checked])));
|
||
}
|
||
function fillFormFromDraft(){
|
||
$('eventGuests').value = Number(draft.guestsCount || 0) || '';
|
||
$('eventResponsible').value = draft.responsible || '';
|
||
$('eventPrepTime').value = draft.prepTime || '';
|
||
$('eventDepartureTime').value = draft.departureTime || '';
|
||
$('eventServingTime').value = draft.servingTime || '';
|
||
$('eventKitchenNote').value = draft.kitchenNote || '';
|
||
$('eventCourierNote').value = draft.courierNote || '';
|
||
const state = getChecklist(draft.checklist);
|
||
document.querySelectorAll('#eventChecklist [data-check]').forEach(input => input.checked = Boolean(state[input.dataset.check]));
|
||
refreshResponsibleList();
|
||
renderChecklistProgress();
|
||
}
|
||
function renderChecklistProgress(){
|
||
const checks = [...document.querySelectorAll('#eventChecklist [data-check]')];
|
||
const done = checks.filter(x => x.checked).length;
|
||
const pct = Math.round(done / checklistKeys.length * 100);
|
||
checks.forEach(input => input.closest('.event-check')?.classList.toggle('done', input.checked));
|
||
$('eventChecklistText').textContent = `${done} / ${checklistKeys.length}`;
|
||
$('eventChecklistBar').style.width = `${pct}%`;
|
||
$('eventOpsSummary').innerHTML = `
|
||
<div><small>Гостей</small><b>${Math.max(0,Number($('eventGuests').value||0)) || '—'}</b></div>
|
||
<div><small>Ответственный</small><b>${$('eventResponsible').value.trim() || '—'}</b></div>
|
||
<div><small>Готовность</small><b>${pct}%</b></div>`;
|
||
}
|
||
|
||
document.querySelectorAll('#eventChecklist [data-check]').forEach(input => input.addEventListener('change', () => { readFormIntoDraft(); renderChecklistProgress(); }));
|
||
['eventGuests','eventResponsible','eventPrepTime','eventDepartureTime','eventServingTime','eventKitchenNote','eventCourierNote'].forEach(id => $(id).addEventListener('input', () => { readFormIntoDraft(); renderChecklistProgress(); }));
|
||
|
||
// Save operational fields together with the order before all existing save hooks run.
|
||
const previousSaveOrder = window.saveOrder;
|
||
window.saveOrder = () => {
|
||
readFormIntoDraft();
|
||
previousSaveOrder();
|
||
refreshResponsibleList();
|
||
decorateOrderRows();
|
||
};
|
||
|
||
// Existing openOrder populates the standard order fields first; then we populate the event card.
|
||
const previousOpenOrder = window.openOrder;
|
||
window.openOrder = id => {
|
||
previousOpenOrder(id);
|
||
fillFormFromDraft();
|
||
};
|
||
|
||
if (typeof window.resetDraft === 'function') {
|
||
const previousResetDraft = window.resetDraft;
|
||
window.resetDraft = () => {
|
||
previousResetDraft();
|
||
fillFormFromDraft();
|
||
};
|
||
}
|
||
|
||
function readiness(order){
|
||
const done = countChecklist(getChecklist(order?.checklist));
|
||
return {done,total:checklistKeys.length,pct:Math.round(done/checklistKeys.length*100)};
|
||
}
|
||
function decorateOrderRows(){
|
||
const rows = [...document.querySelectorAll('#ordersList .row')];
|
||
rows.forEach((row,index) => {
|
||
const order = orders[index];
|
||
if (!order) return;
|
||
row.querySelectorAll('.ops-status-pill').forEach(x=>x.remove());
|
||
const eventCell = row.children[1];
|
||
if (!eventCell) return;
|
||
const r = readiness(order);
|
||
const extra = document.createElement('small');
|
||
extra.className = `ops-status-pill ${r.pct === 100 ? 'complete' : ''}`;
|
||
const parts = [`готовность ${r.pct}%`];
|
||
if (order.guestsCount) parts.push(`${order.guestsCount} гостей`);
|
||
if (order.responsible) parts.push(order.responsible);
|
||
extra.textContent = parts.join(' · ');
|
||
eventCell.appendChild(extra);
|
||
});
|
||
}
|
||
|
||
const previousRenderOrders = window.renderOrders;
|
||
window.renderOrders = () => { previousRenderOrders(); decorateOrderRows(); };
|
||
|
||
// Add compact operational information to printed order documents without changing receipt logic.
|
||
const actions = [...document.querySelectorAll('#new .actions')].find(a=>[...a.querySelectorAll('button')].some(b=>b.textContent.trim()==='Сохранить'));
|
||
if (actions && !document.getElementById('openEventCardBtn')) {
|
||
const btn = document.createElement('button');
|
||
btn.id = 'openEventCardBtn';
|
||
btn.className = 'outline';
|
||
btn.type = 'button';
|
||
btn.textContent = 'Карточка мероприятия';
|
||
btn.onclick = () => { if (typeof details === 'function') details(true); section.scrollIntoView({behavior:'smooth',block:'start'}); };
|
||
actions.insertBefore(btn, actions.lastElementChild || null);
|
||
}
|
||
|
||
fillFormFromDraft();
|
||
decorateOrderRows();
|
||
})();
|
||
|
||
/* ===== TEAM / STAFF ASSIGNMENTS MODULE v1 ===== */
|
||
(() => {
|
||
if (document.getElementById('sun-team-style')) return;
|
||
const nav = document.querySelector('nav');
|
||
const detailsBox = document.getElementById('details');
|
||
if (!nav || !detailsBox) return;
|
||
|
||
const STORAGE_KEY = 'sunEmployees';
|
||
const DEFAULT_ROLES = ['Шеф-повар','Повар','Помощник повара','Комплектовщик','Курьер','Официант','Менеджер'];
|
||
let employees = [];
|
||
try { employees = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); } catch(e) { employees = []; }
|
||
if (!Array.isArray(employees)) employees = [];
|
||
employees = employees.map(e => ({
|
||
id: String(e.id || (crypto.randomUUID ? sunUUID() : Date.now()+Math.random())),
|
||
name: String(e.name || '').trim(),
|
||
role: String(e.role || 'Сотрудник').trim(),
|
||
phone: String(e.phone || '').trim(),
|
||
active: e.active !== false,
|
||
note: String(e.note || '').trim()
|
||
})).filter(e => e.name);
|
||
|
||
const persistEmployees = () => localStorage.setItem(STORAGE_KEY, JSON.stringify(employees));
|
||
const esc=window.SunSafe.escapeHTML;
|
||
const fmtMoney = v => (typeof money === 'function' ? money(v) : `${Math.round(Number(v||0)).toLocaleString('ru-RU')} ₽`);
|
||
const orderTotalForTeam = o => Number(o?.total || 0) || (o?.lines || []).reduce((s,l)=>{
|
||
const b = boxes.find(x=>String(x.id)===String(l.id));
|
||
return s + Number(b?.price||0) * Number(l.qty||0);
|
||
},0);
|
||
const orderStamp = o => `${o?.date || ''}T${o?.time || '00:00'}`;
|
||
const activeOrder = o => !['Отменён','Отдан заказчику'].includes(o?.status || 'Новый');
|
||
|
||
const style = document.createElement('style');
|
||
style.id = 'sun-team-style';
|
||
style.textContent = `
|
||
.team-toolbar{display:flex;justify-content:space-between;align-items:center;gap:10px;margin:14px 0;flex-wrap:wrap}
|
||
.team-summary{display:grid;grid-template-columns:repeat(4,minmax(120px,1fr));gap:10px;margin:12px 0 18px}
|
||
.team-summary>div{background:#f7f8f8;border-radius:9px;padding:12px}.team-summary small{display:block;color:#7c858b}.team-summary b{display:block;color:var(--n);font-size:20px;margin-top:4px}
|
||
.team-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px}
|
||
.team-card{border:1px solid var(--l);border-radius:10px;background:#fff;padding:14px}.team-card.inactive{opacity:.58}.team-card h3{margin:0;color:var(--n);font-size:17px}.team-card p{margin:5px 0;color:#68717a}.team-card .role{display:inline-flex;padding:4px 8px;border-radius:999px;background:#f4f1e8;color:#7a632c;font-size:11px;font-weight:700;margin-top:6px}.team-card .load{margin:10px 0;padding:8px;border-radius:7px;background:#f7f8f8}.team-card .load b{color:var(--n)}
|
||
.team-card .actions{display:flex;flex-wrap:wrap}.team-schedule{margin-top:22px}.team-schedule h2{font-size:19px;color:var(--n);margin:0 0 10px}.team-schedule-row{display:grid;grid-template-columns:150px 1fr 100px;gap:10px;padding:10px 0;border-bottom:1px solid #e6e8e9;align-items:start}.team-schedule-row .events{display:flex;gap:6px;flex-wrap:wrap}.team-shift{border:1px solid #d9dde0;border-radius:7px;background:#fafbfb;padding:6px 8px;font-size:12px;text-align:left}.team-shift b{display:block;color:var(--n)}.team-conflict{display:inline-flex;margin-top:3px;padding:2px 6px;border-radius:999px;background:#fbeeee;color:#9c3d3d;font-size:10px;font-weight:700}
|
||
.staff-assignment{margin-top:16px;border:1px solid var(--l);border-radius:9px;overflow:hidden;background:#fff}.staff-assignment-head{padding:11px 13px;background:#eef2f4;display:flex;justify-content:space-between;gap:10px;align-items:center}.staff-assignment-head h4{margin:0;color:var(--n);font-size:15px}.staff-assignment-list{padding:4px 12px 10px;display:grid;grid-template-columns:1fr 1fr;gap:0 14px}.staff-choice{display:flex;flex-direction:row;align-items:center;gap:9px;padding:9px 5px;border-bottom:1px solid #edf0f1;color:#203040;cursor:pointer}.staff-choice input{width:18px;height:18px;accent-color:#c99a32;flex:0 0 auto}.staff-choice span{min-width:0}.staff-choice small{display:block;color:#7b858b;margin-top:2px}.staff-empty{padding:14px;color:#879198}.staff-assignment-meta{font-size:12px;color:#68717a}.staff-assignment-meta.warn{color:#9c3d3d;font-weight:700}
|
||
.team-modal-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}.team-modal-grid .wide{grid-column:1/-1}.team-active-label{display:flex;flex-direction:row;align-items:center;gap:8px;margin-top:12px}.team-active-label input{width:18px;height:18px}
|
||
@media(max-width:760px){.team-summary{grid-template-columns:1fr 1fr}.team-schedule-row{grid-template-columns:1fr}.team-schedule-row>div:last-child{text-align:left!important}.staff-assignment-list{grid-template-columns:1fr}.team-modal-grid{grid-template-columns:1fr}.team-modal-grid .wide{grid-column:auto}}
|
||
`;
|
||
document.head.appendChild(style);
|
||
|
||
// Navigation and team page.
|
||
const teamBtn = document.createElement('button');
|
||
teamBtn.type = 'button';
|
||
teamBtn.textContent = 'Команда';
|
||
nav.appendChild(teamBtn);
|
||
|
||
const teamView = document.createElement('section');
|
||
teamView.id = 'team';
|
||
teamView.className = 'view page';
|
||
teamView.innerHTML = `
|
||
<h1>Команда</h1>
|
||
<div class="team-toolbar">
|
||
<div><b>Сотрудники и загрузка</b><div class="hint">Назначайте сотрудников на мероприятия и контролируйте пересечения.</div></div>
|
||
<button class="primary" type="button" id="teamAddBtn">Добавить сотрудника</button>
|
||
</div>
|
||
<div class="team-summary" id="teamSummary"></div>
|
||
<div class="team-grid" id="teamGrid"></div>
|
||
<div class="team-schedule"><h2>Загрузка на ближайшие 14 дней</h2><div id="teamSchedule"></div></div>`;
|
||
document.body.appendChild(teamView);
|
||
|
||
teamBtn.onclick = () => {
|
||
document.querySelectorAll('.view').forEach(v=>v.classList.remove('on'));
|
||
teamView.classList.add('on');
|
||
[...nav.querySelectorAll('button')].forEach(b=>b.classList.remove('on'));
|
||
teamBtn.classList.add('on');
|
||
renderTeam();
|
||
};
|
||
|
||
// Employee editor modal.
|
||
const modalEl = document.createElement('div');
|
||
modalEl.id = 'teamEditor';
|
||
modalEl.className = 'modal';
|
||
modalEl.innerHTML = `<div class="dialog" style="width:min(620px,100%)">
|
||
<div class="dialog-head"><h2 id="teamEditorTitle">Сотрудник</h2><button type="button" id="teamEditorClose">×</button></div>
|
||
<div class="team-modal-grid">
|
||
<label>Имя<input id="teamName" placeholder="Имя сотрудника"></label>
|
||
<label>Роль<input id="teamRole" list="teamRoles" placeholder="Например, повар"><datalist id="teamRoles">${DEFAULT_ROLES.map(r=>`<option value="${esc(r)}"></option>`).join('')}</datalist></label>
|
||
<label>Телефон<input id="teamPhone" placeholder="+7 ..."></label>
|
||
<label class="wide">Комментарий<textarea id="teamNote" rows="3" placeholder="Навыки, график, особенности..."></textarea></label>
|
||
</div>
|
||
<label class="team-active-label"><input id="teamActive" type="checkbox" checked> Активный сотрудник</label>
|
||
<div class="actions"><button class="primary" type="button" id="teamSave">Сохранить</button><button class="danger" type="button" id="teamDelete" style="display:none">Удалить</button></div>
|
||
</div>`;
|
||
document.body.appendChild(modalEl);
|
||
let editingEmployeeId = null;
|
||
const el = id => document.getElementById(id);
|
||
el('teamEditorClose').onclick = () => modalEl.classList.remove('on');
|
||
modalEl.addEventListener('click', e => { if (e.target === modalEl) modalEl.classList.remove('on'); });
|
||
|
||
function openEmployee(id=null){
|
||
const emp = id ? employees.find(x=>x.id===String(id)) : null;
|
||
editingEmployeeId = emp?.id || null;
|
||
el('teamEditorTitle').textContent = emp ? 'Изменить сотрудника' : 'Новый сотрудник';
|
||
el('teamName').value = emp?.name || '';
|
||
el('teamRole').value = emp?.role || '';
|
||
el('teamPhone').value = emp?.phone || '';
|
||
el('teamNote').value = emp?.note || '';
|
||
el('teamActive').checked = emp ? emp.active !== false : true;
|
||
el('teamDelete').style.display = emp ? 'inline-block' : 'none';
|
||
modalEl.classList.add('on');
|
||
}
|
||
window.sunEditEmployee = openEmployee;
|
||
el('teamAddBtn').onclick = () => openEmployee();
|
||
el('teamSave').onclick = () => {
|
||
const name = el('teamName').value.trim();
|
||
if (!name) return alert('Введите имя сотрудника.');
|
||
const record = {
|
||
id: editingEmployeeId || (crypto.randomUUID ? sunUUID() : String(Date.now())),
|
||
name,
|
||
role: el('teamRole').value.trim() || 'Сотрудник',
|
||
phone: el('teamPhone').value.trim(),
|
||
note: el('teamNote').value.trim(),
|
||
active: el('teamActive').checked
|
||
};
|
||
const idx = employees.findIndex(x=>x.id===record.id);
|
||
if (idx >= 0) employees[idx] = record; else employees.push(record);
|
||
employees.sort((a,b)=>a.name.localeCompare(b.name,'ru'));
|
||
persistEmployees();
|
||
modalEl.classList.remove('on');
|
||
renderTeam();
|
||
renderStaffAssignments();
|
||
refreshResponsibleDatalistFromTeam();
|
||
};
|
||
el('teamDelete').onclick = () => {
|
||
const emp = employees.find(x=>x.id===editingEmployeeId);
|
||
if (!emp || !confirm(`Удалить сотрудника «${emp.name}»? Назначения в старых заказах будут сняты.`)) return;
|
||
employees = employees.filter(x=>x.id!==editingEmployeeId);
|
||
orders.forEach(o => { if (Array.isArray(o.staffIds)) o.staffIds = o.staffIds.filter(id=>String(id)!==editingEmployeeId); });
|
||
if (Array.isArray(draft.staffIds)) draft.staffIds = draft.staffIds.filter(id=>String(id)!==editingEmployeeId);
|
||
persistEmployees();
|
||
if (typeof persist === 'function') persist();
|
||
modalEl.classList.remove('on');
|
||
renderTeam();
|
||
renderStaffAssignments();
|
||
refreshResponsibleDatalistFromTeam();
|
||
};
|
||
|
||
function assignedOrders(empId){
|
||
return orders.filter(o => activeOrder(o) && Array.isArray(o.staffIds) && o.staffIds.map(String).includes(String(empId)));
|
||
}
|
||
function conflictCount(empId){
|
||
const list = assignedOrders(empId);
|
||
const groups = {};
|
||
list.forEach(o => { const key = `${o.date||''}|${o.time||''}`; groups[key]=(groups[key]||0)+1; });
|
||
return Object.values(groups).filter(n=>n>1).reduce((s,n)=>s+(n-1),0);
|
||
}
|
||
function openTeamOrder(id){ if (typeof window.openOrder === 'function') window.openOrder(id); }
|
||
window.openTeamOrder = openTeamOrder;
|
||
|
||
function renderTeam(){
|
||
const now = new Date(); now.setHours(0,0,0,0);
|
||
const until = new Date(now); until.setDate(until.getDate()+14);
|
||
const upcoming = orders.filter(o=>{
|
||
if (!activeOrder(o) || !o.date) return false;
|
||
const d = new Date(`${o.date}T00:00:00`);
|
||
return d >= now && d < until;
|
||
});
|
||
const activeCount = employees.filter(e=>e.active).length;
|
||
const assignedSet = new Set(upcoming.flatMap(o=>Array.isArray(o.staffIds)?o.staffIds.map(String):[]));
|
||
const conflicts = employees.reduce((s,e)=>s+conflictCount(e.id),0);
|
||
el('teamSummary').innerHTML = `
|
||
<div><small>Сотрудников</small><b>${employees.length}</b></div>
|
||
<div><small>Активных</small><b>${activeCount}</b></div>
|
||
<div><small>Задействованы 14 дней</small><b>${assignedSet.size}</b></div>
|
||
<div><small>Пересечений</small><b>${conflicts}</b></div>`;
|
||
|
||
el('teamGrid').innerHTML = employees.length ? employees.map(emp=>{
|
||
const list = assignedOrders(emp.id).sort((a,b)=>orderStamp(a).localeCompare(orderStamp(b)));
|
||
const future = list.filter(o=>o.date && new Date(`${o.date}T23:59:59`) >= now).slice(0,3);
|
||
const conflicts = conflictCount(emp.id);
|
||
return `<div class="team-card ${emp.active?'':'inactive'}">
|
||
<h3>${esc(emp.name)}</h3><span class="role">${esc(emp.role)}</span>
|
||
<p>${emp.phone ? esc(emp.phone) : 'Телефон не указан'}</p>
|
||
<div class="load"><b>${future.length}</b> ближайших назначений${conflicts?`<div class="team-conflict">⚠ пересечений: ${conflicts}</div>`:''}</div>
|
||
${future.map(o=>`<button class="team-shift" type="button" onclick="openTeamOrder(${Number(o.id)})"><b>${esc(o.date)} · ${esc(o.time||'—')}</b>${esc(o.event||'Заказ')}</button>`).join('') || '<p class="hint">Нет ближайших назначений.</p>'}
|
||
<div class="actions"><button class="outline" type="button" onclick="sunEditEmployee('${esc(emp.id)}')">Изменить</button></div>
|
||
</div>`;
|
||
}).join('') : '<p class="empty">Сотрудников пока нет. Добавьте первого сотрудника.</p>';
|
||
|
||
const byDate = {};
|
||
upcoming.sort((a,b)=>orderStamp(a).localeCompare(orderStamp(b))).forEach(o=>{ (byDate[o.date] ||= []).push(o); });
|
||
el('teamSchedule').innerHTML = Object.keys(byDate).length ? Object.entries(byDate).map(([dateKey,list])=>{
|
||
const shifts = list.map(o=>{
|
||
const staff = (Array.isArray(o.staffIds)?o.staffIds:[]).map(id=>employees.find(e=>e.id===String(id))).filter(Boolean);
|
||
return `<button class="team-shift" type="button" onclick="openTeamOrder(${Number(o.id)})"><b>${esc(o.time||'—')} · №${esc(o.id)}</b>${esc(o.event||'Заказ')}<br><small>${staff.length ? staff.map(s=>esc(s.name)).join(', ') : 'Команда не назначена'}</small></button>`;
|
||
}).join('');
|
||
return `<div class="team-schedule-row"><div><b>${esc(dateKey)}</b><div class="hint">${list.length} мероприятий</div></div><div class="events">${shifts}</div><div style="text-align:right"><b>${fmtMoney(list.reduce((s,o)=>s+orderTotalForTeam(o),0))}</b></div></div>`;
|
||
}).join('') : '<p class="empty">На ближайшие 14 дней активных мероприятий нет.</p>';
|
||
}
|
||
|
||
// Assignment block inside event card.
|
||
const eventSection = detailsBox.querySelector('.event-card-section');
|
||
const assignment = document.createElement('div');
|
||
assignment.className = 'staff-assignment';
|
||
assignment.innerHTML = `<div class="staff-assignment-head"><h4>Команда на мероприятие</h4><span class="staff-assignment-meta" id="staffAssignmentMeta">0 назначено</span></div><div id="staffAssignmentList" class="staff-assignment-list"></div>`;
|
||
if (eventSection) {
|
||
const notes = eventSection.querySelector('.ops-notes');
|
||
if (notes) notes.insertAdjacentElement('afterend', assignment); else eventSection.appendChild(assignment);
|
||
} else detailsBox.appendChild(assignment);
|
||
|
||
function currentSelectedIds(){
|
||
return [...document.querySelectorAll('#staffAssignmentList [data-staff-id]:checked')].map(x=>String(x.dataset.staffId));
|
||
}
|
||
function readStaffIntoDraft(){ draft.staffIds = currentSelectedIds(); }
|
||
function currentDraftStamp(){ return `${date?.value || draft.date || ''}|${time?.value || draft.time || ''}`; }
|
||
function employeeHasConflict(empId){
|
||
const stamp = currentDraftStamp();
|
||
if (!stamp || stamp.startsWith('|')) return false;
|
||
return orders.some(o => o.id != draft.id && activeOrder(o) && `${o.date||''}|${o.time||''}` === stamp && Array.isArray(o.staffIds) && o.staffIds.map(String).includes(String(empId)));
|
||
}
|
||
function renderStaffAssignments(){
|
||
const list = el('staffAssignmentList');
|
||
if (!list) return;
|
||
const selected = new Set((Array.isArray(draft.staffIds)?draft.staffIds:[]).map(String));
|
||
const active = employees.filter(e=>e.active || selected.has(e.id));
|
||
list.innerHTML = active.length ? active.map(emp=>{
|
||
const conflict = employeeHasConflict(emp.id);
|
||
return `<label class="staff-choice"><input type="checkbox" data-staff-id="${esc(emp.id)}" ${selected.has(emp.id)?'checked':''}><span><b>${esc(emp.name)}</b><small>${esc(emp.role)}${conflict?' · ⚠ уже назначен на это время':''}</small></span></label>`;
|
||
}).join('') : '<div class="staff-empty">Сначала добавьте сотрудников в разделе «Команда».</div>';
|
||
list.querySelectorAll('[data-staff-id]').forEach(input=>input.addEventListener('change',()=>{ readStaffIntoDraft(); updateStaffMeta(); }));
|
||
updateStaffMeta();
|
||
}
|
||
function updateStaffMeta(){
|
||
const meta = el('staffAssignmentMeta'); if (!meta) return;
|
||
const ids = currentSelectedIds();
|
||
const conflicts = ids.filter(employeeHasConflict).length;
|
||
meta.textContent = `${ids.length} назначено${conflicts ? ` · ⚠ конфликтов ${conflicts}` : ''}`;
|
||
meta.classList.toggle('warn', conflicts>0);
|
||
}
|
||
|
||
function refreshResponsibleDatalistFromTeam(){
|
||
const dl = document.getElementById('responsiblePeople');
|
||
if (!dl) return;
|
||
const names = new Set(employees.filter(e=>e.active).map(e=>e.name));
|
||
orders.map(o=>(o.responsible||'').trim()).filter(Boolean).forEach(n=>names.add(n));
|
||
dl.innerHTML = [...names].sort((a,b)=>a.localeCompare(b,'ru')).map(n=>`<option value="${esc(n)}"></option>`).join('');
|
||
}
|
||
|
||
// Save/open/reset hooks after the event-card module.
|
||
const priorSave = window.saveOrder;
|
||
window.saveOrder = () => {
|
||
readStaffIntoDraft();
|
||
priorSave();
|
||
renderTeam();
|
||
};
|
||
const priorOpen = window.openOrder;
|
||
window.openOrder = id => {
|
||
priorOpen(id);
|
||
renderStaffAssignments();
|
||
refreshResponsibleDatalistFromTeam();
|
||
};
|
||
if (typeof window.resetDraft === 'function') {
|
||
const priorReset = window.resetDraft;
|
||
window.resetDraft = () => { priorReset(); renderStaffAssignments(); };
|
||
}
|
||
const priorRenderOrders = window.renderOrders;
|
||
window.renderOrders = () => { priorRenderOrders(); if (teamView.classList.contains('on')) renderTeam(); };
|
||
|
||
// Refresh conflict warnings when the order date/time changes.
|
||
['date','time'].forEach(id => document.getElementById(id)?.addEventListener('change', renderStaffAssignments));
|
||
|
||
persistEmployees();
|
||
refreshResponsibleDatalistFromTeam();
|
||
renderStaffAssignments();
|
||
renderTeam();
|
||
})();
|
||
|
||
/* ===== DAILY PRODUCTION SHEET + STABILITY PATCH v1 ===== */
|
||
(() => {
|
||
if (document.getElementById('sun-production-style')) return;
|
||
const nav = document.querySelector('nav');
|
||
if (!nav) return;
|
||
|
||
const esc=window.SunSafe.escapeHTML;
|
||
const fmt = v => typeof money === 'function' ? money(v) : `${Math.round(Number(v||0)).toLocaleString('ru-RU')} ₽`;
|
||
const itemById = id => boxes.find(x => String(x.id) === String(id));
|
||
const orderTotal = o => Number(o?.total || 0) || (o?.lines || []).reduce((s,l)=>s+Number(itemById(l.id)?.price||0)*Number(l.qty||0),0);
|
||
const isWork = o => !['Отменён','Отдан заказчику'].includes(o?.status || 'Новый');
|
||
const getEmployees = () => { try { const x=JSON.parse(localStorage.getItem('sunEmployees')||'[]'); return Array.isArray(x)?x:[]; } catch(e){ return []; } };
|
||
const getStock = () => { try { const x=JSON.parse(localStorage.getItem('sunStock')||'[]'); return Array.isArray(x)?x:[]; } catch(e){ return []; } };
|
||
const stockKey = (name,unit) => `${String(name||'').trim().toLowerCase()}|${String(unit||'').trim().toLowerCase()}`;
|
||
|
||
const style = document.createElement('style');
|
||
style.id = 'sun-production-style';
|
||
style.textContent = `
|
||
.prod-head{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;flex-wrap:wrap}.prod-head .actions{margin:0}.prod-datebar{display:flex;align-items:end;gap:8px;flex-wrap:wrap;margin:14px 0}.prod-datebar label{min-width:180px}.prod-summary{display:grid;grid-template-columns:repeat(5,minmax(130px,1fr));gap:9px;margin:12px 0 18px}.prod-summary>div{border:1px solid var(--l);background:#fafafa;border-radius:8px;padding:11px}.prod-summary small{display:block;color:#68717a}.prod-summary b{display:block;color:var(--n);font-size:19px;margin-top:4px}
|
||
.prod-section{border:1px solid var(--l);border-radius:10px;background:#fff;margin:12px 0;overflow:hidden}.prod-section-head{display:flex;justify-content:space-between;gap:10px;align-items:center;background:#f6f7f7;padding:11px 13px}.prod-section-head h2{font-size:17px;margin:0;color:var(--n)}.prod-body{padding:0 13px 12px}.prod-order{display:grid;grid-template-columns:100px minmax(180px,1.3fr) minmax(160px,1fr) 130px 110px;gap:10px;padding:12px 0;border-bottom:1px solid #e7eaeb;align-items:start}.prod-order:last-child{border-bottom:0}.prod-time b{font-size:17px;color:var(--n)}.prod-order small{display:block;color:#68717a;margin-top:3px}.prod-order .staff{display:flex;flex-wrap:wrap;gap:4px}.prod-chip{font-size:11px;background:#eef2f4;border-radius:999px;padding:3px 7px}.prod-status{font-size:11px;font-weight:700;border-radius:999px;padding:4px 8px;background:#f4f1e8;display:inline-block}.prod-actions{display:flex;flex-direction:column;gap:5px}.prod-actions button{padding:6px 8px}
|
||
.prod-table-wrap{overflow:auto}.prod-table{width:100%;border-collapse:collapse;min-width:680px}.prod-table th,.prod-table td{padding:9px 7px;border-bottom:1px solid #e7eaeb;text-align:left}.prod-table th{font-size:12px;color:#68717a;background:#fafafa}.prod-table td.num,.prod-table th.num{text-align:right;white-space:nowrap}.prod-deficit{color:#a33;font-weight:700}.prod-ok{color:#287a49}.prod-empty{padding:18px 0;color:#879198}.prod-print-title{display:none}
|
||
@media(max-width:900px){.prod-summary{grid-template-columns:1fr 1fr}.prod-order{grid-template-columns:72px 1fr}.prod-order>div:nth-child(3),.prod-order>div:nth-child(4){grid-column:2}.prod-actions{grid-column:1/-1;flex-direction:row}.prod-actions button{flex:1}}
|
||
@media print{body *{visibility:hidden!important}#production,#production *{visibility:visible!important}#production{position:absolute;inset:0;margin:0!important;padding:10mm!important;box-shadow:none!important}.prod-head .actions,.prod-datebar,.prod-actions,header{display:none!important}.prod-print-title{display:block!important}.prod-section{break-inside:avoid}.prod-order{grid-template-columns:80px 1.2fr 1fr 110px}.prod-actions{display:none!important}.prod-summary{grid-template-columns:repeat(5,1fr)}}
|
||
`;
|
||
document.head.appendChild(style);
|
||
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button'; btn.textContent = 'Производство';
|
||
nav.appendChild(btn);
|
||
const view = document.createElement('section');
|
||
view.id = 'production'; view.className = 'view page';
|
||
view.innerHTML = `<div class="prod-head"><div><h1>Производственный лист</h1><p class="hint" style="margin:5px 0 0">Все заказы выбранного дня: кухня, комплектация, выезд, команда и потребность.</p></div><div class="actions"><button class="outline" type="button" id="prodPrint">Печать / PDF</button></div></div><div class="prod-print-title"><h1>${window.CateriumBranding.nameHTML()} · Производственный лист</h1></div><div class="prod-datebar"><button class="outline" type="button" id="prodPrev">←</button><label>Дата<input type="date" id="prodDate"></label><button class="outline" type="button" id="prodNext">→</button><button class="outline" type="button" id="prodToday">Сегодня</button></div><div id="prodContent"></div>`;
|
||
document.body.appendChild(view);
|
||
const dateInput = view.querySelector('#prodDate');
|
||
const today = () => new Date().toISOString().slice(0,10);
|
||
dateInput.value = today();
|
||
const moveDate = d => { const x=new Date(`${dateInput.value||today()}T12:00:00`); x.setDate(x.getDate()+d); dateInput.value=x.toISOString().slice(0,10); renderProduction(); };
|
||
view.querySelector('#prodPrev').onclick=()=>moveDate(-1);
|
||
view.querySelector('#prodNext').onclick=()=>moveDate(1);
|
||
view.querySelector('#prodToday').onclick=()=>{dateInput.value=today();renderProduction();};
|
||
view.querySelector('#prodPrint').onclick=()=>window.print();
|
||
dateInput.onchange=renderProduction;
|
||
|
||
function staffFor(o){
|
||
const emps=getEmployees();
|
||
return (Array.isArray(o.staffIds)?o.staffIds:[]).map(id=>emps.find(e=>String(e.id)===String(id))).filter(Boolean);
|
||
}
|
||
function aggregateLines(dayOrders){
|
||
const map=new Map();
|
||
dayOrders.forEach(o=>(o.lines||[]).forEach(l=>{
|
||
const b=itemById(l.id); if(!b) return;
|
||
const key=String(b.id); const prev=map.get(key)||{name:b.name,qty:0,price:Number(b.price||0)}; prev.qty+=Number(l.qty||0); map.set(key,prev);
|
||
}));
|
||
return [...map.values()].sort((a,b)=>a.name.localeCompare(b.name,'ru'));
|
||
}
|
||
function aggregatePrep(dayOrders){
|
||
const map=new Map();
|
||
dayOrders.forEach(o=>(o.lines||[]).forEach(l=>{
|
||
const b=itemById(l.id); if(!b || !Array.isArray(b.ingredients)) return;
|
||
b.ingredients.forEach(x=>{
|
||
if(!Array.isArray(x) || !x[0]) return;
|
||
const name=x[0], qty=Number(x[1]||0)*Number(l.qty||0), unit=x[2]||'шт.';
|
||
const key=stockKey(name,unit); const p=map.get(key)||{name,unit,qty:0}; p.qty+=qty; map.set(key,p);
|
||
});
|
||
}));
|
||
return [...map.values()].sort((a,b)=>a.name.localeCompare(b.name,'ru'));
|
||
}
|
||
function renderProduction(){
|
||
const key=dateInput.value||today();
|
||
const dayOrders=orders.filter(o=>o.date===key && isWork(o)).sort((a,b)=>String(a.time||'').localeCompare(String(b.time||'')));
|
||
const total=dayOrders.reduce((s,o)=>s+orderTotal(o),0);
|
||
const guests=dayOrders.reduce((s,o)=>s+Number(o.guestsCount||0),0);
|
||
const staffSet=new Set(dayOrders.flatMap(o=>(Array.isArray(o.staffIds)?o.staffIds.map(String):[])));
|
||
const boxesList=aggregateLines(dayOrders);
|
||
const prep=aggregatePrep(dayOrders);
|
||
const stock=getStock(); const stockMap=new Map(stock.map(p=>[stockKey(p.name,p.unit),p]));
|
||
const deficits=prep.filter(p=>p.qty>Number(stockMap.get(stockKey(p.name,p.unit))?.qty||stockMap.get(stockKey(p.name,p.unit))?.balance||0));
|
||
|
||
const orderHtml=dayOrders.map(o=>{
|
||
const staff=staffFor(o); const ready = o.checklist && typeof o.checklist==='object' ? Object.values(o.checklist).filter(Boolean).length : 0;
|
||
const prepTime=o.prepTime||'—', depart=o.departureTime||'—', serve=o.serveTime||o.time||'—';
|
||
return `<div class="prod-order"><div class="prod-time"><b>${esc(o.time||'—')}</b><small>Подача ${esc(serve)}</small></div><div><b>№${esc(o.id)} · ${esc(o.event||'Заказ')}</b><small>${esc(o.contact||'Клиент не указан')} · ${esc(o.address||'Адрес не указан')}</small><small>${o.guestsCount?`${esc(o.guestsCount)} гостей · `:''}${fmt(orderTotal(o))}</small>${o.kitchenNote?`<small><b>Кухня:</b> ${esc(o.kitchenNote)}</small>`:''}</div><div><small>Начать готовить: <b>${esc(prepTime)}</b></small><small>Выезд: <b>${esc(depart)}</b></small><div class="staff">${staff.length?staff.map(s=>`<span class="prod-chip">${esc(s.role||'')} · ${esc(s.name)}</span>`).join(''):'<span class="prod-chip">Команда не назначена</span>'}</div>${o.courierNote?`<small><b>Курьеру:</b> ${esc(o.courierNote)}</small>`:''}</div><div><span class="prod-status">${esc(o.status||'Новый')}</span><small>Чек-лист: ${ready}/7</small></div><div class="prod-actions"><button class="outline" type="button" onclick="productionOpenOrder(${Number(o.id)})">Открыть</button>${typeof window.writeOffOrderStock==='function'?`<button class="outline" type="button" onclick="writeOffOrderStock(${Number(o.id)})">Списать склад</button>`:''}</div></div>`;
|
||
}).join('');
|
||
const boxesHtml=boxesList.length?`<div class="prod-table-wrap"><table class="prod-table"><thead><tr><th>Позиция</th><th class="num">Кол-во</th><th class="num">Цена</th><th class="num">Сумма</th></tr></thead><tbody>${boxesList.map(x=>`<tr><td>${esc(x.name)}</td><td class="num"><b>${x.qty}</b></td><td class="num">${fmt(x.price)}</td><td class="num">${fmt(x.price*x.qty)}</td></tr>`).join('')}</tbody></table></div>`:'<div class="prod-empty">Позиций на эту дату нет.</div>';
|
||
const prepHtml=prep.length?`<div class="prod-table-wrap"><table class="prod-table"><thead><tr><th>Продукт / заготовка</th><th class="num">Нужно</th><th class="num">На складе</th><th class="num">Не хватает</th></tr></thead><tbody>${prep.map(p=>{const sp=stockMap.get(stockKey(p.name,p.unit));const have=Number(sp?.qty||sp?.balance||0);const lack=Math.max(0,p.qty-have);return `<tr><td>${esc(p.name)}</td><td class="num"><b>${Number(p.qty.toFixed(2))} ${esc(p.unit)}</b></td><td class="num">${Number(have.toFixed(2))} ${esc(p.unit)}</td><td class="num ${lack?'prod-deficit':'prod-ok'}">${lack?`${Number(lack.toFixed(2))} ${esc(p.unit)}`:'✓'}</td></tr>`;}).join('')}</tbody></table></div>`:'<div class="prod-empty">Составы боксов не требуют расчёта или заказов нет.</div>';
|
||
|
||
view.querySelector('#prodContent').innerHTML=`<div class="prod-summary"><div><small>Заказов</small><b>${dayOrders.length}</b></div><div><small>Гостей</small><b>${guests}</b></div><div><small>Боксов / позиций</small><b>${boxesList.reduce((s,x)=>s+x.qty,0)}</b></div><div><small>Сотрудников</small><b>${staffSet.size}</b></div><div><small>Сумма дня</small><b>${fmt(total)}</b></div></div><section class="prod-section"><div class="prod-section-head"><h2>План дня · ${esc(key)}</h2><span class="hint">${dayOrders.length} заказов</span></div><div class="prod-body">${orderHtml||'<div class="prod-empty">На выбранную дату активных заказов нет.</div>'}</div></section><section class="prod-section"><div class="prod-section-head"><h2>Сводка по продукции</h2><span class="hint">Что приготовить и собрать</span></div><div class="prod-body">${boxesHtml}</div></section><section class="prod-section"><div class="prod-section-head"><h2>Продукты и заготовки</h2><span class="hint">Дефицит: ${deficits.length} поз.</span></div><div class="prod-body">${prepHtml}</div></section>`;
|
||
}
|
||
window.productionOpenOrder=id=>{ if(typeof window.openOrder==='function') window.openOrder(id); };
|
||
btn.onclick=()=>{document.querySelectorAll('.view').forEach(v=>v.classList.remove('on'));view.classList.add('on');[...nav.querySelectorAll('button')].forEach(b=>b.classList.remove('on'));btn.classList.add('on');renderProduction();};
|
||
|
||
// Keep the production sheet synchronized with mutations in other modules.
|
||
if (typeof window.saveOrder === 'function') { const p=window.saveOrder; window.saveOrder=()=>{p(); if(view.classList.contains('on')) renderProduction();}; }
|
||
if (typeof window.renderOrders === 'function') { const p=window.renderOrders; window.renderOrders=()=>{p(); if(view.classList.contains('on')) renderProduction();}; }
|
||
renderProduction();
|
||
})();
|
||
|
||
/* ===== SHOPPING LIST MODULE v2 · SUPPLIER GROUPING ===== */
|
||
(() => {
|
||
if (document.getElementById('sun-shopping-style')) return;
|
||
const nav = document.querySelector('nav');
|
||
if (!nav) return;
|
||
|
||
const esc=window.SunSafe.escapeHTML;
|
||
const fmt = v => typeof money === 'function' ? money(v) : `${Math.round(Number(v||0)).toLocaleString('ru-RU')} ₽`;
|
||
const qtyFmt = v => Number(v || 0).toLocaleString('ru-RU', { maximumFractionDigits: 3 });
|
||
const itemById = id => boxes.find(x => String(x.id) === String(id));
|
||
const getStock = () => { try { const x=JSON.parse(localStorage.getItem('sunStock')||'[]'); return Array.isArray(x)?x:[]; } catch { return []; } };
|
||
const getSuppliers = () => { try { const x=JSON.parse(localStorage.getItem('sunSuppliers')||'[]'); return Array.isArray(x)?x.map(s=>({...s,id:String(s.id||''),active:s.active!==false})):[]; } catch { return []; } };
|
||
const keyOf = (name,unit) => `${String(name||'').trim().toLowerCase()}|${String(unit||'').trim().toLowerCase()}`;
|
||
const activeOrder = o => !['Отменён','Отдан заказчику'].includes(o?.status || 'Новый');
|
||
|
||
function categoryFor(name){
|
||
const s=String(name||'').toLowerCase();
|
||
if (/(лосос|форел|тунец|кревет|икр|селед|рыб|морепродукт)/.test(s)) return 'Рыба и морепродукты';
|
||
if (/(говядин|свин|куриц|цыпл|индей|ветчин|салям|мортадел|прошут|коппа|пастрами|мяс|печен|паштет)/.test(s)) return 'Мясо и птица';
|
||
if (/(сыр|моцарел|пармез|чеддер|бри|камамбер|дорблю|крем.?чиз|сливоч|масл|фета)/.test(s)) return 'Молочные продукты и сыры';
|
||
if (/(томат|черри|огур|салат|ромэн|фрилис|баклаж|перец|картоф|капуст|груш|виноград|клубник|голубик|малин|апельс|лимон|фрукт|зелень|артишок|олив|маслин|гриб)/.test(s)) return 'Овощи, фрукты и зелень';
|
||
if (/(хлеб|крост|брускет|тарталет|эклер|профитрол|круассан|булоч|пирож|блин|тесто|бисквит|мафин|вафл)/.test(s)) return 'Хлеб и выпечка';
|
||
if (/(соус|песто|чили|горчиц|кетчуп|барбекю|трюфел|мед|карамел|орех|фисташ|миндал|пекан|юдзу)/.test(s)) return 'Соусы, специи и бакалея';
|
||
return 'Прочее';
|
||
}
|
||
|
||
const style=document.createElement('style');
|
||
style.id='sun-shopping-style';
|
||
style.textContent=`
|
||
.shop-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap}.shop-controls{display:flex;gap:9px;align-items:end;flex-wrap:wrap;margin:14px 0}.shop-controls label{min-width:155px}.shop-controls .grow{flex:1;min-width:210px}.shop-summary{display:grid;grid-template-columns:repeat(auto-fit,minmax(125px,1fr));gap:10px;margin:14px 0}.shop-summary>div{background:#fafafa;border:1px solid var(--l);border-radius:8px;padding:12px}.shop-summary small{display:block;color:#68717a;margin-bottom:5px}.shop-summary b{font-size:20px;color:var(--n)}
|
||
.shop-section{border:1px solid var(--l);border-radius:9px;overflow:hidden;margin:13px 0;background:#fff}.shop-section h3{margin:0;padding:11px 13px;background:#f7f8f8;color:var(--n);display:flex;justify-content:space-between;align-items:center;gap:12px}.shop-title-wrap{display:flex;flex-direction:column;gap:3px}.shop-title-wrap small{font-weight:400;color:#68717a}.shop-group-total{text-align:right;white-space:nowrap}.shop-group-total small{display:block;color:#68717a;font-weight:400}.shop-supplier-link{border:0;background:transparent;padding:0;color:var(--n);text-decoration:underline;font:inherit;font-size:12px}
|
||
.shop-table-wrap{overflow:auto}.shop-table{width:100%;border-collapse:collapse;min-width:760px}.shop-table th,.shop-table td{padding:9px 8px;border-bottom:1px solid #e7eaeb;text-align:left}.shop-table th{font-size:12px;color:#68717a;background:#fff}.shop-table .num{text-align:right;white-space:nowrap}.shop-table tr.done{opacity:.48;text-decoration:line-through}.shop-buy{font-weight:700;color:#a33}.shop-ok{color:#287a49;font-weight:700}.shop-check{width:18px;height:18px}.shop-price-btn{border:0;background:transparent;color:var(--n);text-decoration:underline;padding:0;cursor:pointer}.shop-orders{font-size:11px;color:#68717a;max-width:330px}.shop-empty{padding:22px;color:#879198;text-align:center}.shop-note{margin:8px 0;color:#68717a;font-size:12px}.shop-filter{display:flex;align-items:center;gap:7px;color:#203040;font-size:13px}.shop-copy-status{font-size:12px;color:#287a49;min-height:18px}
|
||
@media(max-width:900px){.shop-summary{grid-template-columns:1fr 1fr}.shop-controls{align-items:stretch}.shop-controls label,.shop-controls .grow{min-width:0;width:100%}.shop-head .actions{width:100%}.shop-head .actions button{flex:1}.shop-section h3{align-items:flex-start}.shop-group-total{font-size:12px}}
|
||
@media print{body *{visibility:hidden!important}#shopping,#shopping *{visibility:visible!important}#shopping{position:absolute;inset:0;margin:0!important;padding:8mm!important;box-shadow:none!important}.shop-head .actions,.shop-controls,.shop-check,.shop-copy-status,header,.shop-supplier-link{display:none!important}.shop-section{break-inside:avoid}.shop-table{min-width:0;font-size:11px}.shop-summary{grid-template-columns:repeat(7,1fr)}}`;
|
||
document.head.appendChild(style);
|
||
|
||
const btn=document.createElement('button');
|
||
btn.type='button'; btn.textContent='Закупки'; nav.appendChild(btn);
|
||
const view=document.createElement('section'); view.id='shopping'; view.className='view page';
|
||
view.innerHTML=`<div class="shop-head"><div><h1>Закупочный лист</h1><p class="hint" style="margin:5px 0 0">Считает закупку по заказам, вычитает остатки и распределяет продукты по поставщикам.</p></div><div class="actions"><button class="outline" id="shopCopy" type="button">Копировать список</button><button class="outline" id="shopPrint" type="button">Печать / PDF</button></div></div>
|
||
<div class="shop-controls"><label>С даты<input type="date" id="shopFrom"></label><label>По дату<input type="date" id="shopTo"></label><label>Группировка<select id="shopGroup"><option value="supplier">По поставщикам</option><option value="category">По категориям</option></select></label><label class="grow">Поиск<input type="search" id="shopSearch" placeholder="Продукт, категория или поставщик"></label><label class="shop-filter"><input type="checkbox" id="shopOnlyDeficit" checked> Только то, что нужно купить</label><button class="outline" id="shopToday" type="button">Сегодня</button><button class="outline" id="shopWeek" type="button">7 дней</button></div>
|
||
<div id="shopCopyStatus" class="shop-copy-status"></div><div id="shopContent"></div>`;
|
||
document.body.appendChild(view);
|
||
|
||
const el=id=>document.getElementById(id);
|
||
const iso=d=>d.toISOString().slice(0,10);
|
||
const today=()=>iso(new Date());
|
||
const plusDays=(dateStr,n)=>{const d=new Date(`${dateStr}T12:00:00`);d.setDate(d.getDate()+n);return iso(d)};
|
||
el('shopFrom').value=today(); el('shopTo').value=plusDays(today(),6);
|
||
|
||
function ordersInRange(){
|
||
const from=el('shopFrom').value, to=el('shopTo').value;
|
||
return orders.filter(o=>activeOrder(o) && o.date && (!from || o.date>=from) && (!to || o.date<=to)).sort((a,b)=>`${a.date}T${a.time||''}`.localeCompare(`${b.date}T${b.time||''}`));
|
||
}
|
||
function aggregate(orderList){
|
||
const map=new Map();
|
||
orderList.forEach(o=>(o.lines||[]).forEach(l=>{
|
||
const item=itemById(l.id); if(!item || !Array.isArray(item.ingredients)) return;
|
||
item.ingredients.forEach(x=>{
|
||
if(!Array.isArray(x) || !x[0]) return;
|
||
const name=String(x[0]).trim(), unit=String(x[2]||'шт.').trim()||'шт.', qty=Number(x[1]||0)*Number(l.qty||0), key=keyOf(name,unit);
|
||
const p=map.get(key)||{name,unit,need:0,orders:new Set()}; p.need+=qty; p.orders.add(`#${o.id}`); map.set(key,p);
|
||
});
|
||
}));
|
||
const stock=getStock(), stockMap=new Map(stock.map(p=>[keyOf(p.name,p.unit),p]));
|
||
const supplierMap=new Map(getSuppliers().map(s=>[String(s.id),s]));
|
||
return [...map.values()].map(p=>{
|
||
const sp=stockMap.get(keyOf(p.name,p.unit)); const have=Number(sp?.qty ?? sp?.balance ?? 0), cost=Number(sp?.cost||0), buy=Math.max(0,p.need-have);
|
||
const rawSupplierId=String(sp?.supplierId||''), supplier=supplierMap.get(rawSupplierId)||null, supplierId=supplier?rawSupplierId:'';
|
||
return {...p,have,cost,buy,sum:buy*cost,stockId:sp?.id||'',category:categoryFor(p.name),supplierId,supplier,supplierName:supplier?.name||'Без поставщика',lastPurchaseDate:sp?.lastPurchaseDate||'',lastPurchasePrice:Number(sp?.lastPurchasePrice||0)};
|
||
});
|
||
}
|
||
function doneKey(row){return `sunShopDone|${el('shopFrom').value}|${el('shopTo').value}|${keyOf(row.name,row.unit)}`;}
|
||
function isDone(row){return localStorage.getItem(doneKey(row))==='1';}
|
||
function setDone(row,on){if(on)localStorage.setItem(doneKey(row),'1');else localStorage.removeItem(doneKey(row));}
|
||
|
||
function currentRows(){
|
||
const q=(el('shopSearch').value||'').trim().toLowerCase(), only=el('shopOnlyDeficit').checked;
|
||
return aggregate(ordersInRange()).filter(r=>(!only||r.buy>0) && (!q||`${r.name} ${r.category} ${r.supplierName} ${r.supplier?.phone||''}`.toLowerCase().includes(q))).sort((a,b)=>a.supplierName.localeCompare(b.supplierName,'ru')||a.category.localeCompare(b.category,'ru')||a.name.localeCompare(b.name,'ru'));
|
||
}
|
||
|
||
function groupRows(rows, mode){
|
||
const groups=new Map();
|
||
rows.forEach(row=>{
|
||
const key=mode==='supplier'?(row.supplierId||'__unassigned'):row.category;
|
||
if(!groups.has(key)) groups.set(key,{key,rows:[],supplier:mode==='supplier'?row.supplier:null,title:mode==='supplier'?row.supplierName:row.category});
|
||
groups.get(key).rows.push(row);
|
||
});
|
||
return [...groups.values()].sort((a,b)=>{
|
||
if(a.key==='__unassigned') return 1;
|
||
if(b.key==='__unassigned') return -1;
|
||
return String(a.title||'').localeCompare(String(b.title||''),'ru');
|
||
});
|
||
}
|
||
|
||
function groupHeader(group, mode){
|
||
const total=group.rows.reduce((s,r)=>s+r.sum,0);
|
||
if(mode==='category') return `<h3><span class="shop-title-wrap"><b>${esc(group.title)}</b></span><span class="shop-group-total">${group.rows.length} поз.<small>${fmt(total)}</small></span></h3>`;
|
||
const s=group.supplier;
|
||
const meta=s?[s.contact||'',s.phone||'',s.deliveryDays?`доставка: ${s.deliveryDays}`:'',Number(s.minOrder||0)>0?`мин. заказ ${fmt(s.minOrder)}`:''].filter(Boolean).join(' · '):'Назначьте поставщика в карточке продукта на складе';
|
||
return `<h3><span class="shop-title-wrap"><b>${esc(group.title)}</b><small>${esc(meta)}</small>${s?`<button class="shop-supplier-link" type="button" onclick="sunEditSupplier('${esc(s.id)}')">Открыть карточку поставщика</button>`:''}</span><span class="shop-group-total">${group.rows.length} поз.<small>${fmt(total)}</small></span></h3>`;
|
||
}
|
||
|
||
function renderShopping(){
|
||
const orderList=ordersInRange(), all=aggregate(orderList), rows=currentRows(), mode=el('shopGroup').value||'supplier';
|
||
const deficitAll=all.filter(r=>r.buy>0), estimate=deficitAll.reduce((s,r)=>s+r.sum,0), noPrice=deficitAll.filter(r=>r.cost<=0).length;
|
||
const checked=rows.filter(isDone).length, supplierCount=new Set(deficitAll.filter(r=>r.supplierId).map(r=>r.supplierId)).size, unassigned=deficitAll.filter(r=>!r.supplierId).length;
|
||
const sections=groupRows(rows,mode).map(group=>`<section class="shop-section">${groupHeader(group,mode)}<div class="shop-table-wrap"><table class="shop-table"><thead><tr><th></th><th>Продукт</th><th class="num">Нужно</th><th class="num">Склад</th><th class="num">Купить</th><th class="num">Цена / ед.</th><th class="num">Сумма</th></tr></thead><tbody>${group.rows.map(r=>`<tr data-shop-key="${esc(keyOf(r.name,r.unit))}" class="${isDone(r)?'done':''}"><td><input class="shop-check" type="checkbox" ${isDone(r)?'checked':''} data-shop-done="${esc(keyOf(r.name,r.unit))}"></td><td><b>${esc(r.name)}</b><div class="shop-orders">Поставщик: ${esc(r.supplierName)}${r.lastPurchaseDate?` · последняя закупка ${esc(r.lastPurchaseDate)}`:''}<br>Заказы: ${esc([...r.orders].join(', '))}</div></td><td class="num">${qtyFmt(r.need)} ${esc(r.unit)}</td><td class="num">${qtyFmt(r.have)} ${esc(r.unit)}</td><td class="num ${r.buy>0?'shop-buy':'shop-ok'}">${r.buy>0?`${qtyFmt(r.buy)} ${esc(r.unit)}`:'✓'}</td><td class="num">${r.stockId?`<button class="shop-price-btn" type="button" onclick="shoppingEditStock('${esc(r.stockId)}')">${fmt(r.cost)}</button>`:fmt(r.cost)}</td><td class="num"><b>${r.cost>0?fmt(r.sum):'—'}</b></td></tr>`).join('')}</tbody></table></div></section>`).join('');
|
||
el('shopContent').innerHTML=`<div class="shop-summary"><div><small>Заказов в периоде</small><b>${orderList.length}</b></div><div><small>Нужно купить</small><b>${deficitAll.length} поз.</b></div><div><small>Поставщиков</small><b>${supplierCount}</b></div><div><small>Без поставщика</small><b>${unassigned}</b></div><div><small>Отмечено купленным</small><b>${checked}</b></div><div><small>Без закупочной цены</small><b>${noPrice}</b></div><div><small>Ориентир закупки</small><b>${fmt(estimate)}</b></div></div>${orderList.length?sections||'<div class="shop-empty">По выбранному фильтру ничего не найдено.</div>':'<div class="shop-empty">На выбранный период активных заказов нет.</div>'}<p class="shop-note">Поставщик и закупочная цена берутся из карточки продукта на складе. При проведении прихода дата и цена последней закупки обновляются автоматически.</p>`;
|
||
view.querySelectorAll('[data-shop-done]').forEach(input=>input.addEventListener('change',()=>{
|
||
const row=rows.find(r=>keyOf(r.name,r.unit)===input.dataset.shopDone); if(row)setDone(row,input.checked); renderShopping();
|
||
}));
|
||
}
|
||
|
||
window.shoppingEditStock=id=>{ if(typeof window.editStockProduct==='function') window.editStockProduct(id); else alert('Откройте раздел «Склад» и укажите закупочную цену продукта.'); };
|
||
function makeText(){
|
||
const orderList=ordersInRange(), rows=aggregate(orderList).filter(r=>r.buy>0), mode=el('shopGroup').value||'supplier';
|
||
const lines=[`${window.CateriumBranding.nameHTML().toUpperCase()} — ЗАКУПОЧНЫЙ ЛИСТ`,`${el('shopFrom').value} — ${el('shopTo').value}`,`Заказов: ${orderList.length}`,''];
|
||
groupRows(rows,mode).forEach(group=>{
|
||
lines.push(String(group.title||'').toUpperCase());
|
||
if(mode==='supplier' && group.supplier){
|
||
const s=group.supplier;
|
||
if(s.contact) lines.push(`Контакт: ${s.contact}`);
|
||
if(s.phone) lines.push(`Телефон: ${s.phone}`);
|
||
if(s.address) lines.push(`Адрес: ${s.address}`);
|
||
if(s.deliveryDays) lines.push(`Доставка: ${s.deliveryDays}`);
|
||
if(Number(s.minOrder||0)>0) lines.push(`Минимальный заказ: ${fmt(s.minOrder)}`);
|
||
}
|
||
group.rows.forEach(r=>lines.push(`☐ ${r.name}: ${qtyFmt(r.buy)} ${r.unit}${r.cost>0?` — ~${fmt(r.sum)}`:''}`));
|
||
lines.push(`Подытог: ${fmt(group.rows.reduce((s,r)=>s+r.sum,0))}`,'');
|
||
});
|
||
const estimate=rows.reduce((s,r)=>s+r.sum,0); lines.push(`Ориентировочно: ${fmt(estimate)}`); return lines.join('\n');
|
||
}
|
||
el('shopCopy').onclick=async()=>{try{await navigator.clipboard.writeText(makeText());el('shopCopyStatus').textContent='Список скопирован.';}catch{const t=document.createElement('textarea');t.value=makeText();document.body.appendChild(t);t.select();document.execCommand('copy');t.remove();el('shopCopyStatus').textContent='Список скопирован.';}setTimeout(()=>el('shopCopyStatus').textContent='',1800);};
|
||
el('shopPrint').onclick=()=>window.print();
|
||
el('shopToday').onclick=()=>{el('shopFrom').value=today();el('shopTo').value=today();renderShopping();};
|
||
el('shopWeek').onclick=()=>{el('shopFrom').value=today();el('shopTo').value=plusDays(today(),6);renderShopping();};
|
||
['shopFrom','shopTo','shopOnlyDeficit','shopGroup'].forEach(id=>el(id).addEventListener('change',renderShopping)); el('shopSearch').addEventListener('input',renderShopping);
|
||
btn.onclick=()=>{document.querySelectorAll('.view').forEach(v=>v.classList.remove('on'));view.classList.add('on');[...nav.querySelectorAll('button')].forEach(b=>b.classList.remove('on'));btn.classList.add('on');renderShopping();};
|
||
|
||
if(typeof window.saveOrder==='function'){const p=window.saveOrder;window.saveOrder=()=>{p();if(view.classList.contains('on'))renderShopping();};}
|
||
if(typeof window.renderOrders==='function'){const p=window.renderOrders;window.renderOrders=()=>{p();if(view.classList.contains('on'))renderShopping();};}
|
||
window.addEventListener('sun:suppliers-changed',()=>{if(view.classList.contains('on'))renderShopping();});
|
||
window.addEventListener('sun:stock-changed',()=>{if(view.classList.contains('on'))renderShopping();});
|
||
window.sunRenderShopping=renderShopping;
|
||
renderShopping();
|
||
})();
|
||
|
||
/* ===== SUPPLIERS MODULE v1 ===== */
|
||
(() => {
|
||
if (document.getElementById('sun-suppliers-style')) return;
|
||
const nav=document.querySelector('nav');
|
||
if(!nav) return;
|
||
const KEY='sunSuppliers', STOCK_KEY='sunStock', MOVES_KEY='sunStockMoves';
|
||
const esc=window.SunSafe.escapeHTML;
|
||
const fmt=v=>typeof money==='function'?money(v):`${Math.round(Number(v||0)).toLocaleString('ru-RU')} ₽`;
|
||
const dateLabel=v=>v?new Date(`${v}T12:00:00`).toLocaleDateString('ru-RU'):'—';
|
||
const readArray=(key)=>{try{const x=JSON.parse(localStorage.getItem(key)||'[]');return Array.isArray(x)?x:[];}catch{return[];}};
|
||
let suppliers=readArray(KEY).map(s=>({id:String(s.id||sunUUID()),name:String(s.name||'Без названия'),category:String(s.category||''),contact:String(s.contact||''),phone:String(s.phone||''),email:String(s.email||''),address:String(s.address||''),deliveryDays:String(s.deliveryDays||''),minOrder:Number(s.minOrder||0),notes:String(s.notes||''),active:s.active!==false,createdAt:s.createdAt||new Date().toISOString()}));
|
||
let editingId=null;
|
||
|
||
function saveSuppliers(){
|
||
localStorage.setItem(KEY,JSON.stringify(suppliers));
|
||
try{window.dispatchEvent(new CustomEvent('sun:suppliers-changed'));}catch(_){ }
|
||
}
|
||
saveSuppliers();
|
||
const getStock=()=>readArray(STOCK_KEY);
|
||
const getMoves=()=>readArray(MOVES_KEY);
|
||
const supplierById=id=>suppliers.find(s=>String(s.id)===String(id||''))||null;
|
||
const productsFor=id=>getStock().filter(p=>String(p.supplierId||'')===String(id||''));
|
||
const purchaseStats=id=>{
|
||
const moves=getMoves().filter(m=>m.type==='in'&&String(m.supplierId||'')===String(id||''));
|
||
return {count:moves.length,total:moves.reduce((s,m)=>s+Math.abs(Number(m.qty||0))*Number(m.cost||0),0),last:moves.map(m=>m.date||'').sort().pop()||''};
|
||
};
|
||
|
||
const style=document.createElement('style');
|
||
style.id='sun-suppliers-style';
|
||
style.textContent=`
|
||
.sup-head{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;flex-wrap:wrap}.sup-summary{display:grid;grid-template-columns:repeat(5,minmax(130px,1fr));gap:10px;margin:14px 0}.sup-summary>div{background:#fafafa;border:1px solid var(--l);border-radius:8px;padding:12px}.sup-summary small{display:block;color:#68717a;margin-bottom:5px}.sup-summary b{font-size:20px;color:var(--n)}
|
||
.sup-toolbar{display:flex;gap:9px;align-items:end;flex-wrap:wrap;margin:14px 0}.sup-toolbar label{min-width:180px}.sup-toolbar .grow{flex:1;min-width:240px}.sup-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px}.sup-card{border:1px solid var(--l);background:#fff;border-radius:10px;padding:14px;display:flex;flex-direction:column;gap:10px}.sup-card.inactive{opacity:.64}.sup-card-head{display:flex;justify-content:space-between;gap:10px}.sup-card h3{margin:0;color:var(--n);font-size:18px}.sup-badge{font-size:11px;border-radius:999px;padding:4px 8px;background:#e8f4eb;color:#287a49;white-space:nowrap}.sup-badge.off{background:#f1eeee;color:#8a5555}.sup-meta{display:grid;gap:5px;color:#50606b;font-size:13px}.sup-meta a{color:var(--n)}.sup-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:7px}.sup-stats>div{background:#f7f8f8;border-radius:7px;padding:8px}.sup-stats small{display:block;color:#68717a}.sup-stats b{display:block;margin-top:3px}.sup-actions{display:flex;gap:7px;margin-top:auto}.sup-actions button{flex:1}.sup-notes{font-size:12px;color:#68717a;border-left:3px solid var(--g);padding-left:8px}.sup-empty{padding:25px;text-align:center;color:#879198}
|
||
.sup-form{display:grid;grid-template-columns:1fr 1fr;gap:10px}.sup-form .wide{grid-column:1/-1}.sup-active{display:flex;flex-direction:row;align-items:center;gap:8px;color:#203040}.sup-active input{width:18px;height:18px}.sup-products-table{width:100%;border-collapse:collapse;min-width:650px}.sup-products-table th,.sup-products-table td{padding:9px 8px;border-bottom:1px solid var(--l);text-align:left}.sup-products-table th{font-size:12px;color:#68717a}.sup-products-table .num{text-align:right;white-space:nowrap}
|
||
@media(max-width:900px){.sup-summary{grid-template-columns:1fr 1fr}.sup-toolbar label,.sup-toolbar .grow{min-width:0;width:100%}.sup-form{grid-template-columns:1fr}.sup-form .wide{grid-column:auto}.sup-grid{grid-template-columns:1fr}}
|
||
`;
|
||
document.head.appendChild(style);
|
||
|
||
const btn=document.createElement('button');btn.type='button';btn.textContent='Поставщики';nav.appendChild(btn);
|
||
const view=document.createElement('section');view.id='suppliers';view.className='view page';
|
||
view.innerHTML=`<div class="sup-head"><div><h1>Поставщики</h1><p class="hint" style="margin:5px 0 0">Контакты, условия, связанные продукты и история закупок.</p></div><button class="primary" id="supAdd" type="button">Добавить поставщика</button></div><div class="sup-summary" id="supSummary"></div><div class="sup-toolbar"><label class="grow">Поиск<input id="supSearch" type="search" placeholder="Название, контакт, телефон, категория"></label><label>Статус<select id="supFilter"><option value="all">Все</option><option value="active">Активные</option><option value="inactive">Неактивные</option></select></label></div><div id="supList"></div>`;
|
||
document.body.appendChild(view);
|
||
|
||
const modalEl=document.createElement('div');modalEl.id='supplier-modal';modalEl.className='modal';
|
||
modalEl.innerHTML=`<div class="dialog"><div class="dialog-head"><h2 id="supplierModalTitle">Поставщик</h2><button type="button" onclick="closeModal('supplier-modal')">×</button></div><div class="sup-form"><label>Название<input id="supplierName"></label><label>Категория / тип<input id="supplierCategory" list="supplierCategoryList" placeholder="Овощи, Metro, рыба…"><datalist id="supplierCategoryList"><option value="Оптовый магазин"><option value="Овощи и фрукты"><option value="Мясо и птица"><option value="Рыба и морепродукты"><option value="Молочные продукты"><option value="Выпечка"><option value="Посуда и упаковка"></datalist></label><label>Контактное лицо<input id="supplierContact"></label><label>Телефон<input id="supplierPhone" type="tel"></label><label>Email<input id="supplierEmail" type="email"></label><label>Минимальный заказ, ₽<input id="supplierMinOrder" type="number" min="0" step="1"></label><label class="wide">Адрес / точка выдачи<input id="supplierAddress"></label><label class="wide">Дни и условия доставки<input id="supplierDeliveryDays" placeholder="Пн–Сб, заказ до 15:00, доставка на следующий день"></label><label class="wide">Комментарий<textarea id="supplierNotes" rows="3"></textarea></label><label class="sup-active wide"><input id="supplierActive" type="checkbox" checked> Поставщик активен</label></div><div class="actions"><button class="primary" id="supplierSave" type="button">Сохранить</button><button class="danger" id="supplierDelete" type="button">Удалить</button></div></div>`;
|
||
document.body.appendChild(modalEl);
|
||
|
||
const productsModal=document.createElement('div');productsModal.id='supplier-products-modal';productsModal.className='modal';
|
||
productsModal.innerHTML=`<div class="dialog"><div class="dialog-head"><h2 id="supplierProductsTitle">Продукты поставщика</h2><button type="button" onclick="closeModal('supplier-products-modal')">×</button></div><div id="supplierProductsContent"></div><div class="actions"><button class="outline" type="button" onclick="closeModal('supplier-products-modal')">Закрыть</button></div></div>`;
|
||
document.body.appendChild(productsModal);
|
||
|
||
const el=id=>document.getElementById(id);
|
||
function render(){
|
||
const stock=getStock(), moves=getMoves(), linked=stock.filter(p=>p.supplierId).length, unassigned=stock.filter(p=>!p.supplierId).length, active=suppliers.filter(s=>s.active!==false).length;
|
||
const purchaseTotal=moves.filter(m=>m.type==='in'&&m.supplierId).reduce((s,m)=>s+Math.abs(Number(m.qty||0))*Number(m.cost||0),0);
|
||
el('supSummary').innerHTML=`<div><small>Всего поставщиков</small><b>${suppliers.length}</b></div><div><small>Активных</small><b>${active}</b></div><div><small>Продуктов привязано</small><b>${linked}</b></div><div><small>Без поставщика</small><b>${unassigned}</b></div><div><small>Закупок по истории</small><b>${fmt(purchaseTotal)}</b></div>`;
|
||
const q=(el('supSearch').value||'').trim().toLowerCase(), filter=el('supFilter').value;
|
||
const list=suppliers.filter(s=>(filter==='all'||(filter==='active'&&s.active!==false)||(filter==='inactive'&&s.active===false))&&(!q||`${s.name} ${s.category} ${s.contact} ${s.phone} ${s.email} ${s.address}`.toLowerCase().includes(q))).sort((a,b)=>(a.active===b.active?String(a.name).localeCompare(String(b.name),'ru'):(a.active===false?1:-1)));
|
||
const cards=list.map(s=>{
|
||
const products=stock.filter(p=>String(p.supplierId||'')===String(s.id)), stats=purchaseStats(s.id), phoneHref=String(s.phone||'').replace(/[^\d+]/g,'');
|
||
return `<article class="sup-card ${s.active===false?'inactive':''}"><div class="sup-card-head"><div><h3>${esc(s.name)}</h3><small class="hint">${esc(s.category||'Категория не указана')}</small></div><span class="sup-badge ${s.active===false?'off':''}">${s.active===false?'Неактивен':'Активен'}</span></div><div class="sup-meta">${s.contact?`<span>Контакт: <b>${esc(s.contact)}</b></span>`:''}${s.phone?`<span>Телефон: <a href="tel:${esc(phoneHref)}">${esc(s.phone)}</a></span>`:''}${s.email?`<span>Email: <a href="mailto:${esc(s.email)}">${esc(s.email)}</a></span>`:''}${s.address?`<span>Адрес: ${esc(s.address)}</span>`:''}${s.deliveryDays?`<span>Доставка: ${esc(s.deliveryDays)}</span>`:''}${Number(s.minOrder||0)>0?`<span>Минимальный заказ: <b>${fmt(s.minOrder)}</b></span>`:''}</div><div class="sup-stats"><div><small>Продуктов</small><b>${products.length}</b></div><div><small>Закупок</small><b>${stats.count}</b></div><div><small>Последняя</small><b>${dateLabel(stats.last)}</b></div></div>${s.notes?`<div class="sup-notes">${esc(s.notes)}</div>`:''}<div class="sup-actions"><button class="outline" type="button" onclick="sunSupplierProducts('${esc(s.id)}')">Продукты</button><button class="primary" type="button" onclick="sunEditSupplier('${esc(s.id)}')">Изменить</button></div></article>`;
|
||
}).join('');
|
||
const unassignedCard=unassigned?`<article class="sup-card"><div class="sup-card-head"><div><h3>Без поставщика</h3><small class="hint">Требуется распределение</small></div><span class="sup-badge off">${unassigned} поз.</span></div><p class="hint">Эти продукты попадут в отдельный раздел закупочного листа.</p><div class="sup-actions"><button class="outline" type="button" onclick="sunSupplierProducts('')">Показать продукты</button></div></article>`:'';
|
||
el('supList').innerHTML=list.length||unassigned?`<div class="sup-grid">${cards}${unassignedCard}</div>`:'<div class="sup-empty">Поставщиков пока нет. Добавьте первого поставщика и назначьте его продуктам на складе.</div>';
|
||
}
|
||
|
||
function openSupplier(id){
|
||
editingId=id||null;const s=editingId?supplierById(editingId):null;
|
||
el('supplierModalTitle').textContent=s?'Изменить поставщика':'Новый поставщик';
|
||
el('supplierName').value=s?.name||'';el('supplierCategory').value=s?.category||'';el('supplierContact').value=s?.contact||'';el('supplierPhone').value=s?.phone||'';el('supplierEmail').value=s?.email||'';el('supplierMinOrder').value=Number(s?.minOrder||0);el('supplierAddress').value=s?.address||'';el('supplierDeliveryDays').value=s?.deliveryDays||'';el('supplierNotes').value=s?.notes||'';el('supplierActive').checked=s?s.active!==false:true;el('supplierDelete').style.display=s?'inline-block':'none';modal('supplier-modal');
|
||
}
|
||
window.sunEditSupplier=openSupplier;
|
||
el('supAdd').onclick=()=>openSupplier(null);
|
||
el('supplierSave').onclick=()=>{
|
||
const name=el('supplierName').value.trim();if(!name)return alert('Введите название поставщика.');
|
||
const data={name,category:el('supplierCategory').value.trim(),contact:el('supplierContact').value.trim(),phone:el('supplierPhone').value.trim(),email:el('supplierEmail').value.trim(),minOrder:Math.max(0,Number(el('supplierMinOrder').value||0)),address:el('supplierAddress').value.trim(),deliveryDays:el('supplierDeliveryDays').value.trim(),notes:el('supplierNotes').value.trim(),active:el('supplierActive').checked};
|
||
if(editingId){const s=supplierById(editingId);Object.assign(s,data);}else{suppliers.push({id:sunUUID(),createdAt:new Date().toISOString(),...data});}
|
||
saveSuppliers();closeModal('supplier-modal');render();
|
||
};
|
||
el('supplierDelete').onclick=()=>{
|
||
const s=supplierById(editingId);if(!s)return;const linked=productsFor(s.id).length;
|
||
if(!confirm(`Удалить поставщика «${s.name}»?${linked?` У ${linked} продуктов поставщик будет сброшен.`:''}`))return;
|
||
if(typeof window.sunUnlinkSupplier==='function') window.sunUnlinkSupplier(s.id);
|
||
else { const stock=getStock().map(p=>String(p.supplierId||'')===String(s.id)?{...p,supplierId:''}:p);localStorage.setItem(STOCK_KEY,JSON.stringify(stock));try{window.dispatchEvent(new CustomEvent('sun:stock-changed'));}catch(_){ } }
|
||
suppliers=suppliers.filter(x=>x.id!==s.id);saveSuppliers();closeModal('supplier-modal');render();
|
||
};
|
||
|
||
window.sunSupplierProducts=id=>{
|
||
const s=id?supplierById(id):null, list=productsFor(id).sort((a,b)=>String(a.name||'').localeCompare(String(b.name||''),'ru'));
|
||
el('supplierProductsTitle').textContent=s?`Продукты · ${s.name}`:'Продукты без поставщика';
|
||
el('supplierProductsContent').innerHTML=list.length?`<div style="overflow:auto"><table class="sup-products-table"><thead><tr><th>Продукт</th><th>Ед.</th><th class="num">Цена</th><th>Последняя закупка</th><th></th></tr></thead><tbody>${list.map(p=>`<tr><td><b>${esc(p.name||'Без названия')}</b></td><td>${esc(p.unit||'')}</td><td class="num">${fmt(p.cost||0)}</td><td>${esc(p.lastPurchaseDate||'—')}${Number(p.lastPurchasePrice||0)>0?` · ${fmt(p.lastPurchasePrice)}`:''}</td><td><button class="outline" type="button" onclick="sunSupplierEditStock('${esc(p.id)}')">Изменить</button></td></tr>`).join('')}</tbody></table></div>`:'<div class="sup-empty">Продуктов нет.</div>';
|
||
modal('supplier-products-modal');
|
||
};
|
||
window.sunSupplierEditStock=id=>{closeModal('supplier-products-modal');if(typeof window.editStockProduct==='function')window.editStockProduct(id);};
|
||
|
||
el('supSearch').oninput=render;el('supFilter').onchange=render;
|
||
btn.onclick=()=>{document.querySelectorAll('.view').forEach(v=>v.classList.remove('on'));view.classList.add('on');[...nav.querySelectorAll('button')].forEach(b=>b.classList.remove('on'));btn.classList.add('on');render();};
|
||
window.addEventListener('sun:stock-changed',()=>{if(view.classList.contains('on'))render();});
|
||
window.sunRenderSuppliers=render;
|
||
render();
|
||
})();
|
||
|
||
// Polished application navigation: clearer groups, equal spacing and better responsive behavior.
|
||
(() => {
|
||
const nav = document.querySelector('header nav');
|
||
const header = document.querySelector('header');
|
||
if (!nav || !header || document.getElementById('sun-nav-polish-style')) return;
|
||
|
||
const preferredOrder = [
|
||
'Главная','Новый заказ','Заказы','Календарь',
|
||
'Производство','Закупки','Склад',
|
||
'Клиенты','Деньги','Команда','Поставщики','Статистика'
|
||
];
|
||
const groupMap = {
|
||
work: ['Новый заказ','Заказы','Календарь'],
|
||
operations: ['Производство','Закупки','Склад'],
|
||
management: ['Клиенты','Деньги','Команда','Поставщики','Статистика']
|
||
};
|
||
const classMap = {
|
||
'Главная':'nav-home','Новый заказ':'nav-new','Заказы':'nav-orders','Календарь':'nav-calendar',
|
||
'Производство':'nav-production','Закупки':'nav-shopping','Склад':'nav-stock',
|
||
'Клиенты':'nav-clients','Деньги':'nav-money','Команда':'nav-team','Поставщики':'nav-suppliers','Статистика':'nav-stats'
|
||
};
|
||
|
||
const allButtons = [...nav.querySelectorAll('button')];
|
||
const byName = new Map(allButtons.map(button => [button.textContent.trim(), button]));
|
||
|
||
// Keep any future/unknown buttons too, after the known navigation items.
|
||
preferredOrder.forEach(name => {
|
||
const button = byName.get(name);
|
||
if (button) nav.appendChild(button);
|
||
});
|
||
allButtons.filter(button => !preferredOrder.includes(button.textContent.trim())).forEach(button => nav.appendChild(button));
|
||
|
||
const buildGroup = (key, label) => {
|
||
const group = document.createElement('div');
|
||
group.className = `sun-nav-group sun-nav-${key}`;
|
||
group.dataset.label = label;
|
||
const items = groupMap[key] || [];
|
||
items.forEach(name => {
|
||
const button = byName.get(name);
|
||
if (!button) return;
|
||
button.classList.add('sun-nav-button', classMap[name] || '');
|
||
button.dataset.navLabel = name;
|
||
group.appendChild(button);
|
||
});
|
||
return group;
|
||
};
|
||
|
||
const groups = [
|
||
buildGroup('work','Заказы'),
|
||
buildGroup('operations','Операции'),
|
||
buildGroup('management','Управление')
|
||
];
|
||
nav.innerHTML = '';
|
||
const homeButton = byName.get('Главная');
|
||
if (homeButton) {
|
||
homeButton.classList.add('sun-nav-button','nav-home','sun-nav-home-standalone');
|
||
homeButton.dataset.navLabel = 'Главная';
|
||
nav.appendChild(homeButton);
|
||
}
|
||
groups.forEach(group => nav.appendChild(group));
|
||
|
||
// Any future buttons that were not in our map go into Management.
|
||
allButtons.filter(button => !preferredOrder.includes(button.textContent.trim())).forEach(button => {
|
||
button.classList.add('sun-nav-button');
|
||
groups[2].appendChild(button);
|
||
});
|
||
|
||
// Safe registration point for modules that add navigation after the sidebar has been grouped.
|
||
// Never insert a button relative to a node owned by another parent: register it in the target group instead.
|
||
const groupByKey = {work:groups[0], operations:groups[1], management:groups[2]};
|
||
window.sunRegisterNavButton = function(options={}){
|
||
const button=options.button;
|
||
if(!button) return null;
|
||
const targetGroup=groupByKey[options.group] || groupByKey.management || nav;
|
||
button.classList.add('sun-nav-button');
|
||
if(options.className) String(options.className).split(/\s+/).filter(Boolean).forEach(c=>button.classList.add(c));
|
||
if(options.navLabel) button.dataset.navLabel=options.navLabel;
|
||
const beforeLabel=String(options.beforeLabel||'').trim();
|
||
const before=beforeLabel?[...targetGroup.children].find(el=>el.tagName==='BUTTON'&&String(el.dataset.navLabel||el.textContent||'').trim()===beforeLabel):null;
|
||
if(before) before.before(button); else targetGroup.appendChild(button);
|
||
return button;
|
||
};
|
||
|
||
const style = document.createElement('style');
|
||
style.id = 'sun-nav-polish-style';
|
||
style.textContent = `
|
||
header{
|
||
min-height:92px;height:auto;padding:12px 20px;gap:22px;align-items:center;
|
||
box-shadow:0 5px 18px #102c3d1c;position:relative;z-index:90
|
||
}
|
||
header .brand{display:flex;align-items:center;flex:0 0 auto;white-space:nowrap;letter-spacing:.1px}
|
||
header nav{
|
||
flex:1;min-width:0;display:grid;grid-template-columns:minmax(360px,1.35fr) minmax(270px,.9fr) minmax(430px,1.5fr);
|
||
gap:8px;align-items:stretch;overflow:visible;white-space:normal
|
||
}
|
||
.sun-nav-group{
|
||
position:relative;display:grid;grid-auto-flow:column;grid-auto-columns:1fr;gap:4px;
|
||
padding:19px 5px 5px;border-radius:12px;background:#ffffff0b;border:1px solid #ffffff13;min-width:0
|
||
}
|
||
.sun-nav-group::before{
|
||
content:attr(data-label);position:absolute;left:10px;top:4px;color:#9fb4c2;font-size:9px;font-weight:700;
|
||
letter-spacing:.09em;text-transform:uppercase;line-height:1
|
||
}
|
||
header nav .sun-nav-button{
|
||
min-width:0;height:43px;padding:0 9px;border:0;border-radius:8px;background:transparent;color:#dce8ec;
|
||
border-bottom:0;font-size:12.5px;line-height:1.1;font-weight:700;position:relative;transition:.18s ease;
|
||
display:flex;align-items:center;justify-content:center;text-align:center;white-space:nowrap
|
||
}
|
||
header nav .sun-nav-button::before{
|
||
content:'';width:6px;height:6px;border-radius:50%;margin-right:7px;background:#7892a3;flex:0 0 auto;opacity:.9
|
||
}
|
||
header nav .sun-nav-button:hover{background:#ffffff12;color:#fff;transform:translateY(-1px)}
|
||
header nav .sun-nav-button.on{
|
||
color:#15364c;background:#fff;border:0;box-shadow:0 4px 13px #071b2730;transform:translateY(-1px)
|
||
}
|
||
header nav .sun-nav-button.on::after{
|
||
content:'';position:absolute;left:12px;right:12px;bottom:3px;height:2px;border-radius:2px;background:var(--g)
|
||
}
|
||
header nav .nav-home::before{background:#d9ad4b}
|
||
header nav .nav-new::before{background:#6fb48b}
|
||
header nav .nav-orders::before{background:#7da4c4}
|
||
header nav .nav-calendar::before{background:#a28bc3}
|
||
header nav .nav-production::before{background:#dc8e63}
|
||
header nav .nav-shopping::before{background:#72aa97}
|
||
header nav .nav-stock::before{background:#9aab68}
|
||
header nav .nav-clients::before{background:#6f9fc1}
|
||
header nav .nav-money::before{background:#d0a343}
|
||
header nav .nav-team::before{background:#a486b6}
|
||
header nav .nav-suppliers::before{background:#c77c69}
|
||
header nav .nav-stats::before{background:#7c98a8}
|
||
|
||
@media(max-width:1320px) and (min-width:901px){
|
||
header{align-items:flex-start}
|
||
header nav{grid-template-columns:1fr 1fr;grid-template-areas:'work work' 'operations management';gap:7px}
|
||
.sun-nav-work{grid-area:work}.sun-nav-operations{grid-area:operations}.sun-nav-management{grid-area:management}
|
||
.sun-nav-group{padding-top:18px}
|
||
}
|
||
@media(max-width:1050px) and (min-width:901px){
|
||
header{padding-inline:14px;gap:14px}.brand{font-size:18px!important}
|
||
header nav .sun-nav-button{font-size:11.5px;padding-inline:7px}
|
||
header nav .sun-nav-button::before{margin-right:5px;width:5px;height:5px}
|
||
}
|
||
@media(max-width:900px){
|
||
header{min-height:64px;padding:9px 14px 10px;display:flex;box-shadow:0 2px 10px #102c3d20}
|
||
header .brand{font-size:20px}
|
||
header nav{
|
||
position:fixed;left:0;right:0;bottom:0;z-index:100;display:flex!important;align-items:stretch;gap:0;
|
||
overflow-x:auto;overflow-y:hidden;background:var(--n);height:70px;padding:0 max(6px,env(safe-area-inset-right)) env(safe-area-inset-bottom) max(6px,env(safe-area-inset-left));
|
||
box-shadow:0 -3px 16px #102c3d35;scrollbar-width:none;-webkit-overflow-scrolling:touch;white-space:nowrap
|
||
}
|
||
header nav::-webkit-scrollbar{display:none}
|
||
.sun-nav-group{display:contents}
|
||
.sun-nav-group::before{display:none}
|
||
header nav .sun-nav-button{
|
||
flex:1 0 92px;height:64px;border-radius:0;padding:7px 6px 5px;font-size:11px;line-height:1.12;
|
||
display:flex;flex-direction:column;gap:5px;border-top:3px solid transparent;background:transparent;transform:none!important
|
||
}
|
||
header nav .sun-nav-button::before{width:7px;height:7px;margin:0}
|
||
header nav .sun-nav-button.on{color:#fff;background:#ffffff0d;box-shadow:none;border-top-color:var(--g)}
|
||
header nav .sun-nav-button.on::after{display:none}
|
||
}
|
||
@media(max-width:460px){header nav .sun-nav-button{flex-basis:84px;font-size:10.5px}}
|
||
@media print{header nav{display:none!important}}
|
||
`;
|
||
document.head.appendChild(style);
|
||
})();
|
||
|
||
|
||
/* ===== MODAL BACKDROP + ESC CLOSE v1 ===== */
|
||
(() => {
|
||
if (document.getElementById('sun-modal-backdrop-style')) return;
|
||
const style = document.createElement('style');
|
||
style.id = 'sun-modal-backdrop-style';
|
||
style.textContent = `
|
||
.modal{z-index:1000}
|
||
.modal.on{cursor:pointer}
|
||
.modal.on>.dialog{cursor:default;box-shadow:0 18px 55px #102c3d4f}
|
||
`;
|
||
document.head.appendChild(style);
|
||
|
||
document.addEventListener('click', event => {
|
||
const target = event.target;
|
||
if (!(target instanceof HTMLElement)) return;
|
||
if (!target.classList.contains('modal') || !target.classList.contains('on') || !target.id) return;
|
||
if (typeof window.closeModal === 'function') window.closeModal(target.id);
|
||
});
|
||
document.addEventListener('keydown', event => {
|
||
if (event.key !== 'Escape') return;
|
||
const opened = [...document.querySelectorAll('.modal.on')].pop();
|
||
if (opened?.id && typeof window.closeModal === 'function') window.closeModal(opened.id);
|
||
});
|
||
})();
|
||
|
||
/* ===== VISUAL STATISTICS DASHBOARD v1 ===== */
|
||
(() => {
|
||
const statsView = document.getElementById('stats');
|
||
const statsList = document.getElementById('statsList');
|
||
if (!statsView || !statsList || document.getElementById('sun-stats-visual-style')) return;
|
||
|
||
const style = document.createElement('style');
|
||
style.id = 'sun-stats-visual-style';
|
||
style.textContent = `
|
||
.stats-head{display:flex;justify-content:space-between;align-items:flex-start;gap:14px;flex-wrap:wrap}
|
||
.stats-controls{display:flex;align-items:flex-end;gap:9px;flex-wrap:wrap}.stats-controls label{min-width:190px}
|
||
.stats-kpi{display:grid;grid-template-columns:repeat(6,minmax(130px,1fr));gap:10px;margin:16px 0}
|
||
.stats-kpi>div{border:1px solid var(--l);border-radius:10px;background:#fafafa;padding:12px;min-height:78px}
|
||
.stats-kpi small{display:block;color:#68717a;margin-bottom:7px}.stats-kpi b{display:block;color:var(--n);font-size:20px;line-height:1.1}
|
||
.stats-grid{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(280px,.8fr);gap:12px;margin-top:12px}
|
||
.stats-panel{border:1px solid var(--l);border-radius:11px;background:#fff;padding:15px;min-width:0}
|
||
.stats-panel.wide{grid-column:1/-1}.stats-panel h2{font-size:18px;color:var(--n);margin:0}.stats-panel-head{display:flex;justify-content:space-between;gap:10px;align-items:center;margin-bottom:14px}.stats-panel-head small{color:#7b858c}
|
||
.stats-chart-scroll{overflow-x:auto;padding:8px 2px 2px}.stats-bars{height:250px;min-width:620px;display:flex;align-items:flex-end;gap:8px;padding:24px 8px 30px;border-left:1px solid #e0e4e6;border-bottom:1px solid #e0e4e6;background:repeating-linear-gradient(to top,#fff 0,#fff 49px,#edf0f1 50px)}
|
||
.stats-bar-item{height:100%;min-width:28px;flex:1;display:flex;flex-direction:column;justify-content:flex-end;align-items:center;position:relative}
|
||
.stats-bar-item .value{position:absolute;top:-20px;font-size:10px;color:#53636e;white-space:nowrap}.stats-bar-item i{display:block;width:min(34px,78%);min-height:3px;border-radius:7px 7px 2px 2px;background:linear-gradient(180deg,#d5ad52,#c38e25);box-shadow:0 4px 8px #c99a3222;transition:.18s}
|
||
.stats-bar-item:hover i{filter:brightness(1.08);transform:translateY(-2px)}.stats-bar-item label{position:absolute;bottom:-24px;font-size:10px;color:#68717a;white-space:nowrap;transform:rotate(-22deg);transform-origin:center}
|
||
.stats-donut-wrap{display:grid;grid-template-columns:160px 1fr;gap:16px;align-items:center}.stats-donut{width:156px;height:156px;border-radius:50%;display:grid;place-items:center;position:relative}.stats-donut::after{content:'';width:92px;height:92px;border-radius:50%;background:#fff;position:absolute;box-shadow:inset 0 0 0 1px #edf0f1}.stats-donut-center{position:relative;z-index:1;text-align:center}.stats-donut-center b{display:block;font-size:27px;color:var(--n)}.stats-donut-center small{color:#68717a}
|
||
.stats-legend{display:grid;gap:7px}.stats-legend-row{display:grid;grid-template-columns:10px 1fr auto;gap:8px;align-items:center;font-size:12px}.stats-legend-row i{width:9px;height:9px;border-radius:50%}.stats-legend-row b{color:var(--n)}
|
||
.stats-hbars{display:grid;gap:10px}.stats-hbar{display:grid;grid-template-columns:minmax(150px,1fr) minmax(140px,1.4fr) 92px;gap:10px;align-items:center}.stats-hbar>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}.stats-hbar-track{height:9px;border-radius:999px;background:#eef1f2;overflow:hidden}.stats-hbar-track i{display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,#5d90b0,#7baac5)}.stats-hbar strong{text-align:right;color:var(--n);font-size:12px;white-space:nowrap}
|
||
.stats-week{display:grid;grid-template-columns:repeat(7,1fr);gap:7px;align-items:end;min-height:160px}.stats-week-day{display:flex;flex-direction:column;align-items:center;justify-content:flex-end;height:160px}.stats-week-day i{display:block;width:70%;min-height:3px;border-radius:6px 6px 2px 2px;background:#7ba88d}.stats-week-day b{font-size:11px;color:var(--n);margin:5px 0}.stats-week-day small{font-size:10px;color:#68717a}
|
||
.stats-empty{padding:28px;text-align:center;color:#879198;border:1px dashed #d5dadd;border-radius:9px;background:#fafbfb}
|
||
@media(max-width:1180px){.stats-kpi{grid-template-columns:repeat(3,1fr)}.stats-grid{grid-template-columns:1fr}.stats-panel.wide{grid-column:auto}}
|
||
@media(max-width:720px){.stats-kpi{grid-template-columns:1fr 1fr}.stats-controls,.stats-controls label{width:100%;min-width:0}.stats-donut-wrap{grid-template-columns:1fr;justify-items:center}.stats-hbar{grid-template-columns:minmax(120px,1fr) 1fr}.stats-hbar strong{grid-column:1/-1;text-align:left}.stats-week{gap:3px}.stats-week-day small{font-size:9px}}
|
||
@media(max-width:430px){.stats-kpi{grid-template-columns:1fr}.stats-panel{padding:12px}}
|
||
@media print{#stats .stats-controls{display:none!important}#stats{margin:0!important}.stats-grid{display:block}.stats-panel{break-inside:avoid;margin-bottom:12px}.stats-chart-scroll{overflow:visible}.stats-bars{min-width:0}}
|
||
`;
|
||
document.head.appendChild(style);
|
||
|
||
statsView.innerHTML = `
|
||
<div class="stats-head">
|
||
<div><h1>Статистика</h1><p class="hint" style="margin:5px 0 0">Продажи, загрузка, статусы и популярные позиции в графическом виде.</p></div>
|
||
<div class="stats-controls"><label>Период<select id="statsPeriod"><option value="month">Текущий месяц</option><option value="90">Последние 90 дней</option><option value="year" selected>Последние 12 месяцев</option><option value="all">Всё время</option></select></label><button type="button" class="outline" id="statsPrint">Печать / PDF</button></div>
|
||
</div>
|
||
<div id="statsList"></div>`;
|
||
|
||
const el = id => document.getElementById(id);
|
||
const esc=window.SunSafe.escapeHTML;
|
||
const fmt = value => typeof window.money === 'function' ? window.money(value) : `${Math.round(Number(value || 0)).toLocaleString('ru-RU')} ₽`;
|
||
const totalFor = order => Number(order?.total || 0) || (order?.lines || []).reduce((sum,line) => {
|
||
const item = boxes.find(x => String(x.id) === String(line.id));
|
||
return sum + Number(item?.price || 0) * Number(line.qty || 0);
|
||
},0);
|
||
const parseDate = raw => raw ? new Date(`${raw}T12:00:00`) : null;
|
||
const today = () => { const d = new Date(); d.setHours(12,0,0,0); return d; };
|
||
const startOfMonth = d => new Date(d.getFullYear(), d.getMonth(), 1, 12);
|
||
const addDays = (d,n) => { const x = new Date(d); x.setDate(x.getDate()+n); return x; };
|
||
const addMonths = (d,n) => new Date(d.getFullYear(), d.getMonth()+n, 1, 12);
|
||
const pad = n => String(n).padStart(2,'0');
|
||
const dateKey = d => `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
|
||
const monthKey = d => `${d.getFullYear()}-${pad(d.getMonth()+1)}`;
|
||
const monthLabel = d => d.toLocaleDateString('ru-RU',{month:'short'}).replace('.','');
|
||
|
||
function boundsFor(period){
|
||
const now = today();
|
||
if (period === 'month') return {from:startOfMonth(now),to:addMonths(startOfMonth(now),1)};
|
||
if (period === '90') return {from:addDays(now,-89),to:addDays(now,1)};
|
||
if (period === 'year') return {from:addMonths(startOfMonth(now),-11),to:addMonths(startOfMonth(now),1)};
|
||
return {from:null,to:null};
|
||
}
|
||
function ordersForPeriod(period){
|
||
const {from,to} = boundsFor(period);
|
||
return orders.filter(order => {
|
||
if ((order.status || 'Новый') === 'Отменён') return false;
|
||
const d = parseDate(order.date);
|
||
if (!d) return period === 'all';
|
||
return (!from || d >= from) && (!to || d < to);
|
||
});
|
||
}
|
||
function trendBuckets(period, valid){
|
||
const now=today(), buckets=[];
|
||
if(period==='month'){
|
||
const first=startOfMonth(now), end=addMonths(first,1);
|
||
for(let d=new Date(first);d<end;d=addDays(d,1)) buckets.push({key:dateKey(d),label:String(d.getDate()),sum:0,count:0});
|
||
valid.forEach(o=>{const b=buckets.find(x=>x.key===o.date);if(b){b.sum+=totalFor(o);b.count++;}});
|
||
}else if(period==='90'){
|
||
const from=addDays(now,-89);
|
||
for(let i=0;i<13;i++){const start=addDays(from,i*7),end=addDays(start,7);buckets.push({key:dateKey(start),label:`${pad(start.getDate())}.${pad(start.getMonth()+1)}`,start,end,sum:0,count:0});}
|
||
valid.forEach(o=>{const d=parseDate(o.date);const b=buckets.find(x=>d&&d>=x.start&&d<x.end);if(b){b.sum+=totalFor(o);b.count++;}});
|
||
}else{
|
||
let first;
|
||
if(period==='all'){
|
||
const dates=valid.map(o=>parseDate(o.date)).filter(Boolean).sort((a,b)=>a-b);
|
||
first=dates.length?startOfMonth(dates[0]):addMonths(startOfMonth(now),-11);
|
||
const totalMonths=Math.max(1,(now.getFullYear()-first.getFullYear())*12+now.getMonth()-first.getMonth()+1);
|
||
if(totalMonths>24){
|
||
const years=[...new Set(valid.map(o=>parseDate(o.date)?.getFullYear()).filter(Boolean))].sort();
|
||
years.forEach(y=>buckets.push({key:String(y),label:String(y),sum:0,count:0}));
|
||
valid.forEach(o=>{const d=parseDate(o.date);const b=buckets.find(x=>x.key===String(d?.getFullYear()));if(b){b.sum+=totalFor(o);b.count++;}});
|
||
return buckets;
|
||
}
|
||
}else first=addMonths(startOfMonth(now),-11);
|
||
for(let d=new Date(first);d<=startOfMonth(now);d=addMonths(d,1)) buckets.push({key:monthKey(d),label:monthLabel(d),sum:0,count:0});
|
||
valid.forEach(o=>{const d=parseDate(o.date);const b=buckets.find(x=>x.key===monthKey(d));if(b){b.sum+=totalFor(o);b.count++;}});
|
||
}
|
||
return buckets;
|
||
}
|
||
function barChart(buckets){
|
||
const max=Math.max(1,...buckets.map(x=>x.sum));
|
||
return `<div class="stats-chart-scroll"><div class="stats-bars">${buckets.map(b=>{const height=Math.max(b.sum?3:0,Math.round(b.sum/max*100));return `<div class="stats-bar-item" title="${esc(b.label)}: ${esc(fmt(b.sum))}, заказов ${b.count}"><span class="value">${b.sum?esc(Math.round(b.sum/1000).toLocaleString('ru-RU')+' тыс.'):'0'}</span><i style="height:${height}%"></i><label>${esc(b.label)}</label></div>`;}).join('')}</div></div>`;
|
||
}
|
||
const statusColors={"Новый":"#6f9fc1","В работе":"#9a82bd","Собран":"#e0b242","Передан курьеру":"#df8c43","Отдан заказчику":"#66a77b","Отменён":"#c96b6b"};
|
||
function donut(valid){
|
||
const statuses=['Новый','В работе','Собран','Передан курьеру','Отдан заказчику'];
|
||
const counts=statuses.map(status=>({status,count:valid.filter(o=>(o.status||'Новый')===status).length,color:statusColors[status]}));
|
||
const total=Math.max(1,counts.reduce((s,x)=>s+x.count,0));let cursor=0;
|
||
const parts=counts.filter(x=>x.count).map(x=>{const from=cursor;cursor+=x.count/total*360;return `${x.color} ${from}deg ${cursor}deg`;});
|
||
const background=parts.length?`conic-gradient(${parts.join(',')})`:'#edf0f1';
|
||
return `<div class="stats-donut-wrap"><div class="stats-donut" style="background:${background}"><div class="stats-donut-center"><b>${valid.length}</b><small>заказов</small></div></div><div class="stats-legend">${counts.map(x=>`<div class="stats-legend-row"><i style="background:${x.color}"></i><span>${esc(x.status)}</span><b>${x.count}</b></div>`).join('')}</div></div>`;
|
||
}
|
||
function topItems(valid){
|
||
const data=new Map();
|
||
valid.forEach(order=>{
|
||
const seenInOrder=new Set();
|
||
(order.lines||[]).forEach(line=>{
|
||
const item=boxes.find(x=>String(x.id)===String(line.id));
|
||
if(!item)return;
|
||
const key=String(item.id),row=data.get(key)||{name:item.name,qty:0,orders:0};
|
||
row.qty+=Number(line.qty||0);
|
||
if(!seenInOrder.has(key)){row.orders+=1;seenInOrder.add(key);}
|
||
data.set(key,row);
|
||
});
|
||
});
|
||
const list=[...data.values()].sort((a,b)=>b.orders-a.orders||b.qty-a.qty||String(a.name).localeCompare(String(b.name),'ru')).slice(0,8),max=Math.max(1,...list.map(x=>x.orders));
|
||
return list.length?`<div class="stats-hbars">${list.map(x=>`<div class="stats-hbar" title="${esc(x.name)}: ${x.orders} заказов, ${x.qty} шт."><span>${esc(x.name)}</span><div class="stats-hbar-track"><i style="width:${Math.max(3,x.orders/max*100)}%"></i></div><strong>${x.orders} ${x.orders===1?'заказ':'заказов'} · ${x.qty} шт.</strong></div>`).join('')}</div>`:'<div class="stats-empty">Позиции пока не продавались.</div>';
|
||
}
|
||
function weekdayChart(valid){
|
||
const labels=['Пн','Вт','Ср','Чт','Пт','Сб','Вс'],data=labels.map(label=>({label,count:0,sum:0}));
|
||
valid.forEach(o=>{const d=parseDate(o.date);if(!d)return;const i=(d.getDay()+6)%7;data[i].count++;data[i].sum+=totalFor(o);});
|
||
const max=Math.max(1,...data.map(x=>x.count));
|
||
return `<div class="stats-week">${data.map(x=>`<div class="stats-week-day" title="${x.label}: ${x.count} заказов, ${esc(fmt(x.sum))}"><i style="height:${Math.max(x.count?4:0,x.count/max*100)}%"></i><b>${x.count}</b><small>${x.label}</small></div>`).join('')}</div>`;
|
||
}
|
||
|
||
function renderStatsVisual(){
|
||
const period=el('statsPeriod')?.value||'year',valid=ordersForPeriod(period),sales=valid.reduce((s,o)=>s+totalFor(o),0),paid=valid.reduce((s,o)=>s+Math.max(0,Number(o.prepayment||0)),0),completed=valid.filter(o=>(o.status||'Новый')==='Отдан заказчику').length,active=valid.filter(o=>!['Отдан заказчику','Отменён'].includes(o.status||'Новый')).length,clients=new Set(valid.map(o=>String(o.phone||o.contact||'').trim().toLowerCase()).filter(Boolean)).size,avg=valid.length?sales/valid.length:0;
|
||
const buckets=trendBuckets(period,valid);
|
||
el('statsList').innerHTML=`
|
||
<div class="stats-kpi"><div><small>Заказов</small><b>${valid.length}</b></div><div><small>Продажи</small><b>${fmt(sales)}</b></div><div><small>Средний чек</small><b>${fmt(avg)}</b></div><div><small>Получено предоплат</small><b>${fmt(paid)}</b></div><div><small>Активных</small><b>${active}</b></div><div><small>Клиентов</small><b>${clients}</b></div></div>
|
||
<div class="stats-grid">
|
||
<section class="stats-panel"><div class="stats-panel-head"><h2>Динамика продаж</h2><small>сумма по периоду · завершено ${completed}</small></div>${barChart(buckets)}</section>
|
||
<section class="stats-panel"><div class="stats-panel-head"><h2>Статусы заказов</h2><small>распределение</small></div>${donut(valid)}</section>
|
||
<section class="stats-panel wide"><div class="stats-panel-head"><h2>Популярные позиции</h2><small>по количеству заказов</small></div>${topItems(valid)}</section>
|
||
<section class="stats-panel wide"><div class="stats-panel-head"><h2>Загрузка по дням недели</h2><small>количество мероприятий</small></div>${weekdayChart(valid)}</section>
|
||
</div>`;
|
||
}
|
||
window.renderStats = renderStatsVisual;
|
||
el('statsPeriod').addEventListener('change',renderStatsVisual);
|
||
el('statsPrint').onclick=()=>window.print();
|
||
renderStatsVisual();
|
||
})();
|
||
|
||
/* ===== STATIC DELIVERY MAP PREVIEWS / ORDER GEO MODULE v2 ===== */
|
||
(() => {
|
||
if (document.getElementById('sun-static-maps-style')) return;
|
||
const nav=document.querySelector('header nav'),detailsBox=document.getElementById('details'),addressInput=document.getElementById('address');
|
||
if(!nav||!detailsBox||!addressInput) return;
|
||
|
||
// The old Yandex API keys/settings are no longer used in this build.
|
||
try{localStorage.removeItem('sunYandexMapsSettings');}catch(_){ }
|
||
|
||
const GEO_CACHE_KEY='sunStaticMapGeocodeCacheV2';
|
||
const DEFAULT_CITY='Санкт-Петербург';
|
||
const statusColors={"Новый":"#6f9fc1","В работе":"#9a82bd","Собран":"#e0b242","Передан курьеру":"#df8c43","Отдан заказчику":"#66a77b","Отменён":"#c96b6b"};
|
||
const esc=window.SunSafe.escapeHTML;
|
||
const fmt=v=>typeof window.money==='function'?window.money(v):`${Math.round(Number(v||0)).toLocaleString('ru-RU')} ₽`;
|
||
const el=id=>document.getElementById(id);
|
||
const todayKey=()=>new Date().toISOString().slice(0,10);
|
||
const plusDays=(key,n)=>{const d=new Date(`${key}T12:00:00`);d.setDate(d.getDate()+n);return d.toISOString().slice(0,10);};
|
||
const orderTotal=o=>Number(o?.total||0)||(o?.lines||[]).reduce((s,l)=>{const b=boxes.find(x=>String(x.id)===String(l.id));return s+Number(b?.price||0)*Number(l.qty||0);},0);
|
||
const geoValid=geo=>geo&&Number.isFinite(Number(geo.lon))&&Number.isFinite(Number(geo.lat));
|
||
let lastGeoRequest=0;
|
||
|
||
const style=document.createElement('style');
|
||
style.id='sun-static-maps-style';
|
||
style.textContent=`
|
||
.geo-order-box{margin:10px 0 14px;border:1px solid #dfe3e8;border-radius:14px;background:linear-gradient(145deg,#fff,#f8f9fc);padding:13px}.geo-order-head{display:flex;justify-content:space-between;align-items:flex-start;gap:10px}.geo-order-status{display:flex;align-items:center;gap:7px;font-size:12px;color:#68717a;margin-top:4px}.geo-dot{width:9px;height:9px;border-radius:50%;background:#b8c1c6;flex:0 0 auto}.geo-order-status.ok .geo-dot{background:#58a36f}.geo-order-status.warn .geo-dot{background:#d49a2a}.geo-order-actions{display:flex;gap:7px;flex-wrap:wrap;margin-top:11px}.geo-order-actions button{padding:7px 10px;font-size:12px}.geo-mini{margin-top:11px;border-radius:11px;overflow:hidden;border:1px solid #dfe3e5;background:#eef1f2;min-height:150px;display:grid;place-items:center}.geo-coords{padding:20px;text-align:center;color:#68717a;font-size:12px}.geo-coords img{width:58px;height:44px;object-fit:contain;opacity:.8;margin-bottom:7px}
|
||
.static-map-media{position:relative;background:#edf1f3;overflow:hidden}.static-map-frame{display:block;width:100%;height:100%;border:0;background:#edf1f3}.static-map-freeze{position:absolute;inset:0;z-index:3;cursor:default;background:transparent}.static-map-fallback{display:none;min-height:inherit;height:100%;place-items:center;text-align:center;padding:20px;background:linear-gradient(145deg,#f4f5f8,#eceff4);color:#68717a}.static-map-fallback img{width:70px;height:52px;object-fit:contain;display:block;margin:0 auto 8px}.static-map-credit{font-size:10px;color:#7e8790;margin-top:5px}
|
||
.map-page-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap}.map-page-head p{margin:5px 0 0;color:#68717a;max-width:720px}.map-toolbar{display:grid;grid-template-columns:180px 190px minmax(220px,1fr);gap:9px;align-items:end;margin:14px 0}.map-summary{display:grid;grid-template-columns:repeat(3,minmax(130px,1fr));gap:10px;margin:14px 0}.map-summary>div{border:1px solid #e2e5ea;border-radius:12px;background:#fafbfc;padding:12px}.map-summary small{display:block;color:#68717a}.map-summary b{display:block;color:var(--n);font-size:20px;margin-top:5px}.static-map-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(310px,1fr));gap:13px}.static-map-card{border:1px solid #dfe3e8;border-radius:14px;background:#fff;overflow:hidden;box-shadow:0 5px 18px #24314a0b;transition:.16s}.static-map-card:hover{transform:translateY(-1px);box-shadow:0 8px 24px #24314a12}.static-map-card .static-map-media{height:190px}.static-map-card-body{padding:12px}.static-map-card-head{display:flex;justify-content:space-between;gap:10px;align-items:flex-start}.static-map-card h3{margin:0;color:#1f2c46;font-size:16px}.static-map-card p{margin:5px 0;color:#657080;font-size:12px;line-height:1.4}.static-map-badge{display:inline-flex;align-items:center;padding:4px 8px;border-radius:999px;color:#fff;font-size:10px;font-weight:800;white-space:nowrap}.static-map-card-actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px}.static-map-card-actions button{font-size:11px;padding:6px 8px}.static-map-empty{grid-column:1/-1;padding:34px;text-align:center;border:1px dashed #cfd5dc;border-radius:14px;color:#7a8490;background:#fafbfc}.static-map-empty img{width:80px;height:58px;object-fit:contain;opacity:.8;display:block;margin:0 auto 10px}
|
||
.sun-doc-map{margin-top:20px;border:1px solid #d9dde0;border-radius:10px;overflow:hidden;break-inside:avoid}.sun-doc-map-head{padding:10px 12px;background:#f8f6f0;display:flex;justify-content:space-between;gap:10px}.sun-doc-map-head b{color:#15364c}.sun-doc-map .static-map-media{height:285px}.sun-doc-map-foot{padding:8px 12px;color:#68717a;font-size:11px}.sun-doc-map a{color:#15364c}.sun-doc-map-placeholder{padding:25px;text-align:center;background:#eef1f2;color:#68717a}
|
||
@media(max-width:850px){.map-toolbar{grid-template-columns:1fr 1fr}.map-toolbar label:last-child{grid-column:1/-1}.map-summary{grid-template-columns:1fr 1fr 1fr}}
|
||
@media(max-width:620px){.map-toolbar{grid-template-columns:1fr}.map-toolbar label:last-child{grid-column:auto}.map-summary{grid-template-columns:1fr 1fr}.static-map-grid{grid-template-columns:1fr}.sun-doc-map .static-map-media{height:210px}}
|
||
@media(max-width:430px){.map-summary{grid-template-columns:1fr}}
|
||
@media print{#order-map-view{display:none!important}.sun-doc-map .static-map-media{height:230px}.sun-doc-map-head{background:#f8f6f0!important;-webkit-print-color-adjust:exact;print-color-adjust:exact}.static-map-credit{font-size:8px}}
|
||
`;
|
||
document.head.appendChild(style);
|
||
|
||
function cleanMapAddress(value){
|
||
return String(value||'').split(',').filter(part=>!/^\s*(подъезд|этаж|кв\.?|квартира|офис)\b/i.test(part)).join(', ').trim();
|
||
}
|
||
function draftMapAddress(){
|
||
const region=el('deliveryRegion')?.value?.trim()||'';
|
||
const locality=el('deliveryLocality')?.value?.trim()||'';
|
||
const district=el('deliveryDistrict')?.value?.trim()||'';
|
||
const street=el('deliveryStreet')?.value?.trim()||'';
|
||
const house=el('deliveryHouse')?.value?.trim()||'';
|
||
const corpus=el('deliveryCorpus')?.value?.trim()||'';
|
||
const base=[region,locality,district,street,house?`д. ${house}`:'',corpus?`корп. ${corpus}`:''].filter(Boolean).join(', ');
|
||
return cleanMapAddress(base||addressInput.value);
|
||
}
|
||
function orderMapAddress(order){
|
||
const region=String(order?.deliveryRegion||'').trim(),locality=String(order?.deliveryLocality||'').trim(),district=String(order?.deliveryDistrict||'').trim();
|
||
const street=String(order?.deliveryStreet||'').trim(),house=String(order?.deliveryHouse||'').trim(),corpus=String(order?.deliveryCorpus||'').trim();
|
||
const base=[region,locality,district,street,house?`д. ${house}`:'',corpus?`корп. ${corpus}`:''].filter(Boolean).join(', ');
|
||
return cleanMapAddress(base||order?.address||'');
|
||
}
|
||
function withDefaultCity(address){
|
||
const a=String(address||'').trim();if(!a)return'';
|
||
if(/санкт[- ]?петербург|\bспб\b|москва|ленинградск|область|край|республика|красное\s+село|пушкин|павловск|петергоф|ломоносов|гатчин|стрельн|сестрорецк|кронштадт|всеволож|кудров|мурино|телези|ропш/i.test(a))return a;
|
||
return `${DEFAULT_CITY}, ${a}`;
|
||
}
|
||
function geoMatches(geo,mapAddress,fullAddress=''){
|
||
if(!geoValid(geo))return false;
|
||
const mapKey=String(mapAddress||'').trim();
|
||
if(Number(geo.geoVersion||0)!==3)return false;
|
||
return (geo.sourceMapAddress&&String(geo.sourceMapAddress)===mapKey)||(!geo.sourceMapAddress&&geo.sourceAddress&&String(geo.sourceAddress)===String(fullAddress||''));
|
||
}
|
||
function staticMapUrl(geo,{zoom=16}={}){
|
||
// Stable key-free OpenStreetMap embed. It is displayed as a frozen preview:
|
||
// interaction is blocked by an overlay, so for the user it behaves like a picture.
|
||
if(!geoValid(geo))return'';
|
||
const lat=Number(geo.lat),lon=Number(geo.lon);
|
||
const factor=Math.pow(2,16-Number(zoom||16));
|
||
const dLat=0.0032*factor,dLon=0.0062*factor;
|
||
const bbox=[lon-dLon,lat-dLat,lon+dLon,lat+dLat].map(v=>v.toFixed(6)).join(',');
|
||
return `https://www.openstreetmap.org/export/embed.html?bbox=${encodeURIComponent(bbox)}&layer=mapnik&marker=${encodeURIComponent(`${lat.toFixed(6)},${lon.toFixed(6)}`)}`;
|
||
}
|
||
function yandexUrl(geo,address){
|
||
if(geoValid(geo))return `https://yandex.ru/maps/?ll=${encodeURIComponent(`${geo.lon},${geo.lat}`)}&z=16&pt=${encodeURIComponent(`${geo.lon},${geo.lat},pm2rdm`)}`;
|
||
return `https://yandex.ru/maps/?text=${encodeURIComponent(address||'')}`;
|
||
}
|
||
function routeUrl(geo,address){
|
||
if(geoValid(geo))return `https://yandex.ru/maps/?rtext=${encodeURIComponent(`59.701058,29.977269~${geo.lat},${geo.lon}`)}&rtt=auto`;
|
||
return `https://yandex.ru/maps/?text=${encodeURIComponent(address||'')}`;
|
||
}
|
||
function mediaHtml(geo,label='Карта доставки'){
|
||
const src=staticMapUrl(geo);if(!src)return'';
|
||
return `<div class="static-map-media" aria-label="${esc(label)}"><iframe class="static-map-frame" loading="lazy" src="${esc(src)}" title="${esc(label)}" tabindex="-1" referrerpolicy="no-referrer-when-downgrade"></iframe><div class="static-map-freeze" aria-hidden="true"></div><div class="static-map-fallback"><div><img src="${window.CateriumBranding.logoHTML()}" alt="${window.CateriumBranding.nameHTML()}"><span>Не удалось загрузить превью карты</span></div></div></div>`;
|
||
}
|
||
function readGeoCache(){try{return JSON.parse(localStorage.getItem(GEO_CACHE_KEY)||'{}')||{};}catch{return{};}}
|
||
function writeGeoCache(cache){try{localStorage.setItem(GEO_CACHE_KEY,JSON.stringify(cache));}catch(_){ }}
|
||
async function geocode(mapAddress){
|
||
const clean=String(mapAddress||'').trim();if(!clean)throw new Error('Сначала укажите улицу и дом.');
|
||
const query=withDefaultCity(clean),key=query.toLowerCase(),cache=readGeoCache();
|
||
if(cache[key]&&geoValid(cache[key]))return {...cache[key],sourceMapAddress:clean};
|
||
const wait=Math.max(0,1100-(Date.now()-lastGeoRequest));if(wait)await new Promise(r=>setTimeout(r,wait));lastGeoRequest=Date.now();
|
||
let row=null,provider='nominatim';
|
||
try{
|
||
const url=`https://nominatim.openstreetmap.org/search?format=jsonv2&limit=5&countrycodes=ru&viewbox=27.3,61.4,34.5,58.0&bounded=1&accept-language=ru&q=${encodeURIComponent(query)}`;
|
||
const response=await sunFetchWithTimeout(url,{method:'GET',mode:'cors',credentials:'omit',cache:'default'},6000);
|
||
if(response.ok){const rows=await response.json();row=Array.isArray(rows)?rows[0]:null;}
|
||
}catch(_){row=null;}
|
||
if(!row||!Number.isFinite(Number(row.lon))||!Number.isFinite(Number(row.lat))){
|
||
try{
|
||
const url=`https://photon.komoot.io/api/?limit=1&lang=ru&q=${encodeURIComponent(query)}`;
|
||
const response=await sunFetchWithTimeout(url,{method:'GET',mode:'cors',credentials:'omit',cache:'default'},6000);
|
||
if(response.ok){
|
||
const data=await response.json(),f=Array.isArray(data?.features)?data.features[0]:null,c=f?.geometry?.coordinates;
|
||
if(Array.isArray(c)&&Number.isFinite(Number(c[0]))&&Number.isFinite(Number(c[1]))){
|
||
const pr=f.properties||{};
|
||
row={lon:Number(c[0]),lat:Number(c[1]),display_name:[pr.name,pr.street,pr.housenumber,pr.city,pr.state,pr.country].filter(Boolean).join(', ')||query};
|
||
provider='photon';
|
||
}
|
||
}
|
||
}catch(_){row=null;}
|
||
}
|
||
if(!row||!Number.isFinite(Number(row.lon))||!Number.isFinite(Number(row.lat)))throw new Error('Не удалось найти адрес. Проверьте улицу, дом и подключение к интернету.');
|
||
const result={lon:Number(row.lon),lat:Number(row.lat),formatted:String(row.display_name||query),sourceMapAddress:clean,sourceAddress:addressInput.value||'',verifiedAt:new Date().toISOString(),provider,geoVersion:3};
|
||
cache[key]=result;writeGeoCache(cache);return result;
|
||
}
|
||
function saveDraftGeo(geo){
|
||
draft.geo={...geo,sourceMapAddress:draftMapAddress(),sourceAddress:addressInput.value||'',verifiedAt:new Date().toISOString(),provider:'osm'};
|
||
if(draft.id){const idx=orders.findIndex(o=>String(o.id)===String(draft.id));if(idx>=0){orders[idx].geo=structuredClone(draft.geo);persist();}}
|
||
renderDraftGeo();return draft.geo;
|
||
}
|
||
async function ensureDraftGeo({silent=false,force=false}={}){
|
||
const mapAddress=draftMapAddress();
|
||
if(!mapAddress){if(!silent)alert('Сначала укажите улицу и дом.');return null;}
|
||
if(!force&&geoMatches(draft.geo,mapAddress,addressInput.value))return draft.geo;
|
||
try{return saveDraftGeo(await geocode(mapAddress));}catch(error){if(!silent)alert(error.message||'Не удалось создать карту.');return null;}
|
||
}
|
||
async function ensureOrderGeo(order,{silent=false,force=false}={}){
|
||
if(!order)return null;const mapAddress=orderMapAddress(order);
|
||
if(!mapAddress){if(!silent)alert('В заказе не указан адрес.');return null;}
|
||
if(!force&&geoMatches(order.geo,mapAddress,order.address))return order.geo;
|
||
try{const geo=await geocode(mapAddress);order.geo={...geo,sourceMapAddress:mapAddress,sourceAddress:order.address||'',verifiedAt:new Date().toISOString(),provider:'osm'};persist();if(draft.id&&String(draft.id)===String(order.id))draft.geo=structuredClone(order.geo);return order.geo;}catch(error){if(!silent)alert(error.message||'Не удалось создать карту.');return null;}
|
||
}
|
||
|
||
// Static map block in order details.
|
||
const addressLabel=addressInput.closest('label');
|
||
const geoBox=document.createElement('div');geoBox.id='orderGeoBox';geoBox.className='geo-order-box';
|
||
geoBox.innerHTML=`<div class="geo-order-head"><div><b>Проверка адреса доставки</b><div class="geo-order-status" id="orderGeoStatus"><i class="geo-dot"></i><span>Адрес ещё не проверен</span></div></div><span class="hint" id="orderGeoPrecision"></span></div><div class="geo-order-actions"><button type="button" class="primary" id="orderGeoCreate">Показать карту</button><button type="button" class="outline" id="orderGeoOpen">Открыть в Яндекс Картах</button><button type="button" class="outline" id="orderGeoRoute">Маршрут</button></div><div class="geo-mini" id="orderGeoMini"><div class="geo-coords"><img src="${window.CateriumBranding.logoHTML()}" alt="${window.CateriumBranding.nameHTML()}"><div>Укажите улицу и дом</div></div></div>`;
|
||
if(addressLabel)addressLabel.insertAdjacentElement('afterend',geoBox);else detailsBox.appendChild(geoBox);
|
||
|
||
function renderDraftGeo(){
|
||
const addr=addressInput.value||'',mapAddress=draftMapAddress(),valid=geoMatches(draft?.geo,mapAddress,addr),status=el('orderGeoStatus'),caption=el('orderGeoPrecision'),mini=el('orderGeoMini'),create=el('orderGeoCreate');
|
||
if(!mapAddress){status.className='geo-order-status';status.querySelector('span').textContent='Укажите улицу и дом';caption.textContent='';mini.innerHTML=("<div class=\"geo-coords\"><img src=\""+window.CateriumBranding.logoHTML()+"\" alt=\""+window.CateriumBranding.nameHTML()+"\"><div>После ввода адреса здесь появится превью карты.</div></div>");create.textContent='Показать карту';return;}
|
||
if(!valid){status.className='geo-order-status warn';status.querySelector('span').textContent='Превью не создано';caption.textContent='';mini.innerHTML=("<div class=\"geo-coords\"><img src=\""+window.CateriumBranding.logoHTML()+"\" alt=\""+window.CateriumBranding.nameHTML()+"\"><div>Нажмите «Показать карту».</div></div>");create.textContent='Показать карту';return;}
|
||
status.className='geo-order-status ok';status.querySelector('span').textContent='Адрес найден на карте';caption.textContent=`${Number(draft.geo.lat).toFixed(5)}, ${Number(draft.geo.lon).toFixed(5)}`;mini.innerHTML=mediaHtml(draft.geo,'Карта доставки')+'<div class="static-map-credit">© OpenStreetMap contributors</div>';create.textContent='Обновить карту';
|
||
}
|
||
|
||
// Separate page: no live map, only delivery cards with static images.
|
||
const mapButton=document.createElement('button');mapButton.type='button';mapButton.textContent='Карта';mapButton.classList.add('sun-nav-button','nav-map');mapButton.dataset.navLabel='Карта';nav.appendChild(mapButton);
|
||
const mapView=document.createElement('section');mapView.id='order-map-view';mapView.className='view page';
|
||
mapView.innerHTML=`<div class="map-page-head"><div><h1>Карты доставки</h1><p>Неинтерактивные превью с точкой доставки. Управление картой отключено; для навигации курьера используйте Яндекс Карты.</p></div></div><div class="map-toolbar"><label>Период<select id="mapPeriod"><option value="today">Сегодня</option><option value="tomorrow">Завтра</option><option value="week" selected>7 дней</option><option value="month">30 дней</option><option value="all">Все заказы</option></select></label><label>Статус<select id="mapStatus"><option value="all">Все статусы</option>${Object.keys(statusColors).map(s=>`<option>${s}</option>`).join('')}</select></label><label>Поиск<input id="mapSearch" placeholder="Заказ, клиент, улица..."></label></div><div class="map-summary"><div><small>Заказов</small><b id="mapCount">0</b></div><div><small>Превью готово</small><b id="mapReady">0</b></div><div><small>Без превью</small><b id="mapMissing">0</b></div></div><div id="staticMapGrid" class="static-map-grid"></div>`;
|
||
document.body.appendChild(mapView);
|
||
|
||
function filterMapOrders(){
|
||
const period=el('mapPeriod')?.value||'week',status=el('mapStatus')?.value||'all',q=(el('mapSearch')?.value||'').trim().toLowerCase(),today=todayKey();
|
||
return orders.slice().filter(o=>{
|
||
if(status!=='all'&&(o.status||'Новый')!==status)return false;
|
||
if(period==='today'&&o.date!==today)return false;if(period==='tomorrow'&&o.date!==plusDays(today,1))return false;if(period==='week'&&(o.date<today||o.date>plusDays(today,6)))return false;if(period==='month'&&(o.date<today||o.date>plusDays(today,29)))return false;
|
||
if(q&&!`${o.id} ${o.event||''} ${o.contact||''} ${o.phone||''} ${o.address||''}`.toLowerCase().includes(q))return false;return true;
|
||
}).sort((a,b)=>`${a.date||''}T${a.time||''}`.localeCompare(`${b.date||''}T${b.time||''}`));
|
||
}
|
||
function renderMapPage(){
|
||
const list=filterMapOrders(),ready=list.filter(o=>geoMatches(o.geo,orderMapAddress(o),o.address));el('mapCount').textContent=list.length;el('mapReady').textContent=ready.length;el('mapMissing').textContent=list.length-ready.length;
|
||
el('staticMapGrid').innerHTML=list.length?list.map(o=>{const has=geoMatches(o.geo,orderMapAddress(o),o.address),color=statusColors[o.status||'Новый']||'#6f9fc1',media=has?mediaHtml(o.geo,`Карта заказа №${o.id}`):`<div class="static-map-media"><div class="static-map-fallback" style="display:grid"><div><img src="${window.CateriumBranding.logoHTML()}" alt="${window.CateriumBranding.nameHTML()}"><span>Превью ещё не создано</span></div></div></div>`;return `<article class="static-map-card">${media}<div class="static-map-card-body"><div class="static-map-card-head"><div><h3>№${esc(o.id)} · ${esc(o.time||'—')}</h3><p>${esc(o.date||'—')} · ${esc(o.event||'Заказ')}</p></div><span class="static-map-badge" style="background:${color}">${esc(o.status||'Новый')}</span></div><p><b>${esc(o.contact||'Клиент не указан')}</b> · ${esc(fmt(orderTotal(o)))}</p><p>${esc(o.address||'Адрес не указан')}</p>${has?'<div class="static-map-credit">© OpenStreetMap contributors</div>':''}<div class="static-map-card-actions"><button type="button" class="outline" onclick="sunStaticMapOpenOrder('${esc(o.id)}')">Заказ</button>${o.address?`<button type="button" class="${has?'outline':'primary'}" onclick="sunStaticMapCreateForOrder('${esc(o.id)}')">${has?'Обновить':'Показать карту'}</button><button type="button" class="outline" onclick="sunStaticMapOpenYandex('${esc(o.id)}')">Яндекс Карты</button><button type="button" class="outline" onclick="sunStaticMapRoute('${esc(o.id)}')">Маршрут</button>`:''}</div></div></article>`;}).join(''):`<div class="static-map-empty"><img src="${window.CateriumBranding.logoHTML()}" alt="${window.CateriumBranding.nameHTML()}"><b>Заказов по выбранным фильтрам нет.</b></div>`;
|
||
}
|
||
window.sunStaticMapOpenOrder=id=>{const o=orders.find(x=>String(x.id)===String(id));if(o)window.openOrder?.(Number(o.id));};
|
||
window.sunStaticMapCreateForOrder=async id=>{const o=orders.find(x=>String(x.id)===String(id));if(!o)return;await ensureOrderGeo(o);renderMapPage();};
|
||
window.sunStaticMapOpenYandex=id=>{const o=orders.find(x=>String(x.id)===String(id));if(o)window.open(yandexUrl(o.geo,o.address),'_blank','noopener');};
|
||
window.sunStaticMapRoute=id=>{const o=orders.find(x=>String(x.id)===String(id));if(o)window.open(routeUrl(o.geo,o.address),'_blank','noopener');};
|
||
|
||
function documentMapBlock(){
|
||
const addr=addressInput.value||'',mapAddress=draftMapAddress(),geo=draft?.geo;if(!geoMatches(geo,mapAddress,addr))return'';
|
||
return `<div class="sun-doc-map"><div class="sun-doc-map-head"><b>Точка доставки</b><span>превью карты</span></div>${mediaHtml(geo,'Карта доставки')}<div class="sun-doc-map-foot">${esc(geo.formatted||addr)} · <a href="${esc(yandexUrl(geo,addr))}">Открыть в Яндекс Картах</a><div class="static-map-credit">© OpenStreetMap contributors</div></div></div>`;
|
||
}
|
||
function injectMapIntoDocument(){const doc=document.querySelector('#preview .sun-doc');if(!doc||doc.querySelector('.sun-doc-map'))return;const html=documentMapBlock();if(!html)return;const footer=doc.querySelector('.sun-doc-footer');if(footer)footer.insertAdjacentHTML('beforebegin',html);else doc.insertAdjacentHTML('beforeend',html);}
|
||
[...document.querySelectorAll('#new .actions button')].filter(b=>b.textContent.trim()==='Бланк заказа').forEach(button=>{const original=button.onclick;button.onclick=event=>{original?.call(button,event);injectMapIntoDocument();};});
|
||
|
||
el('orderGeoCreate').onclick=async()=>{await ensureDraftGeo();renderMapPage();};
|
||
el('orderGeoOpen').onclick=()=>window.open(yandexUrl(draft.geo,addressInput.value),'_blank','noopener');
|
||
el('orderGeoRoute').onclick=()=>window.open(routeUrl(draft.geo,addressInput.value),'_blank','noopener');
|
||
addressInput.addEventListener('input',()=>{const current=draftMapAddress();if(draft?.geo&&!geoMatches(draft.geo,current,addressInput.value))draft.geo=null;renderDraftGeo();});
|
||
['mapPeriod','mapStatus'].forEach(id=>el(id).addEventListener('change',renderMapPage));el('mapSearch').addEventListener('input',renderMapPage);
|
||
mapButton.onclick=()=>{document.querySelectorAll('.view').forEach(v=>v.classList.remove('on'));mapView.classList.add('on');[...nav.querySelectorAll('button')].forEach(b=>b.classList.remove('on'));mapButton.classList.add('on');renderMapPage();};
|
||
|
||
if(typeof window.openOrder==='function'){const previous=window.openOrder;window.openOrder=id=>{const result=previous(id);renderDraftGeo();return result;};}
|
||
if(typeof window.resetDraft==='function'){const previous=window.resetDraft;window.resetDraft=()=>{const result=previous();renderDraftGeo();return result;};}
|
||
if(typeof window.status==='function'){const previous=window.status;window.status=(id,value)=>{const result=previous(id,value);if(mapView.classList.contains('on'))renderMapPage();return result;};}
|
||
|
||
window.SunStaticMap={validGeo:geoValid,staticMapUrl,yandexUrl,routeUrl,mediaHtml,ensureOrderGeo,ensureDraftGeo,orderMapAddress,geoMatches};
|
||
renderDraftGeo();
|
||
})();
|
||
|
||
// === Preparations print preview + box photo sheet update ===
|
||
(() => {
|
||
const style = document.createElement('style');
|
||
style.textContent = `
|
||
.prep-doc{font-family:Arial,sans-serif;color:#203040;max-width:1000px;margin:0 auto}
|
||
.prep-doc-head{display:flex;justify-content:space-between;gap:20px;align-items:flex-start;border-bottom:2px solid #c99a32;padding-bottom:12px;margin-bottom:16px}
|
||
.prep-doc-brand{display:flex;align-items:center;gap:10px}.prep-doc-brand img{width:72px;height:52px;object-fit:contain}.prep-doc-brand b{display:block;color:#15364c;font-size:19px}.prep-doc-brand small{color:#68717a}
|
||
.prep-doc-title{text-align:right}.prep-doc-title h2{margin:0;color:#15364c;font-size:23px}.prep-doc-title p{margin:5px 0 0;color:#68717a}
|
||
.prep-doc-section{margin-top:18px}.prep-doc-section h3{margin:0 0 10px;color:#15364c;font-size:17px}
|
||
.prep-list{display:grid;grid-template-columns:1fr 1fr;gap:0 24px;border-top:1px solid #d9dde0}.prep-list p{display:flex;justify-content:space-between;gap:16px;margin:0;padding:8px 0;border-bottom:1px solid #e6e9eb}.prep-list p span{white-space:nowrap;font-weight:700;color:#15364c}
|
||
.prep-photo-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-top:10px}.prep-photo-card{border:1px solid #d9dde0;border-radius:7px;padding:7px;background:#fff;break-inside:avoid;page-break-inside:avoid}.prep-photo-card img{width:100%;aspect-ratio:4/3;object-fit:cover;border-radius:5px;display:block;background:#f0f2f4}.prep-photo-card b{display:block;font-size:11px;line-height:1.25;margin-top:6px;color:#203040;min-height:28px}.prep-photo-card small{display:flex;justify-content:space-between;gap:6px;margin-top:4px;color:#68717a;font-size:10px}.prep-photo-card .qty{font-weight:700;color:#15364c}
|
||
.prep-doc-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:20px;position:sticky;bottom:0;background:#fff;padding:12px 0 2px;border-top:1px solid #eef0f1}
|
||
.prep-empty{color:#879198;padding:14px 0}
|
||
@media(max-width:720px){.prep-doc-head{flex-direction:column}.prep-doc-title{text-align:left}.prep-list{grid-template-columns:1fr}.prep-photo-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||
@media print{
|
||
body *{visibility:hidden!important}
|
||
#preview,#preview *{visibility:visible!important}
|
||
#preview{display:block!important;position:absolute!important;inset:0!important;background:#fff!important;padding:0!important;margin:0!important}
|
||
#preview .dialog{display:block!important;width:100%!important;max-width:none!important;max-height:none!important;overflow:visible!important;padding:8mm!important;border-radius:0!important;box-shadow:none!important}
|
||
.prep-doc-actions,.dialog-head{display:none!important}
|
||
.prep-doc{max-width:none!important}
|
||
.prep-doc-head{margin-bottom:10px;padding-bottom:8px}
|
||
.prep-list{font-size:11px;gap:0 16px}.prep-list p{padding:5px 0}
|
||
.prep-doc-section{margin-top:12px}.prep-doc-section h3{font-size:14px;margin-bottom:6px}
|
||
.prep-photo-grid{grid-template-columns:repeat(4,1fr)!important;gap:5mm 4mm!important}
|
||
.prep-photo-card{height:61mm!important;padding:3mm!important;border-radius:2mm!important;overflow:hidden!important}
|
||
.prep-photo-card img{height:42mm!important;aspect-ratio:auto!important;object-fit:cover!important}
|
||
.prep-photo-card b{font-size:9.5pt!important;line-height:1.15!important;margin-top:2mm!important;min-height:0!important;max-height:8mm!important;overflow:hidden!important}
|
||
.prep-photo-card small{font-size:8pt!important;margin-top:1mm!important}
|
||
.prep-photo-card:nth-child(8n+1){break-before:auto}
|
||
.prep-photo-card:nth-child(8n){break-after:page;page-break-after:always}
|
||
.prep-photo-card:last-child{break-after:auto!important;page-break-after:auto!important}
|
||
@page{size:A4 portrait;margin:9mm}
|
||
}
|
||
`;
|
||
document.head.appendChild(style);
|
||
|
||
const htmlEscape = value => String(value ?? '').replace(/[&<>"']/g, ch => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch]));
|
||
const fmtQty = value => Number.isInteger(Number(value)) ? String(Number(value)) : Number(value).toLocaleString('ru-RU',{maximumFractionDigits:2});
|
||
|
||
function prepData(){
|
||
const map = new Map();
|
||
const selected = [];
|
||
(draft.lines || []).forEach(line => {
|
||
const item = boxes.find(x => String(x.id) === String(line.id));
|
||
if (!item) return;
|
||
const qty = Number(line.qty || 0);
|
||
if (qty <= 0) return;
|
||
selected.push({item, qty});
|
||
(Array.isArray(item.ingredients) ? item.ingredients : []).forEach(part => {
|
||
if (!part) return;
|
||
const name = String(part[0] || '').trim();
|
||
const unit = String(part[2] || '').trim();
|
||
if (!name) return;
|
||
const key = `${name}|${unit}`;
|
||
map.set(key, (map.get(key) || 0) + Number(part[1] || 0) * qty);
|
||
});
|
||
});
|
||
return {entries:[...map.entries()], selected};
|
||
}
|
||
|
||
window.openPreview = () => {
|
||
const {entries, selected} = prepData();
|
||
const dialog = document.querySelector('#preview .dialog');
|
||
if (!dialog) return;
|
||
const eventName = document.getElementById('event')?.value || '';
|
||
const orderDate = document.getElementById('date')?.value || '';
|
||
const orderTime = document.getElementById('time')?.value || '';
|
||
const photos = selected.filter(({item}) => [0,5].includes(Number(item.category ?? 0)) && item.photo);
|
||
|
||
const prepHtml = entries.length ? entries.map(([key,value]) => {
|
||
const split = key.lastIndexOf('|');
|
||
const product = split >= 0 ? key.slice(0,split) : key;
|
||
const unit = split >= 0 ? key.slice(split+1) : '';
|
||
return `<p><b>${htmlEscape(product)}</b><span>${htmlEscape(fmtQty(value))} ${htmlEscape(unit)}</span></p>`;
|
||
}).join('') : `<div class="prep-empty">В заказе нет позиций с составом для заготовок.</div>`;
|
||
|
||
const photoHtml = photos.length ? photos.map(({item,qty}) => `
|
||
<div class="prep-photo-card">
|
||
<img src="${htmlEscape(item.photo)}" alt="${htmlEscape(item.name)}">
|
||
<b>${htmlEscape(item.name)}</b>
|
||
<small><span>${htmlEscape(item.weight || '')}</span><span class="qty">× ${htmlEscape(qty)}</span></small>
|
||
</div>`).join('') : `<div class="prep-empty">В заказе нет боксов с фотографиями.</div>`;
|
||
|
||
dialog.innerHTML = `<div class="prep-doc">
|
||
<div class="prep-doc-head">
|
||
<div class="prep-doc-brand"><img src="${window.CateriumBranding.logoHTML()}" alt="${window.CateriumBranding.nameHTML()}"><div><b>${window.CateriumBranding.nameHTML()}</b><small>Производственная заготовка заказа</small></div></div>
|
||
<div class="prep-doc-title"><h2>Заготовки${draft.id ? ` · заказ № ${htmlEscape(draft.id)}` : ''}</h2><p>${htmlEscape(eventName)}${orderDate ? ` · ${htmlEscape(orderDate)}` : ''}${orderTime ? ` · ${htmlEscape(orderTime)}` : ''}</p></div>
|
||
</div>
|
||
<section class="prep-doc-section"><h3>Продукты и заготовки</h3><div class="prep-list">${prepHtml}</div></section>
|
||
<section class="prep-doc-section"><h3>Боксы в заказе</h3><div class="prep-photo-grid">${photoHtml}</div></section>
|
||
<div class="prep-doc-actions"><button class="primary" type="button" onclick="window.print()">Печать / PDF</button><button class="outline" type="button" onclick="closeModal('preview')">Закрыть</button></div>
|
||
</div>`;
|
||
modal('preview');
|
||
};
|
||
})();
|
||
|
||
/* END category-fix.js */
|
||
</script><script>
|
||
/* BEGIN qr-lite.js — PORTABLE INLINE */
|
||
/* QR Code generator based on Kazuhiko Arase's MIT-licensed QRCode for JavaScript. */
|
||
(function(){
|
||
'use strict';
|
||
|
||
// ---- QRErrorCorrectLevel.js ----
|
||
var QRErrorCorrectLevel = {
|
||
L : 1,
|
||
M : 0,
|
||
Q : 3,
|
||
H : 2
|
||
};
|
||
|
||
|
||
|
||
// ---- QRMode.js ----
|
||
var QRMode = {
|
||
MODE_NUMBER : 1 << 0,
|
||
MODE_ALPHA_NUM : 1 << 1,
|
||
MODE_8BIT_BYTE : 1 << 2,
|
||
MODE_KANJI : 1 << 3
|
||
};
|
||
|
||
|
||
// ---- QRMaskPattern.js ----
|
||
var QRMaskPattern = {
|
||
PATTERN000 : 0,
|
||
PATTERN001 : 1,
|
||
PATTERN010 : 2,
|
||
PATTERN011 : 3,
|
||
PATTERN100 : 4,
|
||
PATTERN101 : 5,
|
||
PATTERN110 : 6,
|
||
PATTERN111 : 7
|
||
};
|
||
|
||
|
||
// ---- QRMath.js ----
|
||
var QRMath = {
|
||
|
||
glog : function(n) {
|
||
|
||
if (n < 1) {
|
||
throw new Error("glog(" + n + ")");
|
||
}
|
||
|
||
return QRMath.LOG_TABLE[n];
|
||
},
|
||
|
||
gexp : function(n) {
|
||
|
||
while (n < 0) {
|
||
n += 255;
|
||
}
|
||
|
||
while (n >= 256) {
|
||
n -= 255;
|
||
}
|
||
|
||
return QRMath.EXP_TABLE[n];
|
||
},
|
||
|
||
EXP_TABLE : new Array(256),
|
||
|
||
LOG_TABLE : new Array(256)
|
||
|
||
};
|
||
|
||
for (var i = 0; i < 8; i++) {
|
||
QRMath.EXP_TABLE[i] = 1 << i;
|
||
}
|
||
for (var i = 8; i < 256; i++) {
|
||
QRMath.EXP_TABLE[i] = QRMath.EXP_TABLE[i - 4]
|
||
^ QRMath.EXP_TABLE[i - 5]
|
||
^ QRMath.EXP_TABLE[i - 6]
|
||
^ QRMath.EXP_TABLE[i - 8];
|
||
}
|
||
for (var i = 0; i < 255; i++) {
|
||
QRMath.LOG_TABLE[QRMath.EXP_TABLE[i] ] = i;
|
||
}
|
||
|
||
|
||
|
||
// ---- QRPolynomial.js ----
|
||
function QRPolynomial(num, shift) {
|
||
if (num.length === undefined) {
|
||
throw new Error(num.length + "/" + shift);
|
||
}
|
||
|
||
var offset = 0;
|
||
|
||
while (offset < num.length && num[offset] === 0) {
|
||
offset++;
|
||
}
|
||
|
||
this.num = new Array(num.length - offset + shift);
|
||
for (var i = 0; i < num.length - offset; i++) {
|
||
this.num[i] = num[i + offset];
|
||
}
|
||
}
|
||
|
||
QRPolynomial.prototype = {
|
||
|
||
get : function(index) {
|
||
return this.num[index];
|
||
},
|
||
|
||
getLength : function() {
|
||
return this.num.length;
|
||
},
|
||
|
||
multiply : function(e) {
|
||
|
||
var num = new Array(this.getLength() + e.getLength() - 1);
|
||
|
||
for (var i = 0; i < this.getLength(); i++) {
|
||
for (var j = 0; j < e.getLength(); j++) {
|
||
num[i + j] ^= QRMath.gexp(QRMath.glog(this.get(i) ) + QRMath.glog(e.get(j) ) );
|
||
}
|
||
}
|
||
|
||
return new QRPolynomial(num, 0);
|
||
},
|
||
|
||
mod : function(e) {
|
||
|
||
if (this.getLength() - e.getLength() < 0) {
|
||
return this;
|
||
}
|
||
|
||
var ratio = QRMath.glog(this.get(0) ) - QRMath.glog(e.get(0) );
|
||
|
||
var num = new Array(this.getLength() );
|
||
|
||
for (var i = 0; i < this.getLength(); i++) {
|
||
num[i] = this.get(i);
|
||
}
|
||
|
||
for (var x = 0; x < e.getLength(); x++) {
|
||
num[x] ^= QRMath.gexp(QRMath.glog(e.get(x) ) + ratio);
|
||
}
|
||
|
||
// recursive call
|
||
return new QRPolynomial(num, 0).mod(e);
|
||
}
|
||
};
|
||
|
||
|
||
|
||
// ---- QRRSBlock.js ----
|
||
function QRRSBlock(totalCount, dataCount) {
|
||
this.totalCount = totalCount;
|
||
this.dataCount = dataCount;
|
||
}
|
||
|
||
QRRSBlock.RS_BLOCK_TABLE = [
|
||
|
||
// L
|
||
// M
|
||
// Q
|
||
// H
|
||
|
||
// 1
|
||
[1, 26, 19],
|
||
[1, 26, 16],
|
||
[1, 26, 13],
|
||
[1, 26, 9],
|
||
|
||
// 2
|
||
[1, 44, 34],
|
||
[1, 44, 28],
|
||
[1, 44, 22],
|
||
[1, 44, 16],
|
||
|
||
// 3
|
||
[1, 70, 55],
|
||
[1, 70, 44],
|
||
[2, 35, 17],
|
||
[2, 35, 13],
|
||
|
||
// 4
|
||
[1, 100, 80],
|
||
[2, 50, 32],
|
||
[2, 50, 24],
|
||
[4, 25, 9],
|
||
|
||
// 5
|
||
[1, 134, 108],
|
||
[2, 67, 43],
|
||
[2, 33, 15, 2, 34, 16],
|
||
[2, 33, 11, 2, 34, 12],
|
||
|
||
// 6
|
||
[2, 86, 68],
|
||
[4, 43, 27],
|
||
[4, 43, 19],
|
||
[4, 43, 15],
|
||
|
||
// 7
|
||
[2, 98, 78],
|
||
[4, 49, 31],
|
||
[2, 32, 14, 4, 33, 15],
|
||
[4, 39, 13, 1, 40, 14],
|
||
|
||
// 8
|
||
[2, 121, 97],
|
||
[2, 60, 38, 2, 61, 39],
|
||
[4, 40, 18, 2, 41, 19],
|
||
[4, 40, 14, 2, 41, 15],
|
||
|
||
// 9
|
||
[2, 146, 116],
|
||
[3, 58, 36, 2, 59, 37],
|
||
[4, 36, 16, 4, 37, 17],
|
||
[4, 36, 12, 4, 37, 13],
|
||
|
||
// 10
|
||
[2, 86, 68, 2, 87, 69],
|
||
[4, 69, 43, 1, 70, 44],
|
||
[6, 43, 19, 2, 44, 20],
|
||
[6, 43, 15, 2, 44, 16],
|
||
|
||
// 11
|
||
[4, 101, 81],
|
||
[1, 80, 50, 4, 81, 51],
|
||
[4, 50, 22, 4, 51, 23],
|
||
[3, 36, 12, 8, 37, 13],
|
||
|
||
// 12
|
||
[2, 116, 92, 2, 117, 93],
|
||
[6, 58, 36, 2, 59, 37],
|
||
[4, 46, 20, 6, 47, 21],
|
||
[7, 42, 14, 4, 43, 15],
|
||
|
||
// 13
|
||
[4, 133, 107],
|
||
[8, 59, 37, 1, 60, 38],
|
||
[8, 44, 20, 4, 45, 21],
|
||
[12, 33, 11, 4, 34, 12],
|
||
|
||
// 14
|
||
[3, 145, 115, 1, 146, 116],
|
||
[4, 64, 40, 5, 65, 41],
|
||
[11, 36, 16, 5, 37, 17],
|
||
[11, 36, 12, 5, 37, 13],
|
||
|
||
// 15
|
||
[5, 109, 87, 1, 110, 88],
|
||
[5, 65, 41, 5, 66, 42],
|
||
[5, 54, 24, 7, 55, 25],
|
||
[11, 36, 12],
|
||
|
||
// 16
|
||
[5, 122, 98, 1, 123, 99],
|
||
[7, 73, 45, 3, 74, 46],
|
||
[15, 43, 19, 2, 44, 20],
|
||
[3, 45, 15, 13, 46, 16],
|
||
|
||
// 17
|
||
[1, 135, 107, 5, 136, 108],
|
||
[10, 74, 46, 1, 75, 47],
|
||
[1, 50, 22, 15, 51, 23],
|
||
[2, 42, 14, 17, 43, 15],
|
||
|
||
// 18
|
||
[5, 150, 120, 1, 151, 121],
|
||
[9, 69, 43, 4, 70, 44],
|
||
[17, 50, 22, 1, 51, 23],
|
||
[2, 42, 14, 19, 43, 15],
|
||
|
||
// 19
|
||
[3, 141, 113, 4, 142, 114],
|
||
[3, 70, 44, 11, 71, 45],
|
||
[17, 47, 21, 4, 48, 22],
|
||
[9, 39, 13, 16, 40, 14],
|
||
|
||
// 20
|
||
[3, 135, 107, 5, 136, 108],
|
||
[3, 67, 41, 13, 68, 42],
|
||
[15, 54, 24, 5, 55, 25],
|
||
[15, 43, 15, 10, 44, 16],
|
||
|
||
// 21
|
||
[4, 144, 116, 4, 145, 117],
|
||
[17, 68, 42],
|
||
[17, 50, 22, 6, 51, 23],
|
||
[19, 46, 16, 6, 47, 17],
|
||
|
||
// 22
|
||
[2, 139, 111, 7, 140, 112],
|
||
[17, 74, 46],
|
||
[7, 54, 24, 16, 55, 25],
|
||
[34, 37, 13],
|
||
|
||
// 23
|
||
[4, 151, 121, 5, 152, 122],
|
||
[4, 75, 47, 14, 76, 48],
|
||
[11, 54, 24, 14, 55, 25],
|
||
[16, 45, 15, 14, 46, 16],
|
||
|
||
// 24
|
||
[6, 147, 117, 4, 148, 118],
|
||
[6, 73, 45, 14, 74, 46],
|
||
[11, 54, 24, 16, 55, 25],
|
||
[30, 46, 16, 2, 47, 17],
|
||
|
||
// 25
|
||
[8, 132, 106, 4, 133, 107],
|
||
[8, 75, 47, 13, 76, 48],
|
||
[7, 54, 24, 22, 55, 25],
|
||
[22, 45, 15, 13, 46, 16],
|
||
|
||
// 26
|
||
[10, 142, 114, 2, 143, 115],
|
||
[19, 74, 46, 4, 75, 47],
|
||
[28, 50, 22, 6, 51, 23],
|
||
[33, 46, 16, 4, 47, 17],
|
||
|
||
// 27
|
||
[8, 152, 122, 4, 153, 123],
|
||
[22, 73, 45, 3, 74, 46],
|
||
[8, 53, 23, 26, 54, 24],
|
||
[12, 45, 15, 28, 46, 16],
|
||
|
||
// 28
|
||
[3, 147, 117, 10, 148, 118],
|
||
[3, 73, 45, 23, 74, 46],
|
||
[4, 54, 24, 31, 55, 25],
|
||
[11, 45, 15, 31, 46, 16],
|
||
|
||
// 29
|
||
[7, 146, 116, 7, 147, 117],
|
||
[21, 73, 45, 7, 74, 46],
|
||
[1, 53, 23, 37, 54, 24],
|
||
[19, 45, 15, 26, 46, 16],
|
||
|
||
// 30
|
||
[5, 145, 115, 10, 146, 116],
|
||
[19, 75, 47, 10, 76, 48],
|
||
[15, 54, 24, 25, 55, 25],
|
||
[23, 45, 15, 25, 46, 16],
|
||
|
||
// 31
|
||
[13, 145, 115, 3, 146, 116],
|
||
[2, 74, 46, 29, 75, 47],
|
||
[42, 54, 24, 1, 55, 25],
|
||
[23, 45, 15, 28, 46, 16],
|
||
|
||
// 32
|
||
[17, 145, 115],
|
||
[10, 74, 46, 23, 75, 47],
|
||
[10, 54, 24, 35, 55, 25],
|
||
[19, 45, 15, 35, 46, 16],
|
||
|
||
// 33
|
||
[17, 145, 115, 1, 146, 116],
|
||
[14, 74, 46, 21, 75, 47],
|
||
[29, 54, 24, 19, 55, 25],
|
||
[11, 45, 15, 46, 46, 16],
|
||
|
||
// 34
|
||
[13, 145, 115, 6, 146, 116],
|
||
[14, 74, 46, 23, 75, 47],
|
||
[44, 54, 24, 7, 55, 25],
|
||
[59, 46, 16, 1, 47, 17],
|
||
|
||
// 35
|
||
[12, 151, 121, 7, 152, 122],
|
||
[12, 75, 47, 26, 76, 48],
|
||
[39, 54, 24, 14, 55, 25],
|
||
[22, 45, 15, 41, 46, 16],
|
||
|
||
// 36
|
||
[6, 151, 121, 14, 152, 122],
|
||
[6, 75, 47, 34, 76, 48],
|
||
[46, 54, 24, 10, 55, 25],
|
||
[2, 45, 15, 64, 46, 16],
|
||
|
||
// 37
|
||
[17, 152, 122, 4, 153, 123],
|
||
[29, 74, 46, 14, 75, 47],
|
||
[49, 54, 24, 10, 55, 25],
|
||
[24, 45, 15, 46, 46, 16],
|
||
|
||
// 38
|
||
[4, 152, 122, 18, 153, 123],
|
||
[13, 74, 46, 32, 75, 47],
|
||
[48, 54, 24, 14, 55, 25],
|
||
[42, 45, 15, 32, 46, 16],
|
||
|
||
// 39
|
||
[20, 147, 117, 4, 148, 118],
|
||
[40, 75, 47, 7, 76, 48],
|
||
[43, 54, 24, 22, 55, 25],
|
||
[10, 45, 15, 67, 46, 16],
|
||
|
||
// 40
|
||
[19, 148, 118, 6, 149, 119],
|
||
[18, 75, 47, 31, 76, 48],
|
||
[34, 54, 24, 34, 55, 25],
|
||
[20, 45, 15, 61, 46, 16]
|
||
];
|
||
|
||
QRRSBlock.getRSBlocks = function(typeNumber, errorCorrectLevel) {
|
||
|
||
var rsBlock = QRRSBlock.getRsBlockTable(typeNumber, errorCorrectLevel);
|
||
|
||
if (rsBlock === undefined) {
|
||
throw new Error("bad rs block @ typeNumber:" + typeNumber + "/errorCorrectLevel:" + errorCorrectLevel);
|
||
}
|
||
|
||
var length = rsBlock.length / 3;
|
||
|
||
var list = [];
|
||
|
||
for (var i = 0; i < length; i++) {
|
||
|
||
var count = rsBlock[i * 3 + 0];
|
||
var totalCount = rsBlock[i * 3 + 1];
|
||
var dataCount = rsBlock[i * 3 + 2];
|
||
|
||
for (var j = 0; j < count; j++) {
|
||
list.push(new QRRSBlock(totalCount, dataCount) );
|
||
}
|
||
}
|
||
|
||
return list;
|
||
};
|
||
|
||
QRRSBlock.getRsBlockTable = function(typeNumber, errorCorrectLevel) {
|
||
|
||
switch(errorCorrectLevel) {
|
||
case QRErrorCorrectLevel.L :
|
||
return QRRSBlock.RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 0];
|
||
case QRErrorCorrectLevel.M :
|
||
return QRRSBlock.RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 1];
|
||
case QRErrorCorrectLevel.Q :
|
||
return QRRSBlock.RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 2];
|
||
case QRErrorCorrectLevel.H :
|
||
return QRRSBlock.RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 3];
|
||
default :
|
||
return undefined;
|
||
}
|
||
};
|
||
|
||
|
||
|
||
// ---- QRBitBuffer.js ----
|
||
function QRBitBuffer() {
|
||
this.buffer = [];
|
||
this.length = 0;
|
||
}
|
||
|
||
QRBitBuffer.prototype = {
|
||
|
||
get : function(index) {
|
||
var bufIndex = Math.floor(index / 8);
|
||
return ( (this.buffer[bufIndex] >>> (7 - index % 8) ) & 1) == 1;
|
||
},
|
||
|
||
put : function(num, length) {
|
||
for (var i = 0; i < length; i++) {
|
||
this.putBit( ( (num >>> (length - i - 1) ) & 1) == 1);
|
||
}
|
||
},
|
||
|
||
getLengthInBits : function() {
|
||
return this.length;
|
||
},
|
||
|
||
putBit : function(bit) {
|
||
|
||
var bufIndex = Math.floor(this.length / 8);
|
||
if (this.buffer.length <= bufIndex) {
|
||
this.buffer.push(0);
|
||
}
|
||
|
||
if (bit) {
|
||
this.buffer[bufIndex] |= (0x80 >>> (this.length % 8) );
|
||
}
|
||
|
||
this.length++;
|
||
}
|
||
};
|
||
|
||
|
||
|
||
// ---- QR8bitByte.js ----
|
||
function QR8bitByte(data) {
|
||
this.mode = QRMode.MODE_8BIT_BYTE;
|
||
this.data = data;
|
||
}
|
||
|
||
QR8bitByte.prototype = {
|
||
|
||
getLength : function() {
|
||
return this.data.length;
|
||
},
|
||
|
||
write : function(buffer) {
|
||
for (var i = 0; i < this.data.length; i++) {
|
||
// not JIS ...
|
||
buffer.put(this.data.charCodeAt(i), 8);
|
||
}
|
||
}
|
||
};
|
||
|
||
|
||
|
||
// ---- QRUtil.js ----
|
||
var QRUtil = {
|
||
|
||
PATTERN_POSITION_TABLE : [
|
||
[],
|
||
[6, 18],
|
||
[6, 22],
|
||
[6, 26],
|
||
[6, 30],
|
||
[6, 34],
|
||
[6, 22, 38],
|
||
[6, 24, 42],
|
||
[6, 26, 46],
|
||
[6, 28, 50],
|
||
[6, 30, 54],
|
||
[6, 32, 58],
|
||
[6, 34, 62],
|
||
[6, 26, 46, 66],
|
||
[6, 26, 48, 70],
|
||
[6, 26, 50, 74],
|
||
[6, 30, 54, 78],
|
||
[6, 30, 56, 82],
|
||
[6, 30, 58, 86],
|
||
[6, 34, 62, 90],
|
||
[6, 28, 50, 72, 94],
|
||
[6, 26, 50, 74, 98],
|
||
[6, 30, 54, 78, 102],
|
||
[6, 28, 54, 80, 106],
|
||
[6, 32, 58, 84, 110],
|
||
[6, 30, 58, 86, 114],
|
||
[6, 34, 62, 90, 118],
|
||
[6, 26, 50, 74, 98, 122],
|
||
[6, 30, 54, 78, 102, 126],
|
||
[6, 26, 52, 78, 104, 130],
|
||
[6, 30, 56, 82, 108, 134],
|
||
[6, 34, 60, 86, 112, 138],
|
||
[6, 30, 58, 86, 114, 142],
|
||
[6, 34, 62, 90, 118, 146],
|
||
[6, 30, 54, 78, 102, 126, 150],
|
||
[6, 24, 50, 76, 102, 128, 154],
|
||
[6, 28, 54, 80, 106, 132, 158],
|
||
[6, 32, 58, 84, 110, 136, 162],
|
||
[6, 26, 54, 82, 110, 138, 166],
|
||
[6, 30, 58, 86, 114, 142, 170]
|
||
],
|
||
|
||
G15 : (1 << 10) | (1 << 8) | (1 << 5) | (1 << 4) | (1 << 2) | (1 << 1) | (1 << 0),
|
||
G18 : (1 << 12) | (1 << 11) | (1 << 10) | (1 << 9) | (1 << 8) | (1 << 5) | (1 << 2) | (1 << 0),
|
||
G15_MASK : (1 << 14) | (1 << 12) | (1 << 10) | (1 << 4) | (1 << 1),
|
||
|
||
getBCHTypeInfo : function(data) {
|
||
var d = data << 10;
|
||
while (QRUtil.getBCHDigit(d) - QRUtil.getBCHDigit(QRUtil.G15) >= 0) {
|
||
d ^= (QRUtil.G15 << (QRUtil.getBCHDigit(d) - QRUtil.getBCHDigit(QRUtil.G15) ) );
|
||
}
|
||
return ( (data << 10) | d) ^ QRUtil.G15_MASK;
|
||
},
|
||
|
||
getBCHTypeNumber : function(data) {
|
||
var d = data << 12;
|
||
while (QRUtil.getBCHDigit(d) - QRUtil.getBCHDigit(QRUtil.G18) >= 0) {
|
||
d ^= (QRUtil.G18 << (QRUtil.getBCHDigit(d) - QRUtil.getBCHDigit(QRUtil.G18) ) );
|
||
}
|
||
return (data << 12) | d;
|
||
},
|
||
|
||
getBCHDigit : function(data) {
|
||
|
||
var digit = 0;
|
||
|
||
while (data !== 0) {
|
||
digit++;
|
||
data >>>= 1;
|
||
}
|
||
|
||
return digit;
|
||
},
|
||
|
||
getPatternPosition : function(typeNumber) {
|
||
return QRUtil.PATTERN_POSITION_TABLE[typeNumber - 1];
|
||
},
|
||
|
||
getMask : function(maskPattern, i, j) {
|
||
|
||
switch (maskPattern) {
|
||
|
||
case QRMaskPattern.PATTERN000 : return (i + j) % 2 === 0;
|
||
case QRMaskPattern.PATTERN001 : return i % 2 === 0;
|
||
case QRMaskPattern.PATTERN010 : return j % 3 === 0;
|
||
case QRMaskPattern.PATTERN011 : return (i + j) % 3 === 0;
|
||
case QRMaskPattern.PATTERN100 : return (Math.floor(i / 2) + Math.floor(j / 3) ) % 2 === 0;
|
||
case QRMaskPattern.PATTERN101 : return (i * j) % 2 + (i * j) % 3 === 0;
|
||
case QRMaskPattern.PATTERN110 : return ( (i * j) % 2 + (i * j) % 3) % 2 === 0;
|
||
case QRMaskPattern.PATTERN111 : return ( (i * j) % 3 + (i + j) % 2) % 2 === 0;
|
||
|
||
default :
|
||
throw new Error("bad maskPattern:" + maskPattern);
|
||
}
|
||
},
|
||
|
||
getErrorCorrectPolynomial : function(errorCorrectLength) {
|
||
|
||
var a = new QRPolynomial([1], 0);
|
||
|
||
for (var i = 0; i < errorCorrectLength; i++) {
|
||
a = a.multiply(new QRPolynomial([1, QRMath.gexp(i)], 0) );
|
||
}
|
||
|
||
return a;
|
||
},
|
||
|
||
getLengthInBits : function(mode, type) {
|
||
|
||
if (1 <= type && type < 10) {
|
||
|
||
// 1 - 9
|
||
|
||
switch(mode) {
|
||
case QRMode.MODE_NUMBER : return 10;
|
||
case QRMode.MODE_ALPHA_NUM : return 9;
|
||
case QRMode.MODE_8BIT_BYTE : return 8;
|
||
case QRMode.MODE_KANJI : return 8;
|
||
default :
|
||
throw new Error("mode:" + mode);
|
||
}
|
||
|
||
} else if (type < 27) {
|
||
|
||
// 10 - 26
|
||
|
||
switch(mode) {
|
||
case QRMode.MODE_NUMBER : return 12;
|
||
case QRMode.MODE_ALPHA_NUM : return 11;
|
||
case QRMode.MODE_8BIT_BYTE : return 16;
|
||
case QRMode.MODE_KANJI : return 10;
|
||
default :
|
||
throw new Error("mode:" + mode);
|
||
}
|
||
|
||
} else if (type < 41) {
|
||
|
||
// 27 - 40
|
||
|
||
switch(mode) {
|
||
case QRMode.MODE_NUMBER : return 14;
|
||
case QRMode.MODE_ALPHA_NUM : return 13;
|
||
case QRMode.MODE_8BIT_BYTE : return 16;
|
||
case QRMode.MODE_KANJI : return 12;
|
||
default :
|
||
throw new Error("mode:" + mode);
|
||
}
|
||
|
||
} else {
|
||
throw new Error("type:" + type);
|
||
}
|
||
},
|
||
|
||
getLostPoint : function(qrCode) {
|
||
|
||
var moduleCount = qrCode.getModuleCount();
|
||
var lostPoint = 0;
|
||
var row = 0;
|
||
var col = 0;
|
||
|
||
|
||
// LEVEL1
|
||
|
||
for (row = 0; row < moduleCount; row++) {
|
||
|
||
for (col = 0; col < moduleCount; col++) {
|
||
|
||
var sameCount = 0;
|
||
var dark = qrCode.isDark(row, col);
|
||
|
||
for (var r = -1; r <= 1; r++) {
|
||
|
||
if (row + r < 0 || moduleCount <= row + r) {
|
||
continue;
|
||
}
|
||
|
||
for (var c = -1; c <= 1; c++) {
|
||
|
||
if (col + c < 0 || moduleCount <= col + c) {
|
||
continue;
|
||
}
|
||
|
||
if (r === 0 && c === 0) {
|
||
continue;
|
||
}
|
||
|
||
if (dark === qrCode.isDark(row + r, col + c) ) {
|
||
sameCount++;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (sameCount > 5) {
|
||
lostPoint += (3 + sameCount - 5);
|
||
}
|
||
}
|
||
}
|
||
|
||
// LEVEL2
|
||
|
||
for (row = 0; row < moduleCount - 1; row++) {
|
||
for (col = 0; col < moduleCount - 1; col++) {
|
||
var count = 0;
|
||
if (qrCode.isDark(row, col ) ) count++;
|
||
if (qrCode.isDark(row + 1, col ) ) count++;
|
||
if (qrCode.isDark(row, col + 1) ) count++;
|
||
if (qrCode.isDark(row + 1, col + 1) ) count++;
|
||
if (count === 0 || count === 4) {
|
||
lostPoint += 3;
|
||
}
|
||
}
|
||
}
|
||
|
||
// LEVEL3
|
||
|
||
for (row = 0; row < moduleCount; row++) {
|
||
for (col = 0; col < moduleCount - 6; col++) {
|
||
if (qrCode.isDark(row, col) &&
|
||
!qrCode.isDark(row, col + 1) &&
|
||
qrCode.isDark(row, col + 2) &&
|
||
qrCode.isDark(row, col + 3) &&
|
||
qrCode.isDark(row, col + 4) &&
|
||
!qrCode.isDark(row, col + 5) &&
|
||
qrCode.isDark(row, col + 6) ) {
|
||
lostPoint += 40;
|
||
}
|
||
}
|
||
}
|
||
|
||
for (col = 0; col < moduleCount; col++) {
|
||
for (row = 0; row < moduleCount - 6; row++) {
|
||
if (qrCode.isDark(row, col) &&
|
||
!qrCode.isDark(row + 1, col) &&
|
||
qrCode.isDark(row + 2, col) &&
|
||
qrCode.isDark(row + 3, col) &&
|
||
qrCode.isDark(row + 4, col) &&
|
||
!qrCode.isDark(row + 5, col) &&
|
||
qrCode.isDark(row + 6, col) ) {
|
||
lostPoint += 40;
|
||
}
|
||
}
|
||
}
|
||
|
||
// LEVEL4
|
||
|
||
var darkCount = 0;
|
||
|
||
for (col = 0; col < moduleCount; col++) {
|
||
for (row = 0; row < moduleCount; row++) {
|
||
if (qrCode.isDark(row, col) ) {
|
||
darkCount++;
|
||
}
|
||
}
|
||
}
|
||
|
||
var ratio = Math.abs(100 * darkCount / moduleCount / moduleCount - 50) / 5;
|
||
lostPoint += ratio * 10;
|
||
|
||
return lostPoint;
|
||
}
|
||
|
||
};
|
||
|
||
|
||
|
||
// ---- index.js ----
|
||
//---------------------------------------------------------------------
|
||
// QRCode for JavaScript
|
||
//
|
||
// Copyright (c) 2009 Kazuhiko Arase
|
||
//
|
||
// URL: http://www.d-project.com/
|
||
//
|
||
// Licensed under the MIT license:
|
||
// http://www.opensource.org/licenses/mit-license.php
|
||
//
|
||
// The word "QR Code" is registered trademark of
|
||
// DENSO WAVE INCORPORATED
|
||
// http://www.denso-wave.com/qrcode/faqpatent-e.html
|
||
//
|
||
//---------------------------------------------------------------------
|
||
// Modified to work in node for this project (and some refactoring)
|
||
//---------------------------------------------------------------------
|
||
|
||
function QRCode(typeNumber, errorCorrectLevel) {
|
||
this.typeNumber = typeNumber;
|
||
this.errorCorrectLevel = errorCorrectLevel;
|
||
this.modules = null;
|
||
this.moduleCount = 0;
|
||
this.dataCache = null;
|
||
this.dataList = [];
|
||
}
|
||
|
||
QRCode.prototype = {
|
||
|
||
addData : function(data) {
|
||
var newData = new QR8bitByte(data);
|
||
this.dataList.push(newData);
|
||
this.dataCache = null;
|
||
},
|
||
|
||
isDark : function(row, col) {
|
||
if (row < 0 || this.moduleCount <= row || col < 0 || this.moduleCount <= col) {
|
||
throw new Error(row + "," + col);
|
||
}
|
||
return this.modules[row][col];
|
||
},
|
||
|
||
getModuleCount : function() {
|
||
return this.moduleCount;
|
||
},
|
||
|
||
make : function() {
|
||
// Calculate automatically typeNumber if provided is < 1
|
||
if (this.typeNumber < 1 ){
|
||
var typeNumber = 1;
|
||
for (typeNumber = 1; typeNumber < 40; typeNumber++) {
|
||
var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, this.errorCorrectLevel);
|
||
|
||
var buffer = new QRBitBuffer();
|
||
var totalDataCount = 0;
|
||
for (var i = 0; i < rsBlocks.length; i++) {
|
||
totalDataCount += rsBlocks[i].dataCount;
|
||
}
|
||
|
||
for (var x = 0; x < this.dataList.length; x++) {
|
||
var data = this.dataList[x];
|
||
buffer.put(data.mode, 4);
|
||
buffer.put(data.getLength(), QRUtil.getLengthInBits(data.mode, typeNumber) );
|
||
data.write(buffer);
|
||
}
|
||
if (buffer.getLengthInBits() <= totalDataCount * 8)
|
||
break;
|
||
}
|
||
this.typeNumber = typeNumber;
|
||
}
|
||
this.makeImpl(false, this.getBestMaskPattern() );
|
||
},
|
||
|
||
makeImpl : function(test, maskPattern) {
|
||
|
||
this.moduleCount = this.typeNumber * 4 + 17;
|
||
this.modules = new Array(this.moduleCount);
|
||
|
||
for (var row = 0; row < this.moduleCount; row++) {
|
||
|
||
this.modules[row] = new Array(this.moduleCount);
|
||
|
||
for (var col = 0; col < this.moduleCount; col++) {
|
||
this.modules[row][col] = null;//(col + row) % 3;
|
||
}
|
||
}
|
||
|
||
this.setupPositionProbePattern(0, 0);
|
||
this.setupPositionProbePattern(this.moduleCount - 7, 0);
|
||
this.setupPositionProbePattern(0, this.moduleCount - 7);
|
||
this.setupPositionAdjustPattern();
|
||
this.setupTimingPattern();
|
||
this.setupTypeInfo(test, maskPattern);
|
||
|
||
if (this.typeNumber >= 7) {
|
||
this.setupTypeNumber(test);
|
||
}
|
||
|
||
if (this.dataCache === null) {
|
||
this.dataCache = QRCode.createData(this.typeNumber, this.errorCorrectLevel, this.dataList);
|
||
}
|
||
|
||
this.mapData(this.dataCache, maskPattern);
|
||
},
|
||
|
||
setupPositionProbePattern : function(row, col) {
|
||
|
||
for (var r = -1; r <= 7; r++) {
|
||
|
||
if (row + r <= -1 || this.moduleCount <= row + r) continue;
|
||
|
||
for (var c = -1; c <= 7; c++) {
|
||
|
||
if (col + c <= -1 || this.moduleCount <= col + c) continue;
|
||
|
||
if ( (0 <= r && r <= 6 && (c === 0 || c === 6) ) ||
|
||
(0 <= c && c <= 6 && (r === 0 || r === 6) ) ||
|
||
(2 <= r && r <= 4 && 2 <= c && c <= 4) ) {
|
||
this.modules[row + r][col + c] = true;
|
||
} else {
|
||
this.modules[row + r][col + c] = false;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
|
||
getBestMaskPattern : function() {
|
||
|
||
var minLostPoint = 0;
|
||
var pattern = 0;
|
||
|
||
for (var i = 0; i < 8; i++) {
|
||
|
||
this.makeImpl(true, i);
|
||
|
||
var lostPoint = QRUtil.getLostPoint(this);
|
||
|
||
if (i === 0 || minLostPoint > lostPoint) {
|
||
minLostPoint = lostPoint;
|
||
pattern = i;
|
||
}
|
||
}
|
||
|
||
return pattern;
|
||
},
|
||
|
||
createMovieClip : function(target_mc, instance_name, depth) {
|
||
|
||
var qr_mc = target_mc.createEmptyMovieClip(instance_name, depth);
|
||
var cs = 1;
|
||
|
||
this.make();
|
||
|
||
for (var row = 0; row < this.modules.length; row++) {
|
||
|
||
var y = row * cs;
|
||
|
||
for (var col = 0; col < this.modules[row].length; col++) {
|
||
|
||
var x = col * cs;
|
||
var dark = this.modules[row][col];
|
||
|
||
if (dark) {
|
||
qr_mc.beginFill(0, 100);
|
||
qr_mc.moveTo(x, y);
|
||
qr_mc.lineTo(x + cs, y);
|
||
qr_mc.lineTo(x + cs, y + cs);
|
||
qr_mc.lineTo(x, y + cs);
|
||
qr_mc.endFill();
|
||
}
|
||
}
|
||
}
|
||
|
||
return qr_mc;
|
||
},
|
||
|
||
setupTimingPattern : function() {
|
||
|
||
for (var r = 8; r < this.moduleCount - 8; r++) {
|
||
if (this.modules[r][6] !== null) {
|
||
continue;
|
||
}
|
||
this.modules[r][6] = (r % 2 === 0);
|
||
}
|
||
|
||
for (var c = 8; c < this.moduleCount - 8; c++) {
|
||
if (this.modules[6][c] !== null) {
|
||
continue;
|
||
}
|
||
this.modules[6][c] = (c % 2 === 0);
|
||
}
|
||
},
|
||
|
||
setupPositionAdjustPattern : function() {
|
||
|
||
var pos = QRUtil.getPatternPosition(this.typeNumber);
|
||
|
||
for (var i = 0; i < pos.length; i++) {
|
||
|
||
for (var j = 0; j < pos.length; j++) {
|
||
|
||
var row = pos[i];
|
||
var col = pos[j];
|
||
|
||
if (this.modules[row][col] !== null) {
|
||
continue;
|
||
}
|
||
|
||
for (var r = -2; r <= 2; r++) {
|
||
|
||
for (var c = -2; c <= 2; c++) {
|
||
|
||
if (Math.abs(r) === 2 ||
|
||
Math.abs(c) === 2 ||
|
||
(r === 0 && c === 0) ) {
|
||
this.modules[row + r][col + c] = true;
|
||
} else {
|
||
this.modules[row + r][col + c] = false;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
},
|
||
|
||
setupTypeNumber : function(test) {
|
||
|
||
var bits = QRUtil.getBCHTypeNumber(this.typeNumber);
|
||
var mod;
|
||
|
||
for (var i = 0; i < 18; i++) {
|
||
mod = (!test && ( (bits >> i) & 1) === 1);
|
||
this.modules[Math.floor(i / 3)][i % 3 + this.moduleCount - 8 - 3] = mod;
|
||
}
|
||
|
||
for (var x = 0; x < 18; x++) {
|
||
mod = (!test && ( (bits >> x) & 1) === 1);
|
||
this.modules[x % 3 + this.moduleCount - 8 - 3][Math.floor(x / 3)] = mod;
|
||
}
|
||
},
|
||
|
||
setupTypeInfo : function(test, maskPattern) {
|
||
|
||
var data = (this.errorCorrectLevel << 3) | maskPattern;
|
||
var bits = QRUtil.getBCHTypeInfo(data);
|
||
var mod;
|
||
|
||
// vertical
|
||
for (var v = 0; v < 15; v++) {
|
||
|
||
mod = (!test && ( (bits >> v) & 1) === 1);
|
||
|
||
if (v < 6) {
|
||
this.modules[v][8] = mod;
|
||
} else if (v < 8) {
|
||
this.modules[v + 1][8] = mod;
|
||
} else {
|
||
this.modules[this.moduleCount - 15 + v][8] = mod;
|
||
}
|
||
}
|
||
|
||
// horizontal
|
||
for (var h = 0; h < 15; h++) {
|
||
|
||
mod = (!test && ( (bits >> h) & 1) === 1);
|
||
|
||
if (h < 8) {
|
||
this.modules[8][this.moduleCount - h - 1] = mod;
|
||
} else if (h < 9) {
|
||
this.modules[8][15 - h - 1 + 1] = mod;
|
||
} else {
|
||
this.modules[8][15 - h - 1] = mod;
|
||
}
|
||
}
|
||
|
||
// fixed module
|
||
this.modules[this.moduleCount - 8][8] = (!test);
|
||
|
||
},
|
||
|
||
mapData : function(data, maskPattern) {
|
||
|
||
var inc = -1;
|
||
var row = this.moduleCount - 1;
|
||
var bitIndex = 7;
|
||
var byteIndex = 0;
|
||
|
||
for (var col = this.moduleCount - 1; col > 0; col -= 2) {
|
||
|
||
if (col === 6) col--;
|
||
|
||
while (true) {
|
||
|
||
for (var c = 0; c < 2; c++) {
|
||
|
||
if (this.modules[row][col - c] === null) {
|
||
|
||
var dark = false;
|
||
|
||
if (byteIndex < data.length) {
|
||
dark = ( ( (data[byteIndex] >>> bitIndex) & 1) === 1);
|
||
}
|
||
|
||
var mask = QRUtil.getMask(maskPattern, row, col - c);
|
||
|
||
if (mask) {
|
||
dark = !dark;
|
||
}
|
||
|
||
this.modules[row][col - c] = dark;
|
||
bitIndex--;
|
||
|
||
if (bitIndex === -1) {
|
||
byteIndex++;
|
||
bitIndex = 7;
|
||
}
|
||
}
|
||
}
|
||
|
||
row += inc;
|
||
|
||
if (row < 0 || this.moduleCount <= row) {
|
||
row -= inc;
|
||
inc = -inc;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
};
|
||
|
||
QRCode.PAD0 = 0xEC;
|
||
QRCode.PAD1 = 0x11;
|
||
|
||
QRCode.createData = function(typeNumber, errorCorrectLevel, dataList) {
|
||
|
||
var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, errorCorrectLevel);
|
||
|
||
var buffer = new QRBitBuffer();
|
||
|
||
for (var i = 0; i < dataList.length; i++) {
|
||
var data = dataList[i];
|
||
buffer.put(data.mode, 4);
|
||
buffer.put(data.getLength(), QRUtil.getLengthInBits(data.mode, typeNumber) );
|
||
data.write(buffer);
|
||
}
|
||
|
||
// calc num max data.
|
||
var totalDataCount = 0;
|
||
for (var x = 0; x < rsBlocks.length; x++) {
|
||
totalDataCount += rsBlocks[x].dataCount;
|
||
}
|
||
|
||
if (buffer.getLengthInBits() > totalDataCount * 8) {
|
||
throw new Error("code length overflow. (" +
|
||
buffer.getLengthInBits() +
|
||
">" +
|
||
totalDataCount * 8 +
|
||
")");
|
||
}
|
||
|
||
// end code
|
||
if (buffer.getLengthInBits() + 4 <= totalDataCount * 8) {
|
||
buffer.put(0, 4);
|
||
}
|
||
|
||
// padding
|
||
while (buffer.getLengthInBits() % 8 !== 0) {
|
||
buffer.putBit(false);
|
||
}
|
||
|
||
// padding
|
||
while (true) {
|
||
|
||
if (buffer.getLengthInBits() >= totalDataCount * 8) {
|
||
break;
|
||
}
|
||
buffer.put(QRCode.PAD0, 8);
|
||
|
||
if (buffer.getLengthInBits() >= totalDataCount * 8) {
|
||
break;
|
||
}
|
||
buffer.put(QRCode.PAD1, 8);
|
||
}
|
||
|
||
return QRCode.createBytes(buffer, rsBlocks);
|
||
};
|
||
|
||
QRCode.createBytes = function(buffer, rsBlocks) {
|
||
|
||
var offset = 0;
|
||
|
||
var maxDcCount = 0;
|
||
var maxEcCount = 0;
|
||
|
||
var dcdata = new Array(rsBlocks.length);
|
||
var ecdata = new Array(rsBlocks.length);
|
||
|
||
for (var r = 0; r < rsBlocks.length; r++) {
|
||
|
||
var dcCount = rsBlocks[r].dataCount;
|
||
var ecCount = rsBlocks[r].totalCount - dcCount;
|
||
|
||
maxDcCount = Math.max(maxDcCount, dcCount);
|
||
maxEcCount = Math.max(maxEcCount, ecCount);
|
||
|
||
dcdata[r] = new Array(dcCount);
|
||
|
||
for (var i = 0; i < dcdata[r].length; i++) {
|
||
dcdata[r][i] = 0xff & buffer.buffer[i + offset];
|
||
}
|
||
offset += dcCount;
|
||
|
||
var rsPoly = QRUtil.getErrorCorrectPolynomial(ecCount);
|
||
var rawPoly = new QRPolynomial(dcdata[r], rsPoly.getLength() - 1);
|
||
|
||
var modPoly = rawPoly.mod(rsPoly);
|
||
ecdata[r] = new Array(rsPoly.getLength() - 1);
|
||
for (var x = 0; x < ecdata[r].length; x++) {
|
||
var modIndex = x + modPoly.getLength() - ecdata[r].length;
|
||
ecdata[r][x] = (modIndex >= 0)? modPoly.get(modIndex) : 0;
|
||
}
|
||
|
||
}
|
||
|
||
var totalCodeCount = 0;
|
||
for (var y = 0; y < rsBlocks.length; y++) {
|
||
totalCodeCount += rsBlocks[y].totalCount;
|
||
}
|
||
|
||
var data = new Array(totalCodeCount);
|
||
var index = 0;
|
||
|
||
for (var z = 0; z < maxDcCount; z++) {
|
||
for (var s = 0; s < rsBlocks.length; s++) {
|
||
if (z < dcdata[s].length) {
|
||
data[index++] = dcdata[s][z];
|
||
}
|
||
}
|
||
}
|
||
|
||
for (var xx = 0; xx < maxEcCount; xx++) {
|
||
for (var t = 0; t < rsBlocks.length; t++) {
|
||
if (xx < ecdata[t].length) {
|
||
data[index++] = ecdata[t][xx];
|
||
}
|
||
}
|
||
}
|
||
|
||
return data;
|
||
|
||
};
|
||
|
||
|
||
|
||
function renderToCanvas(canvas, text, size, margin){
|
||
size = Math.max(64, Number(size || 128)); margin = Math.max(0, Number(margin == null ? 4 : margin));
|
||
var qr = new QRCode(0, QRErrorCorrectLevel.M);
|
||
qr.addData(String(text || ''));
|
||
qr.make();
|
||
var count = qr.getModuleCount();
|
||
var scale = Math.max(1, Math.floor((size - margin * 2) / count));
|
||
var actual = count * scale + margin * 2;
|
||
canvas.width = actual; canvas.height = actual;
|
||
var ctx = canvas.getContext('2d');
|
||
ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, actual, actual);
|
||
ctx.fillStyle = '#111111';
|
||
for (var r=0;r<count;r++) for(var c=0;c<count;c++) if(qr.isDark(r,c)) ctx.fillRect(margin+c*scale,margin+r*scale,scale,scale);
|
||
return canvas;
|
||
}
|
||
window.SunQR = {
|
||
toCanvas: renderToCanvas,
|
||
toDataURL: function(text,size,margin){var c=document.createElement('canvas');renderToCanvas(c,text,size,margin);return c.toDataURL('image/png');}
|
||
};
|
||
})();
|
||
|
||
/* END qr-lite.js */
|
||
</script><script>
|
||
/* BEGIN enterprise-update.js — PORTABLE INLINE */
|
||
/* Sun Catering enterprise extension: backup, roles, sidebar, search, finance, notifications, print, routes and Excel */
|
||
(() => {
|
||
'use strict';
|
||
if (window.SunEnterprise) return;
|
||
|
||
const VERSION = 'v17.6.0 · 2026.09.07';
|
||
const SETTINGS_KEY = 'sunEnterpriseSettingsV1';
|
||
const USERS_KEY = 'sunUsersV1';
|
||
const AUDIT_KEY = 'sunAuditLogV1';
|
||
const READ_NOTIFICATIONS_KEY = 'sunReadNotificationsV1';
|
||
const PRINT_SETTINGS_KEY = 'sunPrintSettingsV1';
|
||
const ROUTES_KEY = 'sunRoutePlansV1';
|
||
const AUTOSAVE_KEY = 'sunOrderDraftAutosaveV1';
|
||
const SESSION_KEY = 'sunSessionUserV1';
|
||
|
||
const $ = id => document.getElementById(id);
|
||
const qs = (sel, root = document) => root.querySelector(sel);
|
||
const qsa = (sel, root = document) => [...root.querySelectorAll(sel)];
|
||
const esc=window.SunSafe.escapeHTML;
|
||
const moneyFmt = value => `${Number(value || 0).toLocaleString('ru-RU', {maximumFractionDigits: 2})} ₽`;
|
||
const numFmt = value => Number(value || 0).toLocaleString('ru-RU', {maximumFractionDigits: 3});
|
||
const todayKey = () => new Date().toISOString().slice(0, 10);
|
||
const safeJson = (raw, fallback) => { try { const parsed = JSON.parse(raw); return parsed ?? fallback; } catch (_) { return fallback; } };
|
||
const clone = value => typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));
|
||
const uid = () => typeof window.sunUUID === 'function' ? window.sunUUID() : `sun-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||
const appOrders = () => { try { return Array.isArray(orders) ? orders : []; } catch (_) { return safeJson(localStorage.sunOrders, []); } };
|
||
const appBoxes = () => { try { return Array.isArray(boxes) ? boxes : []; } catch (_) { return safeJson(localStorage.sunBoxes, []); } };
|
||
const appDraft = () => { try { return draft; } catch (_) { return {}; } };
|
||
const activeOrder = order => !['Отменён', 'Отдан заказчику'].includes(order?.status || 'Новый');
|
||
const orderTotal = order => {
|
||
if (Number.isFinite(Number(order?.total))) return Number(order.total || 0);
|
||
return (order?.lines || []).reduce((sum, line) => {
|
||
const item = appBoxes().find(x => String(x.id) === String(line.id));
|
||
return sum + Number(item?.price || 0) * Number(line.qty || 0);
|
||
}, 0);
|
||
};
|
||
const paymentTotal = order => Math.max(0, Number(order?.prepayment || 0));
|
||
const dateLabel = raw => {
|
||
if (!raw) return '—';
|
||
const d = new Date(`${raw}T00:00:00`);
|
||
return Number.isNaN(d.getTime()) ? raw : d.toLocaleDateString('ru-RU');
|
||
};
|
||
const getEmployees = () => safeJson(localStorage.getItem('sunEmployees') || '[]', []);
|
||
const getSuppliers = () => safeJson(localStorage.getItem('sunSuppliers') || '[]', []);
|
||
const getStock = () => safeJson(localStorage.getItem('sunStock') || '[]', []);
|
||
const getFinance = () => safeJson(localStorage.getItem('sunFinanceRecordsV2') || '[]', []);
|
||
|
||
const defaultSettings = {
|
||
sidebarCollapsed: false,
|
||
rolesEnabled: false,
|
||
autoBackup: true,
|
||
browserNotifications: false,
|
||
cloud: { enabled: false, supabaseUrl: '', anonKey: '', workspaceId: 'sun-catering-main', autoSync: false, lastSync: '' }
|
||
};
|
||
let settings = Object.assign({}, defaultSettings, safeJson(localStorage.getItem(SETTINGS_KEY) || '{}', {}));
|
||
settings.cloud = Object.assign({}, defaultSettings.cloud, settings.cloud || {});
|
||
const saveSettings = () => localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||
|
||
// ---------- Toasts ----------
|
||
const toastHost = document.createElement('div');
|
||
toastHost.id = 'sunToastHost';
|
||
document.body.appendChild(toastHost);
|
||
function toast(text, type = 'info', timeout = 3500) {
|
||
const node = document.createElement('div');
|
||
node.className = `sun-toast ${type}`;
|
||
node.innerHTML = `<span>${esc(text)}</span><button type="button" aria-label="Закрыть">×</button>`;
|
||
node.querySelector('button').onclick = () => node.remove();
|
||
toastHost.appendChild(node);
|
||
requestAnimationFrame(() => node.classList.add('show'));
|
||
setTimeout(() => { node.classList.remove('show'); setTimeout(() => node.remove(), 220); }, timeout);
|
||
}
|
||
|
||
// ---------- Common styles / desktop sidebar ----------
|
||
const style = document.createElement('style');
|
||
style.id = 'sun-enterprise-style';
|
||
style.textContent = `
|
||
:root{--sun-side:274px;--sun-side-collapsed:74px}
|
||
#sunToastHost{position:fixed;right:18px;bottom:18px;z-index:5000;display:grid;gap:8px;max-width:min(390px,calc(100vw - 36px))}
|
||
.sun-toast{background:#15364c;color:#fff;border-radius:9px;padding:11px 12px;box-shadow:0 8px 30px #102c3d4d;display:flex;align-items:center;gap:12px;opacity:0;transform:translateY(8px);transition:.2s}.sun-toast.show{opacity:1;transform:none}.sun-toast.success{background:#347154}.sun-toast.warn{background:#986d2f}.sun-toast.error{background:#943d3d}.sun-toast button{margin-left:auto;color:inherit;border:0;background:transparent;font-size:20px}
|
||
.sun-side-tools{display:grid;gap:7px;width:100%;margin:2px 0 4px}.sun-side-action{width:100%;min-height:40px;border:1px solid #ffffff1f;background:#ffffff0d;color:#e8f0f3;border-radius:9px;padding:8px 10px;text-align:left;font-weight:700;display:flex;align-items:center;gap:8px}.sun-side-action:hover{background:#ffffff19}.sun-side-action .badge{margin-left:auto;min-width:20px;height:20px;padding:0 6px;border-radius:999px;background:#d76a56;color:#fff;display:grid;place-items:center;font-size:11px}.sun-side-action .badge:empty{display:none}.sun-current-user{font-size:12px;color:#adc0cc;padding:4px 4px 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sun-side-footer{margin-top:auto;width:100%;display:grid;gap:6px;padding-top:9px;border-top:1px solid #ffffff18}
|
||
body.sun-enterprise-sidebar{padding-left:var(--sun-side);transition:padding-left .2s ease}.sun-enterprise-sidebar header{position:fixed!important;left:0;top:0;bottom:0;width:var(--sun-side);height:100vh!important;min-height:0!important;padding:14px 12px!important;display:flex!important;flex-direction:column!important;align-items:stretch!important;gap:10px!important;overflow-y:auto;overflow-x:hidden;z-index:900;transition:width .2s ease}.sun-enterprise-sidebar header .brand{font-size:20px!important;padding:2px 5px 10px;justify-content:flex-start}.sun-enterprise-sidebar header .brand .real-logo{flex:0 0 auto}.sun-enterprise-sidebar header nav{display:flex!important;flex-direction:column!important;gap:8px!important;overflow:visible!important;width:100%;white-space:normal!important}.sun-enterprise-sidebar .sun-nav-group{display:flex!important;flex-direction:column!important;gap:3px!important;padding:22px 4px 5px!important;min-width:0!important;background:#ffffff08!important}.sun-enterprise-sidebar header nav .sun-nav-button{width:100%!important;min-height:40px!important;height:auto!important;justify-content:flex-start!important;padding:9px 10px!important;font-size:12.5px!important;border-radius:8px!important;white-space:normal!important;text-align:left!important;transform:none!important}.sun-enterprise-sidebar header nav .sun-nav-button::before{margin-right:9px!important}.sun-enterprise-sidebar header nav .sun-nav-button.on::after{left:9px!important;right:auto!important;top:8px!important;bottom:8px!important;width:3px!important;height:auto!important}.sun-enterprise-sidebar header nav .sun-nav-button.on{padding-left:15px!important}.sun-enterprise-sidebar header nav .nav-print::before{background:#d18d55}.sun-enterprise-sidebar header nav .nav-routes::before{background:#5c9f8c}.sun-enterprise-sidebar header nav .nav-settings::before{background:#9d8d76}
|
||
body.sun-side-collapsed{padding-left:var(--sun-side-collapsed)}.sun-side-collapsed header{width:var(--sun-side-collapsed)}.sun-side-collapsed header .brand{justify-content:center;font-size:0!important}.sun-side-collapsed header .brand .real-logo{margin:0!important;width:48px!important;height:48px!important}.sun-side-collapsed .sun-nav-group{padding-top:13px!important;background:transparent!important;border:0!important}.sun-side-collapsed .sun-nav-group::before,.sun-side-collapsed .sun-nav-button{font-size:0!important}.sun-side-collapsed header nav .sun-nav-button{justify-content:center!important;padding:10px!important;min-height:44px!important}.sun-side-collapsed header nav .sun-nav-button::before{width:10px!important;height:10px!important;margin:0!important}.sun-side-collapsed .sun-side-action{justify-content:center;font-size:0;padding:10px}.sun-side-collapsed .sun-side-action .badge{font-size:10px;position:absolute;margin:0 0 25px 25px}.sun-side-collapsed .sun-current-user{display:none}.sun-side-collapsed .sun-side-footer{border:0}.sun-sidebar-collapse{font-size:12px!important}.sun-side-collapsed .sun-sidebar-collapse::after{content:'›';font-size:24px}.sun-enterprise-sidebar:not(.sun-side-collapsed) .sun-sidebar-collapse::after{content:'‹';font-size:24px;margin-left:auto}
|
||
.sun-global-search{width:min(920px,calc(100vw - 36px));max-height:86vh}.sun-search-input{width:100%;font-size:18px;padding:13px 14px;border-radius:9px;border:1px solid #ccd4d9}.sun-search-results{margin-top:12px;display:grid;gap:12px}.sun-search-group h3{margin:0 0 7px;color:#15364c;font-size:14px;text-transform:uppercase;letter-spacing:.04em}.sun-search-item{width:100%;border:1px solid #d9dde0;border-radius:8px;background:#fff;padding:10px 11px;text-align:left;display:grid;grid-template-columns:1fr auto;gap:8px;align-items:center}.sun-search-item:hover{border-color:#c99a32;background:#fffaf0}.sun-search-item b{display:block;color:#15364c}.sun-search-item small{display:block;color:#68717a;margin-top:3px}.sun-search-empty{padding:24px;text-align:center;color:#879198}
|
||
.enterprise-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:15px}.enterprise-card{border:1px solid #d9dde0;border-radius:11px;background:#fff;padding:15px;min-width:0}.enterprise-card.wide{grid-column:1/-1}.enterprise-card h2{font-size:18px;color:#15364c;margin:0 0 6px}.enterprise-card h3{font-size:15px;color:#15364c;margin:15px 0 7px}.enterprise-card .actions{flex-wrap:wrap}.enterprise-card .status{padding:9px 11px;border-radius:8px;background:#f4f6f7;color:#596a74;margin:9px 0}.enterprise-card .status.good{background:#eaf6ed;color:#347154}.enterprise-card .status.bad{background:#fbeeee;color:#943d3d}.enterprise-fields{display:grid;grid-template-columns:1fr 1fr;gap:9px}.enterprise-fields .wide{grid-column:1/-1}.backup-list,.audit-list,.users-list{display:grid;gap:7px;margin-top:10px}.backup-row,.audit-row,.user-row{border:1px solid #e2e6e8;border-radius:8px;padding:9px 10px;display:grid;grid-template-columns:1fr auto;gap:10px;align-items:center}.audit-row{grid-template-columns:150px 120px 1fr}.backup-row small,.audit-row small,.user-row small{color:#68717a}.role-chip{display:inline-flex;padding:3px 7px;border-radius:999px;background:#eef2f4;color:#53636e;font-size:11px;font-weight:700}
|
||
.sun-login-overlay{position:fixed;inset:0;background:linear-gradient(145deg,#102c3d,#15364c);z-index:10000;display:grid;place-items:center;padding:18px}.sun-login-card{width:min(420px,100%);background:#fff;border-radius:16px;padding:24px;box-shadow:0 24px 70px #0006}.sun-login-brand{display:flex;align-items:center;gap:12px;margin-bottom:18px}.sun-login-brand img{width:60px;height:60px;object-fit:contain}.sun-login-brand h1{margin:0;color:#15364c;font-size:22px}.sun-login-card label{margin-top:10px}.sun-login-card .error{color:#9c3d3d;min-height:20px;margin-top:8px}
|
||
.autosave-banner{margin:12px 14px 0;border:1px solid #d9c58c;background:#fff9e7;border-radius:9px;padding:10px 12px;display:flex;gap:10px;align-items:center;flex-wrap:wrap}.autosave-banner b{color:#6e5924}.autosave-banner .actions{margin:0 0 0 auto}
|
||
|
||
/* Premium sidebar concept — visual experiment. Existing logo asset is intentionally reused everywhere. */
|
||
@media(min-width:901px){
|
||
:root{--sun-side:286px;--sun-side-collapsed:74px}
|
||
body.sun-enterprise-sidebar{background:#f6f7f8}
|
||
.sun-enterprise-sidebar header{background:linear-gradient(180deg,#102634 0%,#0d202c 100%)!important;padding:18px 14px 14px!important;gap:9px!important;box-shadow:8px 0 28px #0d243018}
|
||
.sun-enterprise-sidebar header .brand{min-height:78px;padding:7px 8px 14px!important;font-size:18px!important;line-height:1.15;letter-spacing:.02em;border-bottom:1px solid #ffffff14;display:flex!important;align-items:center!important}
|
||
.sun-enterprise-sidebar header .brand .real-logo{width:58px!important;height:58px!important;margin-right:12px!important;filter:none!important}
|
||
.sun-enterprise-sidebar header nav{gap:4px!important;margin-top:2px}
|
||
.sun-enterprise-sidebar .sun-nav-home-standalone{margin:4px 4px 7px!important;background:#ffffff0b!important;border:1px solid #ffffff10!important}
|
||
.sun-enterprise-sidebar .sun-nav-group{position:relative!important;display:flex!important;flex-direction:column!important;gap:2px!important;margin:0 4px!important;padding:31px 0 10px!important;background:transparent!important;border:0!important;border-bottom:1px solid #ffffff14!important;border-radius:0!important}
|
||
.sun-enterprise-sidebar .sun-nav-group::before{content:attr(data-label)!important;position:absolute!important;left:8px!important;top:11px!important;color:#91a5b2!important;font-size:10px!important;font-weight:800!important;letter-spacing:.10em!important;text-transform:uppercase!important}
|
||
.sun-enterprise-sidebar .sun-nav-group::after{content:'⌃';position:absolute;right:9px;top:7px;color:#d8ad50;font-size:15px;opacity:.9}
|
||
.sun-enterprise-sidebar .sun-nav-work::before{content:'ЗАКАЗЫ'!important}
|
||
.sun-enterprise-sidebar .sun-nav-operations::before{content:'КУХНЯ И ЛОГИСТИКА'!important}
|
||
.sun-enterprise-sidebar .sun-nav-management::before{content:'УПРАВЛЕНИЕ'!important}
|
||
.sun-enterprise-sidebar header nav .sun-nav-button{position:relative!important;min-height:42px!important;padding:9px 10px!important;border-radius:10px!important;color:#e6eef2!important;font-size:13.5px!important;font-weight:650!important;letter-spacing:0!important;transition:background .15s ease,color .15s ease,transform .15s ease!important}
|
||
.sun-enterprise-sidebar header nav .sun-nav-button:hover{background:#ffffff10!important;color:#fff!important;transform:none!important}
|
||
.sun-enterprise-sidebar header nav .sun-nav-button.on{background:#ffffff12!important;color:#ffd56a!important;box-shadow:inset 0 0 0 1px #ffffff08!important;padding-left:10px!important}
|
||
.sun-enterprise-sidebar header nav .sun-nav-button.on::after{content:''!important;position:absolute!important;left:-4px!important;top:8px!important;bottom:8px!important;width:3px!important;height:auto!important;background:#e2b64f!important;border-radius:4px!important}
|
||
.sun-enterprise-sidebar header nav .sun-nav-button::before{display:inline-grid!important;place-items:center!important;width:24px!important;height:24px!important;margin-right:9px!important;border-radius:7px!important;background:transparent!important;color:#cbd8df!important;font-size:18px!important;line-height:1!important;opacity:1!important;flex:0 0 24px!important}
|
||
.sun-enterprise-sidebar header nav .nav-home::before{content:'⌂'!important;color:#f2c85c!important}
|
||
.sun-enterprise-sidebar header nav .nav-new::before{content:'+'!important}
|
||
.sun-enterprise-sidebar header nav .nav-orders::before{content:'▣'!important}
|
||
.sun-enterprise-sidebar header nav .nav-calendar::before{content:'▦'!important}
|
||
.sun-enterprise-sidebar header nav .nav-map::before{content:'⌖'!important}
|
||
.sun-enterprise-sidebar header nav .nav-print::before{content:'▤'!important;color:#d7dee3!important}
|
||
.sun-enterprise-sidebar header nav .nav-production::before{content:'≡'!important}
|
||
.sun-enterprise-sidebar header nav .nav-prep::before{content:'✓'!important}
|
||
.sun-enterprise-sidebar header nav .nav-shopping::before{content:'⌑'!important}
|
||
.sun-enterprise-sidebar header nav .nav-stock::before{content:'□'!important}
|
||
.sun-enterprise-sidebar header nav .nav-routes::before{content:'⌁'!important}
|
||
.sun-enterprise-sidebar header nav .nav-clients::before{content:'♙'!important}
|
||
.sun-enterprise-sidebar header nav .nav-money::before{content:'₽'!important}
|
||
.sun-enterprise-sidebar header nav .nav-team::before{content:'♟'!important}
|
||
.sun-enterprise-sidebar header nav .nav-suppliers::before{content:'▧'!important}
|
||
.sun-enterprise-sidebar header nav .nav-stats::before{content:'⌁'!important}
|
||
.sun-enterprise-sidebar header nav .nav-settings::before{content:'⚙'!important;font-size:16px!important}
|
||
.sun-side-tools{grid-template-columns:1fr auto;gap:6px!important;margin:4px 4px 2px!important;width:auto!important}
|
||
.sun-side-action{min-height:38px!important;border-color:#ffffff13!important;background:#ffffff08!important;border-radius:10px!important;color:#d9e4e9!important;font-size:12px!important}
|
||
#sunNotificationsBtn{width:44px!important;padding:8px!important;justify-content:center!important;position:relative}
|
||
#sunNotificationsBtn>span:nth-child(2){display:none}
|
||
#sunNotificationsBtn>span:first-child{font-size:0}
|
||
#sunNotificationsBtn>span:first-child::before{content:'●';font-size:17px;color:#dce8ec}
|
||
#sunNotificationsBtn .badge{position:absolute;right:-3px;top:-5px;margin:0!important;background:#e5533f!important}
|
||
#sunGlobalSearchBtn kbd{display:none}
|
||
.sun-current-user{grid-column:1/-1;padding:4px 8px 1px!important;color:#8fa5b2!important}
|
||
.sun-side-footer{margin:4px!important;width:auto!important;padding:9px 0 0!important}
|
||
.sun-side-collapsed .sun-nav-home-standalone{margin-inline:0!important}
|
||
.sun-side-collapsed .sun-nav-group{margin-inline:0!important;padding-top:8px!important;border-bottom:0!important}
|
||
.sun-side-collapsed .sun-nav-group::after{display:none}
|
||
.sun-side-collapsed header .brand{min-height:68px!important;border-bottom:0!important}
|
||
.sun-side-collapsed header .brand .real-logo{width:50px!important;height:50px!important}
|
||
}
|
||
@media(max-width:900px){body.sun-enterprise-sidebar,body.sun-side-collapsed{padding-left:0;padding-bottom:76px}.sun-enterprise-sidebar header{position:relative!important;width:auto!important;height:auto!important;min-height:64px!important;padding:9px 14px 10px!important;overflow:visible!important;display:flex!important;flex-direction:column!important}.sun-enterprise-sidebar header .brand{font-size:20px!important;padding:0;justify-content:flex-start}.sun-enterprise-sidebar header .brand .real-logo{width:42px!important;height:42px!important;margin-right:9px!important}.sun-side-tools{display:flex;gap:6px;overflow:auto}.sun-side-action{min-width:45px;width:auto;font-size:0;justify-content:center}.sun-side-action .badge{font-size:10px;position:absolute;margin:-27px -28px 0 0}.sun-current-user{display:none}.sun-side-footer{display:none}.sun-enterprise-sidebar header nav{position:fixed!important;left:0;right:0;bottom:0;z-index:100;display:flex!important;flex-direction:row!important;overflow-x:auto!important;width:auto!important}.sun-enterprise-sidebar .sun-nav-group{display:contents!important}.enterprise-grid{grid-template-columns:1fr}.enterprise-card.wide{grid-column:auto}.enterprise-fields{grid-template-columns:1fr}.enterprise-fields .wide{grid-column:auto}.audit-row{grid-template-columns:1fr}.sun-sidebar-collapse{display:none}.autosave-banner{margin:8px}.autosave-banner .actions{margin-left:0}}
|
||
@media(max-width:900px){
|
||
#sunGlobalSearchBtn>span:first-child,#sunNotificationsBtn>span:first-child{font-size:0!important;display:block!important;width:19px!important;height:19px!important;min-width:19px!important;background:currentColor!important;-webkit-mask-repeat:no-repeat!important;mask-repeat:no-repeat!important;-webkit-mask-position:center!important;mask-position:center!important;-webkit-mask-size:19px 19px!important;mask-size:19px 19px!important}
|
||
#sunGlobalSearchBtn>span:first-child{-webkit-mask-image:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%3E%3Ccircle%20cx%3D%2211%22%20cy%3D%2211%22%20r%3D%227%22%2F%3E%3Cpath%20d%3D%22m20%2020-4-4%22%2F%3E%3C%2Fsvg%3E")!important;mask-image:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%3E%3Ccircle%20cx%3D%2211%22%20cy%3D%2211%22%20r%3D%227%22%2F%3E%3Cpath%20d%3D%22m20%2020-4-4%22%2F%3E%3C%2Fsvg%3E")!important}
|
||
#sunNotificationsBtn>span:first-child{-webkit-mask-image:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.9%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M18%208a6%206%200%200%200-12%200c0%207-3%207-3%209h18c0-2-3-2-3-9Z%22%2F%3E%3Cpath%20d%3D%22M10%2021h4%22%2F%3E%3C%2Fsvg%3E")!important;mask-image:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.9%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M18%208a6%206%200%200%200-12%200c0%207-3%207-3%209h18c0-2-3-2-3-9Z%22%2F%3E%3Cpath%20d%3D%22M10%2021h4%22%2F%3E%3C%2Fsvg%3E")!important}
|
||
#sunGlobalSearchBtn>span:nth-child(2),#sunGlobalSearchBtn kbd,#sunNotificationsBtn>span:nth-child(2){display:none!important}
|
||
#sunNotificationsBtn .badge{position:absolute!important;right:-4px!important;top:-6px!important;margin:0!important;min-width:16px!important;height:16px!important;padding:0 4px!important;font-size:9px!important}
|
||
#new .catalog-head{align-items:center!important}
|
||
#new .catalog-head h1{flex:1 1 100%;order:-1}
|
||
#new .sun-catalog-search{flex:1 1 100%;order:0;min-width:0;width:auto}
|
||
#new .catalog-head button{flex:1 1 0;order:1;min-width:0;padding-inline:8px;white-space:nowrap}
|
||
}
|
||
@media print{.sun-side-tools,.sun-side-footer,#sunToastHost{display:none!important}}
|
||
`;
|
||
document.head.appendChild(style);
|
||
document.body.classList.add('sun-enterprise-sidebar');
|
||
if (settings.sidebarCollapsed) document.body.classList.add('sun-side-collapsed');
|
||
|
||
// ---------- Audit ----------
|
||
let auditLog = safeJson(localStorage.getItem(AUDIT_KEY) || '[]', []);
|
||
function currentUser() {
|
||
try {
|
||
const ws=window.SunCloudV2?.getWorkspace?.(), ss=window.SunCloudV2?.getSession?.();
|
||
if(ws&&ss?.user)return {id:ss.user.id,name:ws.display_name||ss.user.email||'Пользователь',role:ws.role||'viewer'};
|
||
} catch (_) {}
|
||
const id = sessionStorage.getItem(SESSION_KEY);
|
||
const users = safeJson(localStorage.getItem(USERS_KEY) || '[]', []);
|
||
return users.find(u => String(u.id) === String(id)) || { id: 'system', name: 'Система', role: 'viewer' };
|
||
}
|
||
function recordAudit(action, details = '') {
|
||
auditLog.unshift({ id: uid(), at: new Date().toISOString(), user: currentUser().name || 'Система', action, details: String(details || '') });
|
||
auditLog = auditLog.slice(0, 500);
|
||
localStorage.setItem(AUDIT_KEY, JSON.stringify(auditLog));
|
||
if ($('auditList')) renderAudit();
|
||
}
|
||
|
||
// ---------- Header tools ----------
|
||
const header = qs('header');
|
||
const nav = qs('header nav');
|
||
const sideTools = document.createElement('div');
|
||
sideTools.className = 'sun-side-tools';
|
||
sideTools.innerHTML = `
|
||
<button class="sun-side-action" id="sunGlobalSearchBtn" type="button" title="Поиск по приложению"><span>⌕</span><span>Поиск</span><kbd style="margin-left:auto;font-size:10px;opacity:.65">Ctrl K</kbd></button>
|
||
<button class="sun-side-action" id="sunNotificationsBtn" type="button" title="Уведомления"><span>●</span><span>Уведомления</span><span class="badge" id="sunNotificationBadge"></span></button>
|
||
<div class="sun-current-user" id="sunCurrentUserLabel"></div>`;
|
||
window.SunSafe.insertBefore(header,sideTools,nav);
|
||
const sideFooter = document.createElement('div');
|
||
sideFooter.className = 'sun-side-footer';
|
||
sideFooter.innerHTML = `<button class="sun-side-action sun-sidebar-collapse" id="sunSidebarCollapse" type="button"><span>Свернуть меню</span></button><button class="sun-side-action" id="sunLogoutBtn" type="button" style="display:none"><span>↪</span><span>Выйти</span></button>`;
|
||
header.appendChild(sideFooter);
|
||
$('sunSidebarCollapse').onclick = () => {
|
||
document.body.classList.toggle('sun-side-collapsed');
|
||
settings.sidebarCollapsed = document.body.classList.contains('sun-side-collapsed');
|
||
saveSettings();
|
||
};
|
||
|
||
function openView(view, button) {
|
||
qsa('.view').forEach(v => v.classList.remove('on'));
|
||
view.classList.add('on');
|
||
qsa('header nav button').forEach(b => b.classList.remove('on'));
|
||
if (button) button.classList.add('on');
|
||
window.scrollTo({top: 0, behavior: 'instant'});
|
||
}
|
||
function navButtonByLabel(label) { return qsa('header nav button').find(b => b.textContent.trim() === label); }
|
||
function activateNav(label) { const b = navButtonByLabel(label); if (b) b.click(); }
|
||
function navGroup(name) { return qs(`.sun-nav-${name}`, nav) || nav; }
|
||
function makeNavButton(label, group, cls, view, onRender) {
|
||
const button = document.createElement('button');
|
||
button.type = 'button'; button.textContent = label; button.dataset.navLabel = label;
|
||
button.className = `sun-nav-button ${cls || ''}`;
|
||
navGroup(group).appendChild(button);
|
||
button.onclick = () => { openView(view, button); if (typeof onRender === 'function') onRender(); };
|
||
return button;
|
||
}
|
||
|
||
// ---------- Global search ----------
|
||
const searchModal = document.createElement('div');
|
||
searchModal.id = 'sun-global-search-modal'; searchModal.className = 'modal';
|
||
searchModal.innerHTML = `<div class="dialog sun-global-search"><div class="dialog-head"><h2>Поиск по приложению</h2><button type="button" onclick="closeModal('sun-global-search-modal')">×</button></div><input class="sun-search-input" id="sunGlobalSearchInput" type="search" placeholder="Заказ, клиент, адрес, бокс, продукт, сотрудник или поставщик"><div class="sun-search-results" id="sunGlobalSearchResults"><div class="sun-search-empty">Начните вводить запрос.</div></div></div>`;
|
||
document.body.appendChild(searchModal);
|
||
|
||
function clientIndex() {
|
||
const map = new Map();
|
||
appOrders().forEach(o => {
|
||
const key = String(o.phone || o.contact || '').trim(); if (!key) return;
|
||
if (!map.has(key)) map.set(key, { key, name: o.contact || 'Без имени', phone: o.phone || '', addresses: new Set(), orders: [] });
|
||
const c = map.get(key); if (o.address) c.addresses.add(o.address); c.orders.push(o);
|
||
});
|
||
return [...map.values()].map(c => ({...c, addresses: [...c.addresses]}));
|
||
}
|
||
function searchData(query) {
|
||
const q = query.trim().toLowerCase(); if (!q) return [];
|
||
const groups = [];
|
||
const orderMatches = appOrders().filter(o => [o.id, o.event, o.contact, o.phone, o.address, o.note].join(' ').toLowerCase().includes(q)).slice(0, 12).map(o => ({ title: `Заказ №${o.id} · ${o.event || 'Без названия'}`, meta: `${o.date || '—'} ${o.time || ''} · ${o.contact || 'клиент не указан'} · ${moneyFmt(orderTotal(o))}`, action: 'order', id: o.id }));
|
||
if (orderMatches.length) groups.push({name:'Заказы', items:orderMatches});
|
||
const clientMatches = clientIndex().filter(c => [c.name,c.phone,...c.addresses].join(' ').toLowerCase().includes(q)).slice(0,10).map(c => ({title:c.name, meta:`${c.phone || 'без телефона'} · заказов: ${c.orders.length}`, action:'client', id:encodeURIComponent(c.key)}));
|
||
if (clientMatches.length) groups.push({name:'Клиенты', items:clientMatches});
|
||
const boxMatches = appBoxes().filter(b => [b.name,b.catalogSection,(b.ingredients||[]).map(x=>x[0]).join(' ')].join(' ').toLowerCase().includes(q)).slice(0,12).map(b=>({title:b.name,meta:`${moneyFmt(b.price)} · ${b.catalogSection || 'каталог'}`,action:'box',id:b.id}));
|
||
if (boxMatches.length) groups.push({name:'Каталог',items:boxMatches});
|
||
const stockMatches = getStock().filter(p => [p.name,p.unit].join(' ').toLowerCase().includes(q)).slice(0,12).map(p=>({title:p.name,meta:`Остаток ${numFmt(p.qty)} ${p.unit} · ${moneyFmt(p.cost)}`,action:'stock',id:p.id}));
|
||
if (stockMatches.length) groups.push({name:'Склад',items:stockMatches});
|
||
const employeeMatches = getEmployees().filter(e=>[e.name,e.role,e.phone,e.note].join(' ').toLowerCase().includes(q)).slice(0,10).map(e=>({title:e.name,meta:`${e.role || 'сотрудник'} · ${e.phone || 'без телефона'}`,action:'employee',id:e.id}));
|
||
if(employeeMatches.length)groups.push({name:'Команда',items:employeeMatches});
|
||
const supplierMatches = getSuppliers().filter(s=>[s.name,s.contact,s.phone,s.email,s.address].join(' ').toLowerCase().includes(q)).slice(0,10).map(s=>({title:s.name,meta:`${s.contact || ''} ${s.phone || ''}`.trim() || 'поставщик',action:'supplier',id:s.id}));
|
||
if(supplierMatches.length)groups.push({name:'Поставщики',items:supplierMatches});
|
||
return groups;
|
||
}
|
||
function renderGlobalSearch() {
|
||
const groups = searchData($('sunGlobalSearchInput').value);
|
||
$('sunGlobalSearchResults').innerHTML = groups.length ? groups.map(g=>`<section class="sun-search-group"><h3>${esc(g.name)}</h3>${g.items.map(item=>`<button type="button" class="sun-search-item" data-action="${esc(item.action)}" data-id="${esc(item.id)}"><span><b>${esc(item.title)}</b><small>${esc(item.meta)}</small></span><span>→</span></button>`).join('')}</section>`).join('') : `<div class="sun-search-empty">Ничего не найдено.</div>`;
|
||
}
|
||
function openGlobalSearch() { modal('sun-global-search-modal'); setTimeout(()=>{$('sunGlobalSearchInput').focus(); $('sunGlobalSearchInput').select();},40); }
|
||
$('sunGlobalSearchBtn').onclick = openGlobalSearch;
|
||
$('sunGlobalSearchInput').addEventListener('input', renderGlobalSearch);
|
||
$('sunGlobalSearchResults').addEventListener('click', event => {
|
||
const btn = event.target.closest('[data-action]'); if (!btn) return;
|
||
closeModal('sun-global-search-modal');
|
||
const action = btn.dataset.action, id = btn.dataset.id;
|
||
if (action === 'order' && typeof window.openOrder === 'function') window.openOrder(Number(id));
|
||
else if (action === 'client' && typeof window.openClientCard === 'function') { activateNav('Клиенты'); setTimeout(()=>window.openClientCard(id),40); }
|
||
else if (action === 'box') { activateNav('Новый заказ'); const item=appBoxes().find(x=>String(x.id)===String(id)); if(item){ const categoryButtons=qsa('.cats button'); const target=categoryButtons.find(b=>b.textContent.trim()===(['Боксы','Посуда и упаковка','Дополнения','Напитки','Доставка'][Number(item.category||0)]||'Боксы')); target?.click(); setTimeout(()=>window.editBox?.(id),40); } }
|
||
else if (action === 'stock') { activateNav('Склад'); setTimeout(()=>window.editStockProduct?.(id),70); }
|
||
else if (action === 'employee') { activateNav('Команда'); setTimeout(()=>window.sunEditEmployee?.(id),70); }
|
||
else if (action === 'supplier') { activateNav('Поставщики'); setTimeout(()=>window.sunEditSupplier?.(id),70); }
|
||
});
|
||
document.addEventListener('keydown', event => { if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') { event.preventDefault(); openGlobalSearch(); } });
|
||
|
||
// ---------- Settings page ----------
|
||
const settingsView = document.createElement('section');
|
||
settingsView.id = 'enterprise-settings'; settingsView.className = 'view page';
|
||
document.body.appendChild(settingsView);
|
||
const settingsButton = makeNavButton('Настройки', 'management', 'nav-settings', settingsView, renderSettings);
|
||
|
||
// IndexedDB backups.
|
||
function openBackupDb() {
|
||
return new Promise((resolve, reject) => {
|
||
const request = indexedDB.open('SunCateringEnterprise', 1);
|
||
request.onupgradeneeded = () => { const db=request.result; if(!db.objectStoreNames.contains('backups'))db.createObjectStore('backups',{keyPath:'id'}); };
|
||
request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error);
|
||
});
|
||
}
|
||
const FALLBACK_BACKUPS_KEY='sunFallbackBackupsV1';
|
||
function fallbackBackups(){
|
||
try{const rows=JSON.parse(localStorage.getItem(FALLBACK_BACKUPS_KEY)||'[]');return Array.isArray(rows)?rows:[]}catch(_){return []}
|
||
}
|
||
function saveFallbackBackups(rows){
|
||
const compact=(Array.isArray(rows)?rows:[]).slice(0,4);
|
||
localStorage.setItem(FALLBACK_BACKUPS_KEY,JSON.stringify(compact));
|
||
}
|
||
function collectState() {
|
||
const storage = {};
|
||
for (let i=0;i<localStorage.length;i++) {
|
||
const key=localStorage.key(i); if (!key || !key.startsWith('sun') || key === AUDIT_KEY || key === FALLBACK_BACKUPS_KEY) continue;
|
||
storage[key]=localStorage.getItem(key);
|
||
}
|
||
return { format:'sun-catering-backup', version:VERSION, createdAt:new Date().toISOString(), storage };
|
||
}
|
||
function applyState(state) {
|
||
if (!state || state.format !== 'sun-catering-backup' || !state.storage) throw new Error('Неверный формат резервной копии.');
|
||
Object.entries(state.storage).forEach(([key,value])=>{ if(key.startsWith('sun'))localStorage.setItem(key,String(value)); });
|
||
}
|
||
function downloadBlob(name, content, type='application/octet-stream') {
|
||
const blob = content instanceof Blob ? content : new Blob([content],{type});
|
||
const url=URL.createObjectURL(blob), a=document.createElement('a'); a.href=url;a.download=name;document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),1000);
|
||
}
|
||
async function createBackup(reason='Ручная копия') {
|
||
const state=collectState(); const row={id:uid(),createdAt:state.createdAt,reason,state};
|
||
let storedInIdb=false;
|
||
try{
|
||
const db=await openBackupDb();
|
||
await new Promise((resolve,reject)=>{const tx=db.transaction('backups','readwrite');tx.objectStore('backups').put(row);tx.oncomplete=resolve;tx.onerror=()=>reject(tx.error);tx.onabort=()=>reject(tx.error||new Error('IndexedDB transaction aborted'));});
|
||
storedInIdb=true;
|
||
}catch(_){
|
||
// Some browsers restrict IndexedDB for local file:// pages. Use a small
|
||
// localStorage fallback so the button still works in portable mode.
|
||
const rows=fallbackBackups().filter(x=>x&&x.id!==row.id);rows.unshift(row);saveFallbackBackups(rows);
|
||
}
|
||
const rows=await listBackups();
|
||
if(storedInIdb&&rows.length>12){const excess=rows.slice(12);await Promise.all(excess.map(x=>deleteBackup(x.id,false)));}
|
||
recordAudit('Резервная копия', reason); renderBackups(); return row;
|
||
}
|
||
async function listBackups() {
|
||
let idbRows=[];
|
||
try{
|
||
const db=await openBackupDb();
|
||
idbRows=await new Promise((resolve,reject)=>{const req=db.transaction('backups','readonly').objectStore('backups').getAll();req.onsuccess=()=>resolve(req.result||[]);req.onerror=()=>reject(req.error);});
|
||
}catch(_){idbRows=[];}
|
||
const merged=new Map();[...idbRows,...fallbackBackups()].forEach(row=>{if(row?.id&&!merged.has(String(row.id)))merged.set(String(row.id),row);});
|
||
return [...merged.values()].sort((a,b)=>String(b.createdAt||'').localeCompare(String(a.createdAt||'')));
|
||
}
|
||
async function deleteBackup(id, rerender=true){
|
||
let deleted=false;
|
||
try{const db=await openBackupDb();await new Promise((resolve,reject)=>{const tx=db.transaction('backups','readwrite');tx.objectStore('backups').delete(id);tx.oncomplete=resolve;tx.onerror=()=>reject(tx.error);});deleted=true;}catch(_){}
|
||
const fallback=fallbackBackups(),next=fallback.filter(x=>String(x?.id)!==String(id));
|
||
if(next.length!==fallback.length){saveFallbackBackups(next);deleted=true;}
|
||
if(rerender)renderBackups();return deleted;
|
||
}
|
||
async function restoreBackup(id){
|
||
let row=null;
|
||
try{const db=await openBackupDb();row=await new Promise((resolve,reject)=>{const req=db.transaction('backups','readonly').objectStore('backups').get(id);req.onsuccess=()=>resolve(req.result||null);req.onerror=()=>reject(req.error);});}catch(_){}
|
||
if(!row)row=fallbackBackups().find(x=>String(x?.id)===String(id))||null;
|
||
if(!row)return toast('Резервная копия не найдена.','warn');
|
||
if(!confirm(`Восстановить копию от ${new Date(row.createdAt).toLocaleString('ru-RU')}? Текущие данные будут заменены.`))return;
|
||
await createBackup('Перед восстановлением');applyState(row.state);recordAudit('Восстановление базы',row.reason);location.reload();
|
||
}
|
||
|
||
let users = safeJson(localStorage.getItem(USERS_KEY) || '[]', []);
|
||
const roleNames = { owner:'Владелец', manager:'Менеджер', kitchen:'Кухня', purchaser:'Закупщик', courier:'Курьер', waiter:'Официант' };
|
||
const permissions = {
|
||
owner:['*'],
|
||
manager:['Главная','Новый заказ','Заказы','Календарь','Карта','Клиенты','Рассылки','Деньги','Статистика','Печать'],
|
||
kitchen:['Главная','Новый заказ','Календарь','Производство','Склад','Печать'],
|
||
purchaser:['Главная','Закупки','Склад','Поставщики','Печать'],
|
||
courier:['Главная','Заказы','Календарь','Карта','Маршруты'],
|
||
waiter:['Главная','Заказы','Календарь']
|
||
};
|
||
async function pinHash(pin){if(!pin)return'';if(crypto?.subtle){const data=new TextEncoder().encode(String(pin));const hash=await crypto.subtle.digest('SHA-256',data);return [...new Uint8Array(hash)].map(x=>x.toString(16).padStart(2,'0')).join('');}let h=2166136261;for(const c of String(pin)){h^=c.charCodeAt(0);h=Math.imul(h,16777619);}return String(h>>>0);}
|
||
const saveUsers=()=>localStorage.setItem(USERS_KEY,JSON.stringify(users));
|
||
|
||
const userModal=document.createElement('div');userModal.id='sun-user-modal';userModal.className='modal';userModal.innerHTML=`<div class="dialog"><div class="dialog-head"><h2 id="sunUserTitle">Сотрудник системы</h2><button type="button" onclick="closeModal('sun-user-modal')">×</button></div><div class="enterprise-fields"><label>Имя<input id="sunUserName"></label><label>Роль<select id="sunUserRole">${Object.entries(roleNames).map(([k,v])=>`<option value="${k}">${v}</option>`).join('')}</select></label><label>PIN-код<input id="sunUserPin" type="password" inputmode="numeric" placeholder="Не менять — оставьте пустым"></label><label style="flex-direction:row;align-items:center;margin-top:23px"><input id="sunUserActive" type="checkbox" checked style="width:18px;height:18px"> Активный пользователь</label></div><div class="actions"><button class="primary" id="sunUserSave" type="button">Сохранить</button><button class="danger" id="sunUserDelete" type="button">Удалить</button></div></div>`;document.body.appendChild(userModal);
|
||
let editingUserId=null;
|
||
function openUserEditor(id=null){editingUserId=id;const u=users.find(x=>String(x.id)===String(id));$('sunUserTitle').textContent=u?'Изменить пользователя':'Новый пользователь';$('sunUserName').value=u?.name||'';$('sunUserRole').value=u?.role||'manager';$('sunUserPin').value='';$('sunUserActive').checked=u?u.active!==false:true;$('sunUserDelete').style.display=u?'inline-block':'none';modal('sun-user-modal');}
|
||
$('sunUserSave').onclick=async()=>{const name=$('sunUserName').value.trim();if(!name)return toast('Введите имя пользователя.','warn');const pin=$('sunUserPin').value;let u=users.find(x=>String(x.id)===String(editingUserId));if(!u){u={id:uid(),pinHash:'',createdAt:new Date().toISOString()};users.push(u);}u.name=name;u.role=$('sunUserRole').value;u.active=$('sunUserActive').checked;if(pin)u.pinHash=await pinHash(pin);saveUsers();recordAudit('Пользователь системы',`${name} · ${roleNames[u.role]}`);closeModal('sun-user-modal');renderSettings();};
|
||
$('sunUserDelete').onclick=()=>{const u=users.find(x=>String(x.id)===String(editingUserId));if(!u||!confirm(`Удалить пользователя «${u.name}»?`))return;users=users.filter(x=>String(x.id)!==String(editingUserId));saveUsers();recordAudit('Удалён пользователь',u.name);closeModal('sun-user-modal');renderSettings();};
|
||
window.sunEditSystemUser=openUserEditor;
|
||
|
||
function renderUsers(){const host=$('usersList');if(!host)return;host.innerHTML=users.length?users.map(u=>`<div class="user-row"><div><b>${esc(u.name)}</b> <span class="role-chip">${esc(roleNames[u.role]||u.role)}</span><small style="display:block">${u.active===false?'Неактивен':u.pinHash?'PIN установлен':'PIN не установлен'}</small></div><button class="mini" type="button" onclick="sunEditSystemUser('${esc(u.id)}')">Изменить</button></div>`).join(''):'<p class="hint">Пользователей пока нет. Добавьте владельца с PIN-кодом перед включением входа.</p>';}
|
||
async function renderBackups(){const host=$('backupList');if(!host)return;try{const rows=await listBackups();host.innerHTML=rows.length?rows.map(r=>`<div class="backup-row"><div><b>${new Date(r.createdAt).toLocaleString('ru-RU')}</b><small style="display:block">${esc(r.reason||'Резервная копия')}</small></div><div><button class="mini" type="button" data-restore-backup="${esc(r.id)}">Восстановить</button> <button class="mini danger" type="button" data-delete-backup="${esc(r.id)}">Удалить</button></div></div>`).join(''):'<p class="hint">Резервных копий пока нет.</p>';host.querySelectorAll('[data-restore-backup]').forEach(b=>b.onclick=()=>restoreBackup(b.dataset.restoreBackup));host.querySelectorAll('[data-delete-backup]').forEach(b=>b.onclick=()=>deleteBackup(b.dataset.deleteBackup));}catch(e){host.innerHTML=`<p class="hint">Не удалось открыть хранилище копий: ${esc(e.message)}</p>`;}}
|
||
function renderAudit(){const host=$('auditList');if(!host)return;const rows=auditLog.slice(0,80);host.innerHTML=rows.length?rows.map(r=>`<div class="audit-row"><small>${new Date(r.at).toLocaleString('ru-RU')}</small><b>${esc(r.user)}</b><div>${esc(r.action)}${r.details?`<small style="display:block">${esc(r.details)}</small>`:''}</div></div>`).join(''):'<p class="hint">История пока пуста.</p>';}
|
||
|
||
function renderSettings(){
|
||
settingsView.innerHTML=`<div class="catalog-head"><div><h1>Настройки</h1><p class="hint" style="margin:5px 0 0">Профиль, аккаунт, пользователи и рабочие настройки.</p></div><span class="role-chip">Версия ${VERSION}</span></div>
|
||
<div class="enterprise-grid">
|
||
<section class="enterprise-card wide"><h2>Облачная синхронизация</h2><p class="hint">Профиль и рабочая база компании.</p><div class="enterprise-fields"><label class="wide">Supabase URL<input id="cloudUrl" value="${esc(settings.cloud.supabaseUrl)}" placeholder="https://project.supabase.co"></label><label class="wide">Anon key<input id="cloudKey" type="password" value="${esc(settings.cloud.anonKey)}"></label><label>Рабочая база<input id="cloudWorkspace" value="${esc(settings.cloud.workspaceId)}"></label><label style="flex-direction:row;align-items:center;margin-top:23px"><input id="cloudAuto" type="checkbox" ${settings.cloud.autoSync?'checked':''} style="width:18px;height:18px"> Автосинхронизация</label></div><div class="actions"><button class="primary" id="cloudSave" type="button">Сохранить</button><button class="outline" id="cloudPush" type="button">Отправить в облако</button><button class="outline" id="cloudPull" type="button">Загрузить из облака</button></div><div class="status" id="cloudStatus">${settings.cloud.lastSync?`Последняя синхронизация: ${esc(new Date(settings.cloud.lastSync).toLocaleString('ru-RU'))}`:'Облако не синхронизировалось.'}</div></section>
|
||
<section class="enterprise-card"><h2>Пользователи и роли</h2><p class="hint">Локальные роли скрывают недоступные разделы на этом устройстве. Для полноценной защиты нескольких устройств нужна облачная авторизация.</p><label style="flex-direction:row;align-items:center"><input id="rolesToggle" type="checkbox" ${settings.rolesEnabled?'checked':''} style="width:18px;height:18px"> Включить вход по PIN-коду</label><div class="actions"><button class="primary" id="addSystemUser" type="button">Добавить пользователя</button></div><div class="users-list" id="usersList"></div></section>
|
||
<section class="enterprise-card wide"><h2>История изменений</h2><div class="actions"><button class="outline" id="clearAuditBtn" type="button">Очистить историю</button></div><div class="audit-list" id="auditList"></div></section>
|
||
</div>`;
|
||
$('cloudSave').onclick=saveCloudSettings;$('cloudPush').onclick=cloudPush;$('cloudPull').onclick=cloudPull;
|
||
$('rolesToggle').onchange=async e=>{if(e.target.checked){const owner=users.find(u=>u.active!==false&&u.role==='owner'&&u.pinHash);if(!owner){e.target.checked=false;return toast('Сначала добавьте активного владельца и задайте ему PIN-код.','warn',6000);}}settings.rolesEnabled=e.target.checked;saveSettings();recordAudit('Режим входа',settings.rolesEnabled?'включён':'выключен');if(settings.rolesEnabled)toast('Режим входа включён. После выхода потребуется PIN.','success');else{sessionStorage.removeItem(SESSION_KEY);applyPermissions();}};
|
||
$('addSystemUser').onclick=()=>openUserEditor();
|
||
$('clearAuditBtn').onclick=()=>{if(confirm('Очистить историю изменений?')){auditLog=[];localStorage.setItem(AUDIT_KEY,'[]');renderAudit();}};
|
||
renderUsers();renderAudit();
|
||
}
|
||
|
||
function saveCloudSettings(){settings.cloud.supabaseUrl=$('cloudUrl').value.trim().replace(/\/$/,'');settings.cloud.anonKey=$('cloudKey').value.trim();settings.cloud.workspaceId=$('cloudWorkspace').value.trim()||'sun-catering-main';settings.cloud.autoSync=$('cloudAuto').checked;settings.cloud.enabled=Boolean(settings.cloud.supabaseUrl&&settings.cloud.anonKey);saveSettings();toast('Настройки облака сохранены.','success');}
|
||
function cloudHeaders(){return {'apikey':settings.cloud.anonKey,'Authorization':`Bearer ${settings.cloud.anonKey}`,'Content-Type':'application/json'};}
|
||
function cloudStatus(text,bad=false){const el=$('cloudStatus');if(el){el.textContent=text;el.className=`status ${bad?'bad':'good'}`;}}
|
||
async function cloudPush(){saveCloudSettings();if(!settings.cloud.enabled)return cloudStatus('Укажите Supabase URL и Anon key.',true);cloudStatus('Отправляю данные…');try{const url=`${settings.cloud.supabaseUrl}/rest/v1/sun_app_state?on_conflict=workspace_id`;const payload={workspace_id:settings.cloud.workspaceId,payload:collectState(),updated_at:new Date().toISOString()};const res=await sunFetchWithTimeout(url,{method:'POST',headers:{...cloudHeaders(),'Prefer':'resolution=merge-duplicates,return=minimal'},body:JSON.stringify(payload)},12000);if(!res.ok)throw new Error(`${res.status}: ${await res.text()}`);settings.cloud.lastSync=new Date().toISOString();saveSettings();recordAudit('Облачная синхронизация','отправлено');cloudStatus(`Отправлено: ${new Date().toLocaleString('ru-RU')}`);toast('База отправлена в облако.','success');}catch(e){cloudStatus(`Ошибка: ${e.message}`,true);toast('Не удалось отправить данные в облако.','error');}}
|
||
async function cloudPull(){saveCloudSettings();if(!settings.cloud.enabled)return cloudStatus('Укажите Supabase URL и Anon key.',true);cloudStatus('Загружаю данные…');try{const url=`${settings.cloud.supabaseUrl}/rest/v1/sun_app_state?workspace_id=eq.${encodeURIComponent(settings.cloud.workspaceId)}&select=payload,updated_at&limit=1`;const res=await sunFetchWithTimeout(url,{headers:cloudHeaders()},12000);if(!res.ok)throw new Error(`${res.status}: ${await res.text()}`);const rows=await res.json();if(!rows.length)throw new Error('В облаке нет сохранённой базы.');if(!confirm(`Загрузить облачную копию от ${new Date(rows[0].updated_at).toLocaleString('ru-RU')}?`))return;await createBackup('Перед загрузкой из облака');applyState(rows[0].payload);recordAudit('Облачная синхронизация','загружено');location.reload();}catch(e){cloudStatus(`Ошибка: ${e.message}`,true);toast('Не удалось загрузить облачную базу.','error');}}
|
||
let cloudTimer=null;function scheduleCloudPush(){/* Legacy Supabase snapshot sync disabled in Cloud v2 build. */return;}
|
||
|
||
// Track important local changes and queue optional cloud sync.
|
||
(()=>{
|
||
if(Storage.prototype.__sunEnterpriseTracked)return;
|
||
const nativeSet=Storage.prototype.setItem;
|
||
const tracked={sunOrders:'Заказы',sunBoxes:'Каталог',sunStock:'Склад',sunStockMoves:'Движения склада',sunFinanceRecordsV2:'Финансы',sunEmployees:'Команда',sunSuppliers:'Поставщики',sunRoutePlansV1:'Маршруты'};
|
||
const lastAudit=new Map();
|
||
Object.defineProperty(Storage.prototype,'__sunEnterpriseTracked',{value:true,configurable:true});
|
||
Storage.prototype.setItem=function(key,value){
|
||
const old=this===localStorage?this.getItem(key):null;
|
||
const result=nativeSet.call(this,key,value);
|
||
if(this===localStorage&&tracked[key]&&old!==String(value)){
|
||
scheduleCloudPush();
|
||
const now=Date.now();
|
||
if(now-(lastAudit.get(key)||0)>1500){lastAudit.set(key,now);recordAudit('Изменён раздел',tracked[key]);}
|
||
}
|
||
return result;
|
||
};
|
||
})();
|
||
|
||
// ---------- Roles / login ----------
|
||
function applyPermissions(){
|
||
const user=currentUser();$('sunCurrentUserLabel').textContent=settings.rolesEnabled?`${user.name} · ${roleNames[user.role]||user.role}`:'Владелец';$('sunLogoutBtn').style.display=settings.rolesEnabled?'flex':'none';
|
||
const allowed=permissions[user.role]||[];qsa('header nav button').forEach(button=>{const label=button.textContent.trim();button.style.display=(!settings.rolesEnabled||allowed.includes('*')||allowed.includes(label))?'':'none';});
|
||
settingsButton.style.display=(!settings.rolesEnabled||user.role==='owner')?'':'none';
|
||
}
|
||
function showLogin(){
|
||
if(!settings.rolesEnabled||sessionStorage.getItem(SESSION_KEY)){applyPermissions();return;}
|
||
const active=users.filter(u=>u.active!==false&&u.pinHash);if(!active.length){settings.rolesEnabled=false;saveSettings();applyPermissions();return;}
|
||
const overlay=document.createElement('div');overlay.className='sun-login-overlay';overlay.id='sunLoginOverlay';overlay.innerHTML=`<div class="sun-login-card"><div class="sun-login-brand"><img src="${window.CateriumBranding.logoHTML()}" alt="Логотип компании"><div><h1>${window.CateriumBranding.nameHTML()}</h1><p class="hint" style="margin:3px 0 0">Вход в рабочую систему</p></div></div><label>Пользователь<select id="sunLoginUser">${active.map(u=>`<option value="${esc(u.id)}">${esc(u.name)} · ${esc(roleNames[u.role]||u.role)}</option>`).join('')}</select></label><label>PIN-код<input id="sunLoginPin" type="password" inputmode="numeric" autocomplete="current-password"></label><button class="primary" id="sunLoginSubmit" type="button" style="width:100%;margin-top:14px">Войти</button><div class="error" id="sunLoginError"></div></div>`;document.body.appendChild(overlay);const submit=async()=>{const u=users.find(x=>String(x.id)===String($('sunLoginUser').value));const hash=await pinHash($('sunLoginPin').value);if(!u||hash!==u.pinHash){$('sunLoginError').textContent='Неверный PIN-код.';return; }sessionStorage.setItem(SESSION_KEY,u.id);overlay.remove();recordAudit('Вход в систему',u.name);applyPermissions();const first=qsa('header nav button').find(b=>b.style.display!=='none');if(first&&!qs('.view.on'))first.click();};$('sunLoginSubmit').onclick=submit;$('sunLoginPin').addEventListener('keydown',e=>{if(e.key==='Enter')submit();});setTimeout(()=>$('sunLoginPin').focus(),50);
|
||
}
|
||
$('sunLogoutBtn').onclick=()=>{recordAudit('Выход из системы',currentUser().name);sessionStorage.removeItem(SESSION_KEY);location.reload();};
|
||
|
||
// ---------- Draft autosave UI disabled ----------
|
||
// Draft banners/restoration prompts are intentionally disabled to keep “Новый заказ” clean.
|
||
function saveDraftAutosave(){}
|
||
function checkAutosave(){}
|
||
|
||
// ---------- Portable mode: background installers are intentionally absent ----------
|
||
|
||
// Auto backup and public API.
|
||
async function maybeAutoBackup(){if(!settings.autoBackup)return;try{const rows=await listBackups();const latest=rows[0];if(!latest||Date.now()-new Date(latest.createdAt).getTime()>20*3600000)await createBackup('Автоматическая ежедневная копия');}catch(_) {}}
|
||
window.SunEnterprise={VERSION,toast,recordAudit,collectState,applyState,createBackup,scheduleCloudPush,activateNav,currentUser,openView};
|
||
|
||
// Initial setup after all existing modules are loaded.
|
||
applyPermissions();showLogin();checkAutosave();maybeAutoBackup();
|
||
})();
|
||
|
||
/* ===== Notifications and deadline control ===== */
|
||
(() => {
|
||
'use strict';
|
||
const $=id=>document.getElementById(id),qsa=(s,r=document)=>[...r.querySelectorAll(s)];
|
||
if(!$('sunNotificationsBtn')||document.getElementById('sun-notifications-modal'))return;
|
||
const esc=window.SunSafe.escapeHTML;
|
||
const readKey='sunReadNotificationsV1';
|
||
const getOrders=()=>{try{return orders}catch(_){try{return JSON.parse(localStorage.sunOrders||'[]')}catch(__){return[]}}};
|
||
const getBoxes=()=>{try{return boxes}catch(_){try{return JSON.parse(localStorage.sunBoxes||'[]')}catch(__){return[]}}};
|
||
const getEmployees=()=>{try{return JSON.parse(localStorage.sunEmployees||'[]')}catch(_){return[]}};
|
||
const getStock=()=>{try{return JSON.parse(localStorage.sunStock||'[]')}catch(_){return[]}};
|
||
const orderTotal=o=>Number(o.total||0)||((o.lines||[]).reduce((s,l)=>{const b=getBoxes().find(x=>String(x.id)===String(l.id));return s+Number(b?.price||0)*Number(l.qty||0)},0));
|
||
const paid=o=>Math.max(0,Number(o.prepayment||0));
|
||
const toStamp=o=>new Date(`${o.date||'1970-01-01'}T${o.time||'00:00'}:00`).getTime();
|
||
const now=()=>Date.now();
|
||
const readSet=()=>new Set((()=>{try{return JSON.parse(localStorage.getItem(readKey)||'[]')}catch(_){return[]}})());
|
||
const saveRead=set=>localStorage.setItem(readKey,JSON.stringify([...set].slice(-500)));
|
||
|
||
const style=document.createElement('style');style.textContent=`
|
||
.notification-list{display:grid;gap:8px;margin-top:12px}.notification-item{border:1px solid #d9dde0;border-left:5px solid #7da4c4;border-radius:9px;background:#fff;padding:11px 12px;display:grid;grid-template-columns:1fr auto;gap:10px;align-items:start}.notification-item.critical{border-left-color:#c85b52;background:#fffafa}.notification-item.warn{border-left-color:#d5a13f;background:#fffdf7}.notification-item.info{border-left-color:#6c9fbc}.notification-item.read{opacity:.58}.notification-item h3{margin:0;color:#15364c;font-size:15px}.notification-item p{margin:4px 0 0;color:#68717a;font-size:13px}.notification-actions{display:flex;gap:5px;flex-wrap:wrap;justify-content:flex-end}.notification-filter{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.notification-empty{text-align:center;color:#879198;padding:28px}.notification-summary{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:12px}.notification-summary>div{background:#f5f7f7;border-radius:8px;padding:10px}.notification-summary small{display:block;color:#68717a}.notification-summary b{display:block;color:#15364c;font-size:18px;margin-top:3px}@media(max-width:650px){.notification-item{grid-template-columns:1fr}.notification-actions{justify-content:flex-start}.notification-summary{grid-template-columns:1fr 1fr}}
|
||
`;document.head.appendChild(style);
|
||
|
||
const notificationModal=document.createElement('div');notificationModal.id='sun-notifications-modal';notificationModal.className='modal';notificationModal.innerHTML=`<div class="dialog" style="width:min(900px,100%)"><div class="dialog-head"><h2>Уведомления и контроль сроков</h2><button type="button" onclick="closeModal('sun-notifications-modal')">×</button></div><div class="notification-filter"><label>Показывать<select id="notificationFilter"><option value="all">Все</option><option value="unread" selected>Только новые</option><option value="critical">Только важные</option></select></label><button class="outline" id="markAllNotifications" type="button">Отметить всё прочитанным</button><button class="outline" id="requestBrowserNotifications" type="button">Разрешить уведомления браузера</button></div><div class="notification-summary" id="notificationSummary"></div><div class="notification-list" id="notificationList"></div></div>`;document.body.appendChild(notificationModal);
|
||
|
||
function hasCourier(order){const employees=getEmployees(),ids=(order.staffIds||[]).map(String);return employees.some(e=>ids.includes(String(e.id))&&String(e.role||'').toLowerCase().includes('курьер'));}
|
||
function checklistReady(order){const values=Object.values(order.checklist||{});return values.length&&values.every(Boolean);}
|
||
function generate(){
|
||
const items=[],orders=getOrders(),employees=getEmployees(),stock=getStock(),boxes=getBoxes(),current=now();
|
||
const add=(id,severity,title,text,action='order',entityId='')=>items.push({id,severity,title,text,action,entityId});
|
||
orders.filter(o=>!['Отменён','Отдан заказчику'].includes(o.status||'Новый')).forEach(o=>{
|
||
const stamp=toStamp(o),hours=(stamp-current)/3600000;
|
||
if(hours>=0&&hours<=48&&!o.address)add(`address-${o.id}`,'critical',`Заказ №${o.id}: нет адреса`,`${o.event||'Мероприятие'} · ${o.date||'—'} ${o.time||''}`,'order',o.id);
|
||
if(hours>=0&&hours<=48&&o.address&&!(o.geo&&Number.isFinite(Number(o.geo.lat))&&Number.isFinite(Number(o.geo.lon))))add(`geo-${o.id}`,'warn',`Заказ №${o.id}: нет картинки карты`,`Создайте статичную карту доставки: ${o.address}`,'order',o.id);
|
||
const debt=Math.max(0,orderTotal(o)-paid(o));
|
||
if(hours>=0&&hours<=72&&debt>0)add(`debt-${o.id}`,'info',`Заказ №${o.id}: осталось получить оплату`,`${debt.toLocaleString('ru-RU')} ₽ · ${o.contact||'клиент не указан'}`,'order',o.id);
|
||
if(hours>=0&&hours<=3)add(`soon-${o.id}`,'critical',`Скоро выдача заказа №${o.id}`,`${o.time||'—'} · ${o.event||'Мероприятие'} · ${o.address||'самовывоз/адрес не указан'}`,'order',o.id);
|
||
});
|
||
// Team conflicts at the same date/time.
|
||
const slots=new Map();orders.filter(o=>!['Отменён','Отдан заказчику'].includes(o.status||'Новый')).forEach(o=>(o.staffIds||[]).forEach(id=>{const k=`${id}|${o.date}|${o.time}`;if(!slots.has(k))slots.set(k,[]);slots.get(k).push(o);}));
|
||
slots.forEach((list,key)=>{if(list.length<2)return;const employee=employees.find(e=>String(e.id)===String(key.split('|')[0]));add(`conflict-${key}`,'critical',`Пересечение сотрудника: ${employee?.name||'сотрудник'}`,list.map(o=>`№${o.id} ${o.event||''}`).join(' и '),'team','');});
|
||
// Low stock.
|
||
stock.filter(p=>Number(p.min||0)>0&&Number(p.qty||0)<=Number(p.min||0)).slice(0,30).forEach(p=>add(`stock-${p.id}`,Number(p.qty||0)<=0?'critical':'warn',`Низкий остаток: ${p.name}`,`Есть ${Number(p.qty||0).toLocaleString('ru-RU')} ${p.unit||''}, минимум ${Number(p.min||0).toLocaleString('ru-RU')}`,'stock',p.id));
|
||
return items;
|
||
}
|
||
let cached=[];
|
||
function refreshBadge(){cached=generate();const read=readSet(),unread=cached.filter(x=>!read.has(x.id)).length;$('sunNotificationBadge').textContent=unread?String(unread):'';return cached;}
|
||
function render(){
|
||
const items=refreshBadge(),read=readSet(),filter=$('notificationFilter').value,shown=items.filter(x=>filter==='all'||(filter==='unread'&&!read.has(x.id))||(filter==='critical'&&x.severity==='critical'));
|
||
$('notificationSummary').innerHTML=`<div><small>Всего</small><b>${items.length}</b></div><div><small>Новых</small><b>${items.filter(x=>!read.has(x.id)).length}</b></div><div><small>Важных</small><b>${items.filter(x=>x.severity==='critical').length}</b></div>`;
|
||
$('notificationList').innerHTML=shown.length?shown.map(x=>`<article class="notification-item ${x.severity} ${read.has(x.id)?'read':''}" data-notification="${esc(x.id)}"><div><h3>${esc(x.title)}</h3><p>${esc(x.text)}</p></div><div class="notification-actions"><button class="mini" type="button" data-open-notification="${esc(x.id)}">Открыть</button><button class="mini" type="button" data-read-notification="${esc(x.id)}">${read.has(x.id)?'Вернуть в новые':'Прочитано'}</button></div></article>`).join(''):'<div class="notification-empty">Уведомлений по выбранному фильтру нет.</div>';
|
||
qsa('[data-read-notification]',$('notificationList')).forEach(b=>b.onclick=()=>{const set=readSet(),id=b.dataset.readNotification;set.has(id)?set.delete(id):set.add(id);saveRead(set);render();});
|
||
qsa('[data-open-notification]',$('notificationList')).forEach(b=>b.onclick=()=>{const item=items.find(x=>x.id===b.dataset.openNotification);if(!item)return;const set=readSet();set.add(item.id);saveRead(set);closeModal('sun-notifications-modal');if(item.action==='order')window.openOrder?.(Number(item.entityId));else if(item.action==='stock'){SunEnterprise?.activateNav?.('Склад');setTimeout(()=>window.editStockProduct?.(item.entityId),80);}else if(item.action==='team')SunEnterprise?.activateNav?.('Команда');refreshBadge();});
|
||
}
|
||
$('sunNotificationsBtn').onclick=()=>{notificationModal.classList.add('on');render();};
|
||
$('notificationFilter').onchange=render;
|
||
$('markAllNotifications').onclick=()=>{const set=readSet();generate().forEach(x=>set.add(x.id));saveRead(set);render();};
|
||
$('requestBrowserNotifications').onclick=async()=>{if(!('Notification'in window))return SunEnterprise?.toast?.('Уведомления браузера не поддерживаются.','warn');const permission=await Notification.requestPermission();SunEnterprise?.toast?.(permission==='granted'?'Уведомления браузера разрешены.':'Разрешение не выдано.',permission==='granted'?'success':'warn');};
|
||
refreshBadge();setInterval(refreshBadge,60000);
|
||
window.addEventListener('sun:stock-changed',refreshBadge);
|
||
if(typeof window.saveOrder==='function'){const prev=window.saveOrder;window.saveOrder=async(...args)=>{const result=prev(...args);if(result&&typeof result.then==='function')await result;setTimeout(refreshBadge,30);return result;};}
|
||
if(typeof window.status==='function'){const prev=window.status;window.status=(...args)=>{const result=prev(...args);setTimeout(refreshBadge,20);return result;};}
|
||
})();
|
||
|
||
/* ===== ORDER PAYMENTS + PROFITABILITY ===== */
|
||
(() => {
|
||
'use strict';
|
||
if (document.getElementById('sun-order-economy-style')) return;
|
||
const $ = id => document.getElementById(id);
|
||
const esc=window.SunSafe.escapeHTML;
|
||
const fmt = value => `${Number(value || 0).toLocaleString('ru-RU',{maximumFractionDigits:2})} ₽`;
|
||
const uid = () => typeof window.sunUUID === 'function' ? window.sunUUID() : `pay-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||
const style=document.createElement('style');style.id='sun-order-economy-style';style.textContent=`
|
||
.order-finance-panel{margin-top:15px;border:1px solid var(--l);border-radius:10px;background:#fff;overflow:hidden}.order-finance-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:11px 13px;background:#f4f6f7}.order-finance-head h3{margin:0;color:var(--n);font-size:16px}.payment-list{padding:0 13px}.payment-row{display:grid;grid-template-columns:105px 1fr 105px auto;gap:8px;align-items:center;padding:9px 0;border-bottom:1px solid #e8ebed}.payment-row:last-child{border-bottom:0}.payment-row small{color:#68717a}.payment-empty{padding:14px;color:#879198}.economy-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:9px;padding:13px}.economy-summary{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;padding:0 13px 13px}.economy-summary>div{padding:10px;border-radius:8px;background:#f7f8f8}.economy-summary small{display:block;color:#68717a}.economy-summary b{display:block;color:var(--n);font-size:17px;margin-top:4px}.economy-summary .profit.good{background:#eaf6ed}.economy-summary .profit.bad{background:#fbeeee}.profit-table{width:100%;border-collapse:collapse}.profit-table th,.profit-table td{padding:9px;border-bottom:1px solid #e4e7e9;text-align:left}.profit-table th{font-size:12px;color:#68717a}.profit-table .num{text-align:right}.profit-badge{display:inline-flex;padding:3px 7px;border-radius:999px;font-size:11px;font-weight:700;background:#eaf6ed;color:#347154}.profit-badge.bad{background:#fbeeee;color:#943d3d}
|
||
@media(max-width:720px){.payment-row{grid-template-columns:1fr 1fr}.payment-row>*:last-child{justify-self:end}.economy-grid,.economy-summary{grid-template-columns:1fr 1fr}}
|
||
`;document.head.appendChild(style);
|
||
|
||
const details=document.getElementById('details');if(!details)return;
|
||
const panel=document.createElement('div');panel.id='orderEconomyPanel';panel.innerHTML=`
|
||
<section class="order-finance-panel"><div class="order-finance-head"><h3>Платежи клиента</h3><button class="outline" id="addOrderPayment" type="button">+ Добавить платёж</button></div><div class="payment-list" id="orderPaymentList"></div></section>
|
||
<section class="order-finance-panel"><div class="order-finance-head"><h3>Экономика заказа</h3><span class="hint">Себестоимость продуктов рассчитывается по закупочным ценам склада</span></div><div class="economy-grid"><label>Доставка / транспорт<input id="orderDeliveryCost" type="number" min="0" step="1" value="0"></label><label>Работа команды<input id="orderLaborCost" type="number" min="0" step="1" value="0"></label><label>Прочие расходы<input id="orderOtherCost" type="number" min="0" step="1" value="0"></label></div><div class="economy-summary" id="orderEconomySummary"></div></section>`;
|
||
details.appendChild(panel);
|
||
|
||
const paymentModalEl=document.createElement('div');paymentModalEl.id='order-payment-modal';paymentModalEl.className='modal';paymentModalEl.innerHTML=`<div class="dialog"><div class="dialog-head"><h2 id="paymentModalTitle">Платёж</h2><button type="button" onclick="closeModal('order-payment-modal')">×</button></div><div class="enterprise-fields"><label>Дата<input id="paymentDate" type="date"></label><label>Сумма<input id="paymentAmount" type="number" min="0" step="1"></label><label>Способ<select id="paymentMethod"><option>Перевод</option><option>Наличные</option><option>Карта</option><option>СБП</option><option>Безналичный счёт</option><option>Другое</option></select></label><label>Комментарий<input id="paymentNote" placeholder="Предоплата, доплата…"></label></div><div class="actions"><button class="primary" id="paymentSave" type="button">Сохранить</button><button class="danger" id="paymentDelete" type="button">Удалить</button></div></div>`;document.body.appendChild(paymentModalEl);
|
||
let editingPaymentId=null;
|
||
|
||
function currentDraft(){try{return draft;}catch(_){return {};}}
|
||
function normalizePayments(){const d=currentDraft();if(!Array.isArray(d.payments)){d.payments=[];const legacy=Math.max(0,Number(d.prepayment||$('prepayment')?.value||0));if(legacy>0)d.payments.push({id:uid(),date:d.date||$('date')?.value||new Date().toISOString().slice(0,10),amount:legacy,method:'Не указан',note:'Перенесено из поля предоплаты',legacy:true});}return d.payments;}
|
||
function totalPaid(){return normalizePayments().reduce((sum,p)=>sum+Math.max(0,Number(p.amount||0)),0);}
|
||
function stockCostMap(){const list=(()=>{try{return JSON.parse(localStorage.getItem('sunStock')||'[]')}catch(_){return[]}})();const map=new Map();list.forEach(p=>map.set(`${String(p.name||'').trim().toLowerCase()}|${String(p.unit||'').trim().toLowerCase()}`,Number(p.cost||0)));return map;}
|
||
function ingredientCost(){const costs=stockCostMap();const d=currentDraft();return (d.lines||[]).reduce((sum,line)=>{let item;try{item=boxes.find(x=>String(x.id)===String(line.id));}catch(_){item=null;}if(!item)return sum;return sum+(item.ingredients||[]).reduce((s,part)=>s+Number(part?.[1]||0)*Number(line.qty||0)*Number(costs.get(`${String(part?.[0]||'').trim().toLowerCase()}|${String(part?.[2]||'').trim().toLowerCase()}`)||0),0);},0);}
|
||
function currentTotal(){try{return typeof calcOrderTotal==='function'?Number(calcOrderTotal()||0):Number(currentDraft().total||0)}catch(_){return Number(currentDraft().total||0)}}
|
||
function syncFieldsToDraft(){const d=currentDraft();d.prepayment=Math.max(0,Number($('prepayment')?.value||d.prepayment||0));d.costs={delivery:Math.max(0,Number($('orderDeliveryCost')?.value||0)),labor:Math.max(0,Number($('orderLaborCost')?.value||0)),other:Math.max(0,Number($('orderOtherCost')?.value||0)),ingredients:ingredientCost()};try{window.updateOrderSummary?.();}catch(_){}renderEconomy();}
|
||
function renderPayments(){const host=$('orderPaymentList');if(host)host.innerHTML='';if($('prepayment')){$('prepayment').readOnly=false;$('prepayment').title='';$('prepayment').value=Math.max(0,Number(currentDraft().prepayment||0));}try{window.updateOrderSummary?.();}catch(_){}renderEconomy();}
|
||
function renderEconomy(){const total=currentTotal(),ingredients=ingredientCost(),delivery=Math.max(0,Number($('orderDeliveryCost')?.value||0)),labor=Math.max(0,Number($('orderLaborCost')?.value||0)),other=Math.max(0,Number($('orderOtherCost')?.value||0)),cost=ingredients+delivery+labor+other,profit=total-cost,margin=total>0?profit/total*100:0,paid=Math.max(0,Number($('prepayment')?.value||currentDraft().prepayment||0)),debt=Math.max(0,total-paid);const host=$('orderEconomySummary');if(!host)return;host.innerHTML=`<div><small>Продукты</small><b>${fmt(ingredients)}</b></div><div><small>Все расходы</small><b>${fmt(cost)}</b></div><div class="profit ${profit>=0?'good':'bad'}"><small>Прибыль</small><b>${fmt(profit)} · ${margin.toFixed(1)}%</b></div><div><small>Остаток клиента</small><b>${fmt(debt)}</b></div>`;}
|
||
function loadEconomy(){const d=currentDraft();const c=d.costs||{};$('orderDeliveryCost').value=Number(c.delivery||0);$('orderLaborCost').value=Number(c.labor||0);$('orderOtherCost').value=Number(c.other||0);renderPayments();}
|
||
function openPayment(id=null){editingPaymentId=id;const p=normalizePayments().find(x=>String(x.id)===String(id));$('paymentModalTitle').textContent=p?'Изменить платёж':'Новый платёж';$('paymentDate').value=p?.date||$('date')?.value||new Date().toISOString().slice(0,10);$('paymentAmount').value=p?.amount||'';$('paymentMethod').value=p?.method||'Перевод';$('paymentNote').value=p?.note||'';$('paymentDelete').style.display=p?'inline-block':'none';modal('order-payment-modal');setTimeout(()=>$('paymentAmount').focus(),50);}
|
||
$('addOrderPayment').onclick=()=>openPayment();
|
||
$('paymentSave').onclick=()=>{const amount=Math.max(0,Number($('paymentAmount').value||0));if(amount<=0)return window.SunEnterprise?.toast('Введите сумму платежа.','warn');let list=normalizePayments(),p=list.find(x=>String(x.id)===String(editingPaymentId));if(!p){p={id:uid(),createdAt:new Date().toISOString()};list.push(p);}Object.assign(p,{date:$('paymentDate').value||new Date().toISOString().slice(0,10),amount,method:$('paymentMethod').value,note:$('paymentNote').value.trim()});syncFieldsToDraft();renderPayments();closeModal('order-payment-modal');window.SunEnterprise?.recordAudit('Платёж по заказу',`${fmt(amount)} · ${p.method}`);};
|
||
$('paymentDelete').onclick=()=>{const list=normalizePayments(),p=list.find(x=>String(x.id)===String(editingPaymentId));if(!p||!confirm(`Удалить платёж ${fmt(p.amount)}?`))return;currentDraft().payments=list.filter(x=>String(x.id)!==String(editingPaymentId));syncFieldsToDraft();renderPayments();closeModal('order-payment-modal');window.SunEnterprise?.recordAudit('Удалён платёж',fmt(p.amount));};
|
||
['orderDeliveryCost','orderLaborCost','orderOtherCost'].forEach(id=>$(id).addEventListener('input',syncFieldsToDraft));
|
||
|
||
// Wrap order lifecycle.
|
||
if(typeof window.saveOrder==='function'){const prior=window.saveOrder;window.saveOrder=async(...args)=>{syncFieldsToDraft();const result=prior(...args);if(result&&typeof result.then==='function')await result;localStorage.removeItem('sunOrderDraftAutosaveV1');window.SunEnterprise?.recordAudit('Сохранён заказ',`№${currentDraft().id||'новый'} · ${currentDraft().event||''}`);window.SunEnterprise?.scheduleCloudPush();return result;};}
|
||
if(typeof window.openOrder==='function'){const prior=window.openOrder;window.openOrder=id=>{const result=prior(id);setTimeout(loadEconomy,0);return result;};}
|
||
if(typeof window.resetDraft==='function'){const prior=window.resetDraft;window.resetDraft=()=>{const result=prior();currentDraft().payments=[];currentDraft().costs={delivery:0,labor:0,other:0};setTimeout(loadEconomy,0);return result;};}
|
||
if(typeof window.render==='function'){const prior=window.render;window.render=(...args)=>{const result=prior(...args);setTimeout(renderEconomy,0);return result;};}
|
||
|
||
// Profitability tab in Money.
|
||
const tabs=document.getElementById('money-tabs');const content=document.getElementById('money-content');if(tabs&&content&&!tabs.querySelector('[data-money="profit"]')){const btn=document.createElement('button');btn.type='button';btn.dataset.money='profit';btn.textContent='Прибыль по заказам';tabs.appendChild(btn);btn.onclick=()=>{tabs.querySelectorAll('button').forEach(x=>x.classList.remove('on'));btn.classList.add('on');renderProfitability();};}
|
||
function rangeFilter(order){const mode=$('moneyPeriod')?.value||'month';if(mode==='all')return true;let from='',to='';if(mode==='custom'){from=$('moneyFrom')?.value||'';to=$('moneyTo')?.value||'';}else{const now=new Date();from=`${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-01`;to=`${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(new Date(now.getFullYear(),now.getMonth()+1,0).getDate()).padStart(2,'0')}`;}return(!from||order.date>=from)&&(!to||order.date<=to);}
|
||
function orderEconomy(order){const stock=(()=>{try{return JSON.parse(localStorage.getItem('sunStock')||'[]')}catch(_){return[]}})();const map=new Map(stock.map(p=>[`${String(p.name||'').trim().toLowerCase()}|${String(p.unit||'').trim().toLowerCase()}`,Number(p.cost||0)]));const ingredients=(order.lines||[]).reduce((sum,line)=>{let item;try{item=boxes.find(x=>String(x.id)===String(line.id));}catch(_){item=null;}return sum+(item?.ingredients||[]).reduce((s,p)=>s+Number(p?.[1]||0)*Number(line.qty||0)*Number(map.get(`${String(p?.[0]||'').trim().toLowerCase()}|${String(p?.[2]||'').trim().toLowerCase()}`)||0),0);},0);const c=order.costs||{},cost=ingredients+Number(c.delivery||0)+Number(c.labor||0)+Number(c.other||0),total=Number(order.total||0)||orderTotalFallback(order),profit=total-cost;return{ingredients,cost,total,profit,margin:total?profit/total*100:0,paid:Math.max(0,Number(order.prepayment||0))};}
|
||
function orderTotalFallback(order){return (order.lines||[]).reduce((s,l)=>{let item;try{item=boxes.find(x=>String(x.id)===String(l.id));}catch(_){item=null;}return s+Number(item?.price||0)*Number(l.qty||0)},0);}
|
||
function renderProfitability(){const list=(()=>{try{return orders}catch(_){return[]}})().filter(o=>(o.status||'Новый')!=='Отменён'&&rangeFilter(o)).sort((a,b)=>String(b.date||'').localeCompare(String(a.date||'')));const rows=list.map(o=>({o,e:orderEconomy(o)}));const total=rows.reduce((s,x)=>s+x.e.total,0),cost=rows.reduce((s,x)=>s+x.e.cost,0),profit=total-cost,margin=total?profit/total*100:0,debt=rows.reduce((s,x)=>s+Math.max(0,x.e.total-x.e.paid),0);content.innerHTML=`<div class="money-summary" style="grid-template-columns:repeat(4,minmax(130px,1fr))"><div><small>Продажи</small><b>${fmt(total)}</b></div><div><small>Себестоимость</small><b>${fmt(cost)}</b></div><div><small>Валовая прибыль</small><b>${fmt(profit)} · ${margin.toFixed(1)}%</b></div><div><small>Долг клиентов</small><b>${fmt(debt)}</b></div></div><div style="overflow:auto;margin-top:14px"><table class="profit-table"><thead><tr><th>Заказ</th><th>Дата</th><th class="num">Продажа</th><th class="num">Расходы</th><th class="num">Прибыль</th><th class="num">Маржа</th><th class="num">Долг</th></tr></thead><tbody>${rows.map(({o,e})=>`<tr><td><button class="mini" type="button" data-profit-order="${esc(o.id)}">№${esc(o.id)} · ${esc(o.event||'Заказ')}</button></td><td>${esc(o.date||'—')}</td><td class="num">${fmt(e.total)}</td><td class="num">${fmt(e.cost)}</td><td class="num"><b>${fmt(e.profit)}</b></td><td class="num"><span class="profit-badge ${e.margin<20?'bad':''}">${e.margin.toFixed(1)}%</span></td><td class="num">${fmt(Math.max(0,e.total-e.paid))}</td></tr>`).join('')||'<tr><td colspan="7">Нет заказов за выбранный период.</td></tr>'}</tbody></table></div>`;content.querySelectorAll('[data-profit-order]').forEach(b=>b.onclick=()=>window.openOrder?.(Number(b.dataset.profitOrder)));}
|
||
|
||
loadEconomy();
|
||
})();
|
||
|
||
/* ===== Print center, box photos, labels and QR codes ===== */
|
||
(() => {
|
||
'use strict';
|
||
if(document.getElementById('sun-print-center'))return;
|
||
const $=id=>document.getElementById(id),qsa=(s,r=document)=>[...r.querySelectorAll(s)],esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
const money=v=>`${Number(v||0).toLocaleString('ru-RU')} ₽`,settingsKey='sunPrintSettingsV1';
|
||
const readSettings=()=>Object.assign({photosPerPage:8,showNames:true,showQty:true,showComposition:false,startNewPage:true,photosOnly:false},(()=>{try{return JSON.parse(localStorage.getItem(settingsKey)||'{}')}catch(_){return{}}})());
|
||
const saveSettings=s=>localStorage.setItem(settingsKey,JSON.stringify(s));
|
||
const getOrders=()=>{try{return orders}catch(_){return[]}},getBoxes=()=>{try{return boxes}catch(_){return[]}};
|
||
const orderTotal=o=>Number(o.total||0)||((o.lines||[]).reduce((s,l)=>{const b=getBoxes().find(x=>String(x.id)===String(l.id));return s+Number(b?.price||0)*Number(l.qty||0)},0));
|
||
const paid=o=>Math.max(0,Number(o.prepayment||0));
|
||
|
||
const style=document.createElement('style');style.textContent=`
|
||
.print-center-head{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;flex-wrap:wrap}.print-center-grid{display:grid;grid-template-columns:minmax(280px,.8fr) minmax(0,1.4fr);gap:12px;margin-top:14px}.print-card{border:1px solid var(--l);border-radius:11px;background:#fff;padding:15px}.print-card h2{margin:0 0 10px;color:var(--n);font-size:18px}.print-settings{display:grid;gap:8px}.print-check{display:flex;flex-direction:row;align-items:center;gap:8px;color:#203040}.print-check input{width:18px;height:18px}.print-order-summary{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:10px 0}.print-order-summary>div{background:#f6f7f7;border-radius:8px;padding:10px}.print-order-summary small{display:block;color:#68717a}.print-order-summary b{display:block;color:#15364c;margin-top:3px}.print-help{padding:10px 12px;background:#fffaf0;border-left:4px solid #c99a32;color:#596a74;margin-top:12px}
|
||
.sun-custom-doc{font-family:Arial,sans-serif;color:#203040}.sun-custom-head{display:flex;justify-content:space-between;gap:15px;border-bottom:2px solid #c99a32;padding-bottom:10px;margin-bottom:14px}.sun-custom-brand{display:flex;align-items:center;gap:9px}.sun-custom-brand img{width:68px;height:50px;object-fit:contain}.sun-custom-brand b{display:block;color:#15364c;font-size:18px}.sun-custom-title{text-align:right}.sun-custom-title h2{margin:0;color:#15364c}.sun-custom-title p{margin:4px 0;color:#68717a}.sun-photo-sheet{display:grid;grid-template-columns:repeat(var(--photo-cols,4),1fr);gap:8px}.sun-photo-sheet-card{border:1px solid #d9dde0;border-radius:7px;padding:6px;break-inside:avoid;background:#fff}.sun-photo-sheet-card img{width:100%;height:160px;object-fit:cover;border-radius:5px}.sun-photo-sheet-card b{display:block;font-size:11px;line-height:1.2;margin-top:5px}.sun-photo-sheet-card small{display:block;color:#68717a;font-size:10px;margin-top:3px}.sun-label-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:7mm 5mm}.sun-label{border:1px solid #333;border-radius:3mm;padding:4mm;min-height:58mm;display:grid;grid-template-columns:1fr 32mm;gap:3mm;break-inside:avoid}.sun-label h3{margin:0 0 2mm;font-size:12pt}.sun-label p{margin:1mm 0;font-size:9pt}.sun-label img.qr{width:30mm;height:30mm;image-rendering:pixelated}.sun-courier-grid{display:grid;grid-template-columns:1fr 1fr;gap:9px}.sun-courier-box{border:1px solid #d9dde0;border-radius:8px;padding:10px}.sun-courier-box h3{margin:0 0 6px;color:#15364c;font-size:15px}.sun-payment-table{width:100%;border-collapse:collapse}.sun-payment-table th,.sun-payment-table td{border:1px solid #cfd5da;padding:7px}.sun-payment-table td:last-child,.sun-payment-table th:last-child{text-align:right}.sun-custom-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:16px;position:sticky;bottom:0;background:#fff;padding:10px 0}
|
||
@media(max-width:780px){.print-center-grid{grid-template-columns:1fr}.print-order-summary{grid-template-columns:1fr 1fr}.sun-photo-sheet{grid-template-columns:repeat(2,1fr)!important}.sun-courier-grid{grid-template-columns:1fr}.sun-label-grid{grid-template-columns:1fr}}
|
||
@media print{body *{visibility:hidden!important}#preview,#preview *{visibility:visible!important}#preview{display:block!important;position:absolute!important;inset:0!important;background:#fff!important;padding:0!important}#preview .dialog{width:100%!important;max-width:none!important;max-height:none!important;overflow:visible!important;padding:8mm!important;box-shadow:none!important}.sun-custom-actions{display:none!important}.sun-photo-sheet-card img{height:42mm}.sun-label-grid{grid-template-columns:repeat(3,1fr)!important}.sun-label{min-height:58mm!important}.sun-custom-doc{font-size:10pt}@page{size:A4 portrait;margin:9mm}}
|
||
`;document.head.appendChild(style);
|
||
|
||
const view=document.createElement('section');view.id='sun-print-center';view.className='view page';document.body.appendChild(view);
|
||
const nav=document.querySelector('header nav'),group=nav?.querySelector('.sun-nav-work')||nav,button=document.createElement('button');button.type='button';button.textContent='Печать';button.dataset.navLabel='Печать';button.className='sun-nav-button nav-print';group?.appendChild(button);button.onclick=()=>{SunEnterprise.openView(view,button);render();};
|
||
let returnToPrintCenterAfterPreview=false;
|
||
if(!window.__sunPrintClosePatched){
|
||
const originalCloseModal=window.closeModal;
|
||
window.closeModal=function(id){
|
||
originalCloseModal(id);
|
||
if(id==='preview' && window.__sunPrintPreviewReturn){
|
||
window.__sunPrintPreviewReturn=false;
|
||
returnToPrintCenterAfterPreview=false;
|
||
SunEnterprise.openView(view,button);
|
||
}
|
||
};
|
||
window.__sunPrintClosePatched=true;
|
||
}
|
||
|
||
function orderOptions(){return getOrders().slice().sort((a,b)=>`${b.date||''}T${b.time||''}`.localeCompare(`${a.date||''}T${a.time||''}`)).map(o=>`<option value="${esc(o.id)}">№${esc(o.id)} · ${esc(o.date||'—')} · ${esc(o.event||'Заказ')}</option>`).join('');}
|
||
function selectedOrder(){return getOrders().find(o=>String(o.id)===String($('printOrder').value));}
|
||
function currentSettings(){return{photosPerPage:Number($('printPhotosPerPage').value||8),showNames:$('printShowNames').checked,showQty:$('printShowQty').checked,showComposition:$('printShowComposition').checked,startNewPage:$('printStartNewPage').checked,photosOnly:$('printPhotosOnly').checked};}
|
||
function renderSummary(){const o=selectedOrder(),host=$('printOrderSummary');if(!o){host.innerHTML='<p class="hint">Выберите заказ.</p>';return;}host.innerHTML=`<div><small>Сумма</small><b>${money(orderTotal(o))}</b></div><div><small>Оплачено</small><b>${money(paid(o))}</b></div><div><small>Остаток</small><b>${money(Math.max(0,orderTotal(o)-paid(o)))}</b></div>`;}
|
||
function render(){const s=readSettings();view.innerHTML=`<div class="print-center-head"><div><h1>Центр печати</h1><p class="hint" style="margin:5px 0 0">Бланки, чеки, заготовки, фотографии, лист курьеру и этикетки.</p></div><button class="outline" id="printRefresh" type="button">Обновить список</button></div><div class="print-center-grid"><section class="print-card"><h2>Документ</h2><label>Заказ<select id="printOrder"><option value="">Выберите заказ</option>${orderOptions()}</select></label><label style="margin-top:9px">Что подготовить<select id="printType"><option value="order">Бланк заказа</option><option value="receipt">Товарный чек</option><option value="prep">Заготовки + фотографии</option><option value="photos">Только фотографии боксов</option><option value="courier">Лист курьеру</option><option value="labels">Этикетки с QR</option></select></label><div class="print-order-summary" id="printOrderSummary"></div><div class="actions"><button class="primary" id="printPreviewBtn" type="button">Предварительный просмотр</button></div><div class="print-help">В предварительном просмотре нажмите «Печать / PDF». В системном окне выберите принтер или «Сохранить как PDF».</div></section><section class="print-card"><h2>Настройки фотографий</h2><div class="print-settings"><label>Фотографий на лист<select id="printPhotosPerPage"><option value="4" ${s.photosPerPage===4?'selected':''}>4 — крупно</option><option value="6" ${s.photosPerPage===6?'selected':''}>6 — средне</option><option value="8" ${s.photosPerPage===8?'selected':''}>8 — компактно</option></select></label><label class="print-check"><input id="printShowNames" type="checkbox" ${s.showNames?'checked':''}> Показывать названия</label><label class="print-check"><input id="printShowQty" type="checkbox" ${s.showQty?'checked':''}> Показывать количество</label><label class="print-check"><input id="printShowComposition" type="checkbox" ${s.showComposition?'checked':''}> Показывать краткий состав</label><label class="print-check"><input id="printStartNewPage" type="checkbox" ${s.startNewPage?'checked':''}> Начинать фотографии с нового листа</label><label class="print-check"><input id="printPhotosOnly" type="checkbox" ${s.photosOnly?'checked':''}> В заготовках печатать только фотографии</label></div><p class="hint">Настройки сохраняются автоматически и применяются к следующей печати.</p></section></div>`;
|
||
$('printOrder').onchange=renderSummary;$('printRefresh').onclick=render;$('printPreviewBtn').onclick=preview;
|
||
['printPhotosPerPage','printShowNames','printShowQty','printShowComposition','printStartNewPage','printPhotosOnly'].forEach(id=>$(id).onchange=()=>saveSettings(currentSettings()));
|
||
const latest=getOrders().slice().sort((a,b)=>`${b.date||''}T${b.time||''}`.localeCompare(`${a.date||''}T${a.time||''}`))[0];if(latest){$('printOrder').value=String(latest.id);renderSummary();}
|
||
}
|
||
function showDoc(html){const dialog=document.querySelector('#preview .dialog');if(!dialog)return;dialog.innerHTML=html;modal('preview');setTimeout(()=>qsa('canvas[data-sun-qr]',dialog).forEach(canvas=>{const text=decodeURIComponent(canvas.dataset.sunQr||'');if(window.SunQR)window.SunQR.toCanvas(canvas,text,170,4);}),30);}
|
||
function header(o,title,subtitle=''){return `<div class="sun-custom-head"><div class="sun-custom-brand"><img src="${window.CateriumBranding.logoHTML()}"><div><b>${window.CateriumBranding.nameHTML()}</b><small>${window.CateriumBranding.cityHTML()}</small></div></div><div class="sun-custom-title"><h2>${esc(title)}</h2><p>Заказ №${esc(o.id)} · ${esc(o.date||'—')} ${esc(o.time||'')}</p>${subtitle?`<p>${esc(subtitle)}</p>`:''}</div></div>`;}
|
||
const actions=()=>`<div class="sun-custom-actions"><button class="primary" type="button" onclick="window.print()">Печать / PDF</button><button class="outline" type="button" onclick="closeModal('preview')">Закрыть</button></div>`;
|
||
function photoCards(o,s){const selected=(o.lines||[]).map(l=>({item:getBoxes().find(x=>String(x.id)===String(l.id)),qty:Number(l.qty||0)})).filter(x=>x.item&&[0,5].includes(Number(x.item.category||0))&&x.item.photo);const cols=s.photosPerPage===4?2:s.photosPerPage===6?3:4;return `<div class="sun-photo-sheet" style="--photo-cols:${cols}">${selected.map(({item,qty})=>`<article class="sun-photo-sheet-card"><img src="${esc(item.photo)}" alt="${esc(item.name)}">${s.showNames?`<b>${esc(item.name)}</b>`:''}${s.showQty?`<small>Количество боксов: × ${esc(qty)}</small>`:''}${s.showComposition?`<small>${esc((item.composition||[]).slice(0,3).join(' · '))}</small>`:''}</article>`).join('')||'<p class="hint">В заказе нет боксов с фотографиями.</p>'}</div>`;}
|
||
function photosDoc(o){const s=currentSettings();showDoc(`<div class="sun-custom-doc">${header(o,'Фотографии боксов',o.event||'')}${photoCards(o,s)}${actions()}</div>`);}
|
||
function courierDoc(o){const route=o.routePreview,routeUrl=window.SunDeliveryRoute?.yandexRouteUrl?window.SunDeliveryRoute.yandexRouteUrl(route,o.address):window.SunStaticMap?window.SunStaticMap.routeUrl(o.geo,o.address):`https://yandex.ru/maps/?text=${encodeURIComponent(o.address||'')}`;const mapHtml=route&&window.SunDeliveryRoute?.routeMedia?`<div class="sun-doc-route"><div class="sun-doc-route-head"><b>Маршрут доставки</b><span>Телези, 20 → ${esc(o.address||'')}</span></div><div class="sun-route-preview">${window.SunDeliveryRoute.routeMedia(route,760,250)}</div><div class="sun-doc-route-foot">Маршрут по дорогам · © OpenStreetMap contributors</div></div>`:'';showDoc(`<div class="sun-custom-doc">${header(o,'Лист курьеру',o.event||'')}<div class="sun-courier-grid"><div class="sun-courier-box"><h3>Доставка</h3><p><b>Адрес:</b> ${esc(o.address||'—')}</p><p><b>Время:</b> ${esc(o.time||'—')}</p><p><b>Клиент:</b> ${esc(o.contact||'—')}</p><p><b>Телефон:</b> ${esc(o.phone||'—')}</p><p><b>Комментарий:</b> ${esc(o.courierNote||o.note||'—')}</p></div><div class="sun-courier-box"><h3>Оплата</h3><p><b>Сумма:</b> ${money(orderTotal(o))}</p><p><b>Оплачено:</b> ${money(paid(o))}</p><p><b>Получить:</b> ${money(Math.max(0,orderTotal(o)-paid(o)))}</p><p><a href="${esc(routeUrl)}">Открыть маршрут в Яндекс Картах</a></p><canvas data-sun-qr="${encodeURIComponent(routeUrl)}" style="width:38mm;height:38mm"></canvas></div></div>${mapHtml}<h3 style="margin-top:16px">Состав заказа</h3><table class="sun-payment-table"><tr><th>Позиция</th><th>Количество</th></tr>${(o.lines||[]).map(l=>{const b=getBoxes().find(x=>String(x.id)===String(l.id));return`<tr><td>${esc(b?.name||l.id)}</td><td>${esc(l.qty)}</td></tr>`}).join('')}</table>${actions()}</div>`);}
|
||
function labelsDoc(o){const labels=(o.lines||[]).map(l=>({item:getBoxes().find(x=>String(x.id)===String(l.id)),qty:Number(l.qty||0)})).filter(x=>x.item).flatMap(({item,qty})=>Array.from({length:Math.max(1,qty)},(_,i)=>({item,index:i+1,total:qty})));showDoc(`<div class="sun-custom-doc">${header(o,'Этикетки на коробки',o.event||'')}<div class="sun-label-grid">${labels.map(({item,index,total})=>{const code=`SUN-ORDER-${o.id};BOX-${item.id};UNIT-${index}-OF-${total}`;return`<article class="sun-label"><div><h3>${esc(item.name)}</h3><p><b>Заказ №${esc(o.id)}</b></p><p>${esc(o.contact||'Клиент')} · ${esc(o.date||'—')} ${esc(o.time||'')}</p><p>Коробка ${index} из ${total}</p><p>${esc(item.weight||'')}</p></div><canvas data-sun-qr="${encodeURIComponent(code)}"></canvas></article>`}).join('')||'<p class="hint">В заказе нет позиций.</p>'}</div>${actions()}</div>`);}
|
||
function paymentsDoc(o){const list=Array.isArray(o.payments)?o.payments:[];showDoc(`<div class="sun-custom-doc">${header(o,'История платежей',o.contact||'')}<table class="sun-payment-table"><thead><tr><th>Дата</th><th>Способ</th><th>Комментарий</th><th>Сумма</th></tr></thead><tbody>${list.map(p=>`<tr><td>${esc(p.date||'—')}</td><td>${esc(p.method||'—')}</td><td>${esc(p.note||'')}</td><td>${p.status==='refund'?'−':''}${money(p.amount)}</td></tr>`).join('')||'<tr><td colspan="4">Платежей нет</td></tr>'}<tr><td colspan="3"><b>Итого получено</b></td><td><b>${money(paid(o))}</b></td></tr><tr><td colspan="3"><b>Остаток</b></td><td><b>${money(Math.max(0,orderTotal(o)-paid(o)))}</b></td></tr></tbody></table>${actions()}</div>`);}
|
||
async function preview(){const o=selectedOrder();if(!o)return SunEnterprise.toast('Выберите заказ.','warn');saveSettings(currentSettings());const type=$('printType').value;returnToPrintCenterAfterPreview=true;window.__sunPrintPreviewReturn=true;if(type==='photos')return photosDoc(o);if(type==='courier')return courierDoc(o);if(type==='labels')return labelsDoc(o);if(type==='payments')return paymentsDoc(o);window.openOrder?.(Number(o.id));await new Promise(r=>setTimeout(r,60));if(type==='prep'){window.openPreview?.();return;}const text=type==='order'?'Бланк заказа':'Товарный чек';const btn=qsa('#new .actions button').find(b=>b.textContent.trim()===text);if(btn)btn.click();else{window.__sunPrintPreviewReturn=false;returnToPrintCenterAfterPreview=false;SunEnterprise.openView(view,button);SunEnterprise.toast(`Кнопка «${text}» не найдена.`,'error');}}
|
||
window.sunOpenPrintCenterForOrder=id=>{button.click();setTimeout(()=>{$('printOrder').value=String(id);renderSummary();},30);};
|
||
})();
|
||
|
||
/* ===== Courier route planner ===== */
|
||
(() => {
|
||
'use strict';
|
||
if(document.getElementById('sun-routes-view'))return;
|
||
const $=id=>document.getElementById(id),qsa=(s,r=document)=>[...r.querySelectorAll(s)],esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
const ROUTES_KEY='sunRoutePlansV1',BASE_KEY='sunRouteBaseV1';
|
||
const getOrders=()=>{try{return orders}catch(_){return[]}},getEmployees=()=>{try{return JSON.parse(localStorage.sunEmployees||'[]')}catch(_){return[]}};
|
||
const active=o=>!['Отменён','Отдан заказчику'].includes(o.status||'Новый');
|
||
const validGeo=o=>o?.geo&&Number.isFinite(Number(o.geo.lat))&&Number.isFinite(Number(o.geo.lon));
|
||
const plans=()=>{try{return JSON.parse(localStorage.getItem(ROUTES_KEY)||'{}')}catch(_){return{}}};
|
||
const savePlans=p=>localStorage.setItem(ROUTES_KEY,JSON.stringify(p));
|
||
const base=()=>({address:'Ленинградская область, Ломоносовский район, деревня Телези, 20',lat:59.701058,lon:29.977269});
|
||
const hav=(a,b)=>{const R=6371,rad=x=>x*Math.PI/180,dLat=rad(Number(b.lat)-Number(a.lat)),dLon=rad(Number(b.lon)-Number(a.lon)),la1=rad(Number(a.lat)),la2=rad(Number(b.lat));const h=Math.sin(dLat/2)**2+Math.cos(la1)*Math.cos(la2)*Math.sin(dLon/2)**2;return 2*R*Math.asin(Math.sqrt(h));};
|
||
|
||
const style=document.createElement('style');style.textContent=`
|
||
.route-head{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;flex-wrap:wrap}.route-toolbar{display:grid;grid-template-columns:160px 220px 1fr auto;gap:9px;align-items:end;margin:14px 0}.route-summary{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-bottom:12px}.route-summary>div{background:#f5f7f7;border-radius:8px;padding:10px}.route-summary small{display:block;color:#68717a}.route-summary b{display:block;color:#15364c;font-size:18px;margin-top:3px}.route-layout{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(280px,.75fr);gap:12px}.route-list,.route-side{border:1px solid var(--l);border-radius:10px;background:#fff;padding:13px}.route-row{display:grid;grid-template-columns:40px 80px 1fr 100px 120px;gap:8px;align-items:center;border-bottom:1px solid #e7eaeb;padding:10px 0}.route-row:last-child{border-bottom:0}.route-order-buttons{display:flex;flex-direction:column;gap:3px}.route-order-buttons button{height:25px;border:1px solid #cfd5da;background:#fff;border-radius:4px}.route-row h3{margin:0;color:#15364c;font-size:15px}.route-row p{margin:3px 0;color:#68717a;font-size:12px}.route-row .no-geo{color:#9c3d3d;font-weight:700}.route-side h2{font-size:17px;color:#15364c;margin:0 0 10px}.route-side .actions{flex-direction:column}.route-side .actions>*{width:100%;text-align:center}.route-base-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px}.route-base-grid .wide{grid-column:1/-1}.route-empty{padding:28px;text-align:center;color:#879198}.route-print-table{width:100%;border-collapse:collapse}.route-print-table th,.route-print-table td{border:1px solid #cfd5da;padding:6px}.route-print-table th{background:#f4f6f7}.route-print-table .num{text-align:right}
|
||
@media(max-width:950px){.route-toolbar{grid-template-columns:1fr 1fr}.route-layout{grid-template-columns:1fr}.route-summary{grid-template-columns:1fr 1fr}.route-row{grid-template-columns:34px 70px 1fr}.route-row>:nth-child(4),.route-row>:nth-child(5){grid-column:3}}
|
||
@media(max-width:560px){.route-toolbar{grid-template-columns:1fr}.route-summary{grid-template-columns:1fr 1fr}.route-row{grid-template-columns:34px 1fr}.route-row>:not(:first-child){grid-column:2}}
|
||
`;document.head.appendChild(style);
|
||
|
||
const view=document.createElement('section');view.id='sun-routes-view';view.className='view page';document.body.appendChild(view);
|
||
const nav=document.querySelector('header nav'),group=nav?.querySelector('.sun-nav-operations')||nav,button=document.createElement('button');button.type='button';button.textContent='Маршруты';button.dataset.navLabel='Маршруты';button.className='sun-nav-button nav-routes';group?.appendChild(button);button.onclick=()=>{SunEnterprise.openView(view,button);render();};
|
||
|
||
function employees(){return getEmployees().filter(e=>e.active!==false&&String(e.role||'').toLowerCase().includes('курьер'));}
|
||
function selectedKey(){return `${$('routeDate').value}|${$('routeCourier').value||'all'}`;}
|
||
function ordersFor(){const date=$('routeDate').value,courier=$('routeCourier').value;let list=getOrders().filter(o=>active(o)&&o.date===date&&(o.address||validGeo(o)));if(courier)list=list.filter(o=>(o.staffIds||[]).map(String).includes(String(courier)));const p=plans(),ids=p[selectedKey()]||[];const map=new Map(list.map(o=>[String(o.id),o]));const ordered=ids.map(id=>map.get(String(id))).filter(Boolean);list.filter(o=>!ids.map(String).includes(String(o.id))).sort((a,b)=>String(a.time||'').localeCompare(String(b.time||''))).forEach(o=>ordered.push(o));return ordered;}
|
||
function persistOrder(list){const p=plans();p[selectedKey()]=list.map(o=>String(o.id));savePlans(p);}
|
||
function routePoints(list){const b=base(),pts=[];if(Number.isFinite(Number(b.lat))&&Number.isFinite(Number(b.lon))&&b.lat!==''&&b.lon!=='')pts.push({lat:Number(b.lat),lon:Number(b.lon),label:'База'});list.filter(validGeo).forEach(o=>pts.push({lat:Number(o.geo.lat),lon:Number(o.geo.lon),label:`№${o.id}`}));return pts;}
|
||
function routeDistance(list){const pts=routePoints(list);let total=0;for(let i=1;i<pts.length;i++)total+=hav(pts[i-1],pts[i]);return total;}
|
||
function yandexUrl(list){const pts=routePoints(list);if(!pts.length)return'https://yandex.ru/maps/';return`https://yandex.ru/maps/?rtext=${encodeURIComponent(pts.map(p=>`${p.lat},${p.lon}`).join('~'))}&rtt=auto`;}
|
||
function courierName(){return employees().find(e=>String(e.id)===String($('routeCourier').value))?.name||'Все курьеры';}
|
||
function render(){const b=base(),today=new Date().toISOString().slice(0,10);view.innerHTML=`<div class="route-head"><div><h1>Маршруты курьеров</h1><p class="hint" style="margin:5px 0 0">Порядок доставок, километраж, маршрутный лист и ссылка для курьера.</p></div></div><div class="route-toolbar"><label>Дата<input id="routeDate" type="date" value="${today}"></label><label>Курьер<select id="routeCourier"><option value="">Все курьеры</option>${employees().map(e=>`<option value="${esc(e.id)}">${esc(e.name)}</option>`).join('')}</select></label><label>Поиск<input id="courierRouteSearch" type="search" placeholder="Клиент, заказ или адрес"></label><button class="outline" id="routeRefresh" type="button">Обновить</button></div><div id="routeContent"></div>`;$('routeDate').onchange=renderRoute;$('routeCourier').onchange=renderRoute;$('courierRouteSearch').oninput=renderRoute;$('routeRefresh').onclick=renderRoute;renderRoute();}
|
||
function renderRoute(){const all=ordersFor(),query=($('courierRouteSearch')?.value||'').trim().toLowerCase(),list=query?all.filter(o=>[o.id,o.event,o.contact,o.phone,o.address].join(' ').toLowerCase().includes(query)):all,geoCount=all.filter(validGeo).length,distance=routeDistance(all),total=all.reduce((s,o)=>s+Number(o.total||0),0),b=base();$('routeContent').innerHTML=`<div class="route-summary"><div><small>Доставок</small><b>${all.length}</b></div><div><small>Точек на карте</small><b>${geoCount}</b></div><div><small>Без координат</small><b>${all.length-geoCount}</b></div><div><small>Примерно км</small><b>${distance.toLocaleString('ru-RU',{maximumFractionDigits:1})}</b></div><div><small>Сумма заказов</small><b>${total.toLocaleString('ru-RU')} ₽</b></div></div><div class="route-layout"><section class="route-list"><h2 style="margin:0 0 8px;color:#15364c;font-size:17px">Порядок доставок · ${esc(courierName())}</h2>${list.length?list.map((o,index)=>`<div class="route-row" data-route-id="${esc(o.id)}"><div class="route-order-buttons"><button type="button" data-route-up="${esc(o.id)}" ${index===0?'disabled':''}>↑</button><button type="button" data-route-down="${esc(o.id)}" ${index===list.length-1?'disabled':''}>↓</button></div><div><b>${esc(o.time||'—')}</b><p>№${esc(o.id)}</p></div><div><h3>${esc(o.event||'Заказ')}</h3><p>${esc(o.contact||'Клиент не указан')} · ${esc(o.phone||'')}</p><p>${esc(o.address||'Адрес не указан')}</p>${validGeo(o)?'':`<p class="no-geo">Точка не подтверждена</p>`}</div><div><b>${Number(o.total||0).toLocaleString('ru-RU')} ₽</b><p>${esc(o.status||'Новый')}</p></div><div><button class="mini" type="button" data-route-open="${esc(o.id)}">Открыть заказ</button></div></div>`).join(''):'<div class="route-empty">На выбранную дату доставок нет.</div>'}</section><aside class="route-side"><h2>Начало маршрута</h2><div style="padding:11px 12px;border:1px solid #e3e5eb;border-radius:10px;background:#f8f9fb;margin-bottom:10px"><b style="color:#2d294d">Телези, 20</b><div class="hint" style="margin-top:4px">Постоянная точка старта для всех маршрутов</div></div><div class="actions"><button class="primary" id="routeOpenYandex" type="button">Открыть маршрут в Яндекс Картах</button><button class="outline" id="routeCopy" type="button">Скопировать маршрут курьеру</button><button class="outline" id="routePrint" type="button">Печать маршрутного листа</button></div><p class="hint">На печати маршрут строится по дорогам от Телези, 20 через точки в указанном порядке.</p></aside></div>`;
|
||
qsa('[data-route-up]').forEach(btn=>btn.onclick=()=>move(btn.dataset.routeUp,-1));qsa('[data-route-down]').forEach(btn=>btn.onclick=()=>move(btn.dataset.routeDown,1));qsa('[data-route-open]').forEach(btn=>btn.onclick=()=>window.openOrder?.(Number(btn.dataset.routeOpen)));
|
||
const refreshAllRouteGeos=async(force=false)=>{for(const o of all){if(!o?.address)continue;try{await window.SunStaticMap?.ensureOrderGeo?.(o,{silent:true,force});}catch(_){}}};
|
||
$('routeOpenYandex').onclick=async()=>{const btn=$('routeOpenYandex'),old=btn.textContent;btn.disabled=true;btn.textContent='Проверяем адреса…';try{await refreshAllRouteGeos(true);window.open(yandexUrl(all),'_blank','noopener');renderRoute();}finally{btn.disabled=false;btn.textContent=old;}};
|
||
$('routeCopy').onclick=async()=>{const btn=$('routeCopy'),old=btn.textContent;btn.disabled=true;btn.textContent='Проверяем адреса…';try{await refreshAllRouteGeos(true);await copyRoute(all);renderRoute();}finally{btn.disabled=false;btn.textContent=old;}};
|
||
$('routePrint').onclick=async()=>{const btn=$('routePrint');const old=btn.textContent;btn.disabled=true;btn.textContent='Строим маршрут…';try{await printRoute(all);renderRoute();}finally{btn.disabled=false;btn.textContent=old;}};
|
||
}
|
||
function move(id,delta){const list=ordersFor(),index=list.findIndex(o=>String(o.id)===String(id)),next=index+delta;if(index<0||next<0||next>=list.length)return;[list[index],list[next]]=[list[next],list[index]];persistOrder(list);renderRoute();}
|
||
async function copyRoute(list){const text=[`Маршрут ${$('routeDate').value} · ${courierName()}`,base().address?`Старт: ${base().address}`:'',...list.map((o,i)=>`${i+1}. ${o.time||'—'} · Заказ №${o.id} · ${o.contact||''}\n${o.address||''}\n${o.phone||''}${o.courierNote?`\nКомментарий: ${o.courierNote}`:''}`),`Яндекс Карты: ${yandexUrl(list)}`].filter(Boolean).join('\n\n');try{await navigator.clipboard.writeText(text);SunEnterprise.toast('Маршрут скопирован.','success');}catch(_){const ta=document.createElement('textarea');ta.value=text;document.body.appendChild(ta);ta.select();document.execCommand('copy');ta.remove();SunEnterprise.toast('Маршрут скопирован.','success');}}
|
||
async function buildPrintedRoute(list){
|
||
const b=base(),stops=[];
|
||
for(const o of list){
|
||
let geo=null;
|
||
if(window.SunStaticMap?.ensureOrderGeo){try{geo=await window.SunStaticMap.ensureOrderGeo(o,{silent:true,force:true});}catch(_){}}
|
||
if(!geo&&validGeo(o)) geo=o.geo;
|
||
if(geo&&Number.isFinite(Number(geo.lat))&&Number.isFinite(Number(geo.lon)))stops.push({lat:Number(geo.lat),lon:Number(geo.lon),label:`№${o.id}`,orderId:o.id,address:o.address||''});
|
||
}
|
||
if(!stops.length)return null;
|
||
const coords=[{lat:Number(b.lat),lon:Number(b.lon)},...stops];
|
||
const url=`https://router.project-osrm.org/route/v1/driving/${coords.map(x=>`${x.lon},${x.lat}`).join(';')}?overview=full&geometries=geojson&steps=false`;
|
||
try{
|
||
const response=await sunFetchWithTimeout(url,{mode:'cors',credentials:'omit'},8000);if(!response.ok)throw new Error('route');
|
||
const data=await response.json(),r=data?.routes?.[0],geometry=r?.geometry?.coordinates;if(!r||!Array.isArray(geometry)||geometry.length<2)throw new Error('route');
|
||
const simplified=geometry.length>320?geometry.filter((_,i)=>i%Math.ceil(geometry.length/320)===0||i===geometry.length-1):geometry;
|
||
return {origin:{lat:Number(b.lat),lon:Number(b.lon),address:b.address},destination:{lat:stops[stops.length-1].lat,lon:stops[stops.length-1].lon,address:stops[stops.length-1].address},stops,coords:simplified.map(c=>[Number(c[0]),Number(c[1])]),distance:Number(r.distance||0),duration:Number(r.duration||0),sourceAddress:'route-sheet',routeVersion:3};
|
||
}catch(_){return null;}
|
||
}
|
||
function routeSheetDistance(v){return `${(Number(v||0)/1000).toLocaleString('ru-RU',{maximumFractionDigits:1})} км`;}
|
||
function routeSheetTime(v){const n=Math.max(1,Math.round(Number(v||0)/60));return n>=60?`${Math.floor(n/60)} ч ${n%60?`${n%60} мин`:''}`:`${n} мин`;}
|
||
async function printRoute(list){
|
||
const dialog=document.querySelector('#preview .dialog'),b=base(),route=await buildPrintedRoute(list);
|
||
const map=route&&window.SunDeliveryRoute?.routeMedia?`<div class="sun-route-sheet-map" style="margin:14px 0 16px;border:1px solid #d9dde5;border-radius:12px;overflow:hidden;break-inside:avoid"><div style="padding:9px 11px;background:#f7f6fb;display:flex;justify-content:space-between;gap:10px"><b>Маршрут на карте</b><span>${routeSheetDistance(route.distance)} · примерно ${routeSheetTime(route.duration)}</span></div><div class="sun-route-preview" style="height:330px;border:0;border-radius:0">${window.SunDeliveryRoute.routeMedia(route,980,330)}</div><div style="padding:7px 11px;font-size:10px;color:#747b86">A — Телези, 20 · далее точки 1, 2, 3… в порядке маршрутного листа</div></div>`:`<div style="margin:14px 0;padding:12px;border:1px dashed #cfd4dc;border-radius:10px;color:#737b88">Не удалось загрузить схему маршрута. Таблица и ссылка на Яндекс Карты доступны ниже.</div>`;
|
||
dialog.innerHTML=`<div class="sun-custom-doc"><div class="sun-custom-head"><div class="sun-custom-brand"><img src="${window.CateriumBranding.logoHTML()}"><div><b>${window.CateriumBranding.nameHTML()}</b><small>Маршрутный лист</small></div></div><div class="sun-custom-title"><h2>${esc($('routeDate').value)}</h2><p>${esc(courierName())}</p></div></div><p><b>Старт:</b> ${esc(b.address)}</p>${map}<table class="route-print-table"><thead><tr><th>№</th><th>Время</th><th>Заказ / клиент</th><th>Адрес</th><th>Телефон</th><th>Комментарий</th><th class="num">Получить</th></tr></thead><tbody>${list.map((o,i)=>`<tr><td>${i+1}</td><td>${esc(o.time||'—')}</td><td>№${esc(o.id)} · ${esc(o.contact||o.event||'')}</td><td>${esc(o.address||'')}</td><td>${esc(o.phone||'')}</td><td>${esc(o.courierNote||o.note||'')}</td><td class="num">${Math.max(0,Number(o.total||0)-Number(o.prepayment||0)).toLocaleString('ru-RU')} ₽</td></tr>`).join('')}</tbody></table><p style="margin-top:12px"><b>Маршрут:</b> <a href="${esc(yandexUrl(list))}">открыть в Яндекс Картах</a></p><div class="sun-custom-actions"><button class="primary" onclick="window.print()">Печать / PDF</button><button class="outline" onclick="closeModal('preview')">Закрыть</button></div></div>`;modal('preview');
|
||
}
|
||
window.sunOpenRoutes=()=>button.click();
|
||
})();
|
||
|
||
/* ===== EXCEL (SPREADSHEETML) IMPORT / EXPORT ===== */
|
||
(() => {
|
||
'use strict';
|
||
const esc=window.SunSafe.escapeHTML;
|
||
const parse=(raw,d=[])=>{try{return JSON.parse(raw)||d}catch(_){return d}};
|
||
const paid=o=>Math.max(0,Number(o.prepayment||0));
|
||
const orderTotal=o=>{if(Number.isFinite(Number(o.total)))return Number(o.total||0);let boxes=[];try{boxes=window.boxes||[]}catch(_){try{boxes=globalThis.boxes}catch(__){boxes=[]}}if(!boxes.length)boxes=parse(localStorage.getItem('sunBoxes')||'[]');return(o.lines||[]).reduce((s,l)=>s+Number(boxes.find(x=>String(x.id)===String(l.id))?.price||0)*Number(l.qty||0),0)};
|
||
function cell(value,type){if(value===null||value===undefined)value='';if(type==='Number')return `<Cell><Data ss:Type="Number">${Number(value||0)}</Data></Cell>`;if(type==='Boolean')return `<Cell><Data ss:Type="Boolean">${value?1:0}</Data></Cell>`;return `<Cell><Data ss:Type="String">${escXml(value)}</Data></Cell>`;}
|
||
function sheet(name,headers,rows,types={}){return `<Worksheet ss:Name="${escXml(name)}"><Table><Row>${headers.map(h=>cell(h)).join('')}</Row>${rows.map(row=>`<Row>${headers.map(h=>cell(row[h],types[h])).join('')}</Row>`).join('')}</Table><WorksheetOptions xmlns="urn:schemas-microsoft-com:office:excel"><FreezePanes/><FrozenNoSplit/><SplitHorizontal>1</SplitHorizontal><TopRowBottomPane>1</TopRowBottomPane><ActivePane>2</ActivePane></WorksheetOptions></Worksheet>`;}
|
||
function workbookXml(){let orders=[];try{orders=globalThis.orders||[]}catch(_){orders=parse(localStorage.getItem('sunOrders')||'[]')}if(!orders.length)orders=parse(localStorage.getItem('sunOrders')||'[]');let boxes=[];try{boxes=globalThis.boxes||[]}catch(_){boxes=parse(localStorage.getItem('sunBoxes')||'[]')}if(!boxes.length)boxes=parse(localStorage.getItem('sunBoxes')||'[]');const stock=parse(localStorage.getItem('sunStock')||'[]'),finance=parse(localStorage.getItem('sunFinanceRecordsV2')||'[]'),team=parse(localStorage.getItem('sunEmployees')||'[]'),suppliers=parse(localStorage.getItem('sunSuppliers')||'[]');const clients=new Map();orders.forEach(o=>{const k=String(o.phone||o.contact||'').trim();if(!k)return;if(!clients.has(k))clients.set(k,{Имя:o.contact||'',Телефон:o.phone||'',Адреса:new Set(),Заказов:0,Сумма:0});const c=clients.get(k);if(o.address)c.Адреса.add(o.address);if((o.status||'Новый')!=='Отменён'){c.Заказов++;c.Сумма+=orderTotal(o);}});
|
||
const sheets=[];
|
||
let headers=['ID','Мероприятие','Дата','Время','Статус','Клиент','Телефон','Адрес','Сумма','Оплачено','Остаток','Гостей','Ответственный','Позиции JSON','Платежи JSON','Расходы JSON','Примечание','Комментарий кухне','Комментарий курьеру','Широта','Долгота'];
|
||
sheets.push(sheet('Заказы',headers,orders.map(o=>({'ID':o.id,'Мероприятие':o.event||'','Дата':o.date||'','Время':o.time||'','Статус':o.status||'Новый','Клиент':o.contact||'','Телефон':o.phone||'','Адрес':o.address||'','Сумма':orderTotal(o),'Оплачено':paid(o),'Остаток':Math.max(0,orderTotal(o)-paid(o)),'Гостей':o.guestsCount||'','Ответственный':o.responsible||'','Позиции JSON':JSON.stringify(o.lines||[]),'Платежи JSON':JSON.stringify(o.payments||[]),'Расходы JSON':JSON.stringify(o.costs||{}),'Примечание':o.note||'','Комментарий кухне':o.kitchenNote||'','Комментарий курьеру':o.courierNote||'','Широта':o.geo?.lat||'','Долгота':o.geo?.lon||''})),{'ID':'Number','Сумма':'Number','Оплачено':'Number','Остаток':'Number','Широта':'Number','Долгота':'Number'}));
|
||
headers=['ID','Название','Категория','Цена','Цена до скидки','Вес','Раздел','Состав JSON','Описание состава','Фото'];sheets.push(sheet('Каталог',headers,boxes.map(b=>({'ID':b.id,'Название':b.name||'','Категория':Number(b.category||0),'Цена':Number(b.price||0),'Цена до скидки':Number(b.oldPrice||0),'Вес':b.weight||'','Раздел':b.catalogSection||'','Состав JSON':JSON.stringify(b.ingredients||[]),'Описание состава':(b.composition||[]).join(' | '),'Фото':b.photo||''})),{'Категория':'Number','Цена':'Number','Цена до скидки':'Number'}));
|
||
headers=['ID','Продукт','Единица','Остаток','Минимум','Закупочная цена','Поставщик ID','Последняя закупка','Последняя цена'];sheets.push(sheet('Склад',headers,stock.map(p=>({'ID':p.id,'Продукт':p.name||'','Единица':p.unit||'','Остаток':Number(p.qty||0),'Минимум':Number(p.min||0),'Закупочная цена':Number(p.cost||0),'Поставщик ID':p.supplierId||'','Последняя закупка':p.lastPurchaseDate||'','Последняя цена':Number(p.lastPurchasePrice||0)})),{'Остаток':'Number','Минимум':'Number','Закупочная цена':'Number','Последняя цена':'Number'}));
|
||
headers=['ID','Тип','Дата','Сумма','Категория','Описание','Комментарий'];sheets.push(sheet('Финансы',headers,finance.map(r=>({'ID':r.id,'Тип':r.kind||'','Дата':r.date||'','Сумма':Number(r.amount||0),'Категория':r.category||'','Описание':r.description||'','Комментарий':r.note||''})),{'Сумма':'Number'}));
|
||
headers=['ID','Имя','Роль','Телефон','Активен','Комментарий'];sheets.push(sheet('Команда',headers,team.map(e=>({'ID':e.id,'Имя':e.name||'','Роль':e.role||'','Телефон':e.phone||'','Активен':e.active!==false,'Комментарий':e.note||''})),{'Активен':'Boolean'}));
|
||
headers=['ID','Название','Категория','Контакт','Телефон','Email','Адрес','Активен','Доставка','Минимальный заказ','Комментарий'];sheets.push(sheet('Поставщики',headers,suppliers.map(s=>({'ID':s.id,'Название':s.name||'','Категория':s.category||'','Контакт':s.contact||'','Телефон':s.phone||'','Email':s.email||'','Адрес':s.address||'','Активен':s.active!==false,'Доставка':s.delivery||s.deliveryTerms||'','Минимальный заказ':Number(s.minOrder||0),'Комментарий':s.note||''})),{'Активен':'Boolean','Минимальный заказ':'Number'}));
|
||
headers=['Имя','Телефон','Адреса','Заказов','Сумма'];sheets.push(sheet('Клиенты',headers,[...clients.values()].map(c=>({'Имя':c.Имя,'Телефон':c.Телефон,'Адреса':[...c.Адреса].join(' | '),'Заказов':c.Заказов,'Сумма':c.Сумма})),{'Заказов':'Number','Сумма':'Number'}));
|
||
return `<?xml version="1.0" encoding="UTF-8"?><?mso-application progid="Excel.Sheet"?><Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet"><DocumentProperties xmlns="urn:schemas-microsoft-com:office:office"><Author>${window.CateriumBranding.nameHTML()}</Author><Created>${new Date().toISOString()}</Created></DocumentProperties><Styles><Style ss:ID="Default" ss:Name="Normal"><Alignment ss:Vertical="Bottom"/><Borders/><Font ss:FontName="Arial"/><Interior/><NumberFormat/><Protection/></Style></Styles>${sheets.join('')}</Workbook>`;
|
||
}
|
||
function download(name,text,type){const blob=new Blob([text],{type}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=name;document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),1000);}
|
||
function exportExcel(){download(`sun-catering-${new Date().toISOString().slice(0,10)}.xls`,workbookXml(),'application/vnd.ms-excel;charset=utf-8');window.SunEnterprise?.recordAudit('Экспорт базы','Excel (.xls)');}
|
||
function rowsFromWorksheet(ws){const rows=[...ws.getElementsByTagNameNS('*','Row')];if(!rows.length)return[];const values=rows.map(row=>[...row.getElementsByTagNameNS('*','Cell')].map(cell=>cell.getElementsByTagNameNS('*','Data')[0]?.textContent||''));const headers=values.shift()||[];return values.map(vals=>Object.fromEntries(headers.map((h,i)=>[h,vals[i]??''])));}
|
||
function sheetByName(doc,name){return [...doc.getElementsByTagNameNS('*','Worksheet')].find(ws=>(ws.getAttribute('ss:Name')||ws.getAttributeNS('urn:schemas-microsoft-com:office:spreadsheet','Name')||ws.getAttribute('Name'))===name);}
|
||
const jsonCell=(v,d)=>{try{return JSON.parse(v||'')}catch(_){return d}};
|
||
async function importExcel(file){const text=await file.text(),doc=new DOMParser().parseFromString(text,'application/xml');if(doc.querySelector('parsererror'))throw new Error('Файл не является поддерживаемой книгой Excel XML. Импортируйте файл, ранее выгруженный из этого приложения.');const data={};for(const name of ['Заказы','Каталог','Склад','Финансы','Команда','Поставщики']){const ws=sheetByName(doc,name);if(ws)data[name]=rowsFromWorksheet(ws);}if(!Object.keys(data).length)throw new Error('В книге нет поддерживаемых листов.');await window.SunEnterprise?.createBackup('Перед импортом Excel');if(data['Заказы'])localStorage.setItem('sunOrders',JSON.stringify(data['Заказы'].map(r=>({id:Number(r.ID)||r.ID,event:r.Мероприятие,date:r.Дата,time:r.Время,status:r.Статус||'Новый',contact:r.Клиент,phone:r.Телефон,address:r.Адрес,total:Number(r.Сумма||0),prepayment:Number(r.Оплачено||0),guestsCount:r.Гостей,responsible:r.Ответственный,lines:jsonCell(r['Позиции JSON'],[]),payments:jsonCell(r['Платежи JSON'],[]),costs:jsonCell(r['Расходы JSON'],{}),note:r.Примечание,kitchenNote:r['Комментарий кухне'],courierNote:r['Комментарий курьеру'],geo:(r.Широта&&r.Долгота)?{lat:Number(r.Широта),lon:Number(r.Долгота),confirmed:true}:null}))));if(data['Каталог'])localStorage.setItem('sunBoxes',JSON.stringify(data['Каталог'].map(r=>({id:r.ID,name:r.Название,category:Number(r.Категория||0),price:Number(r.Цена||0),oldPrice:Number(r['Цена до скидки']||0)||undefined,weight:r.Вес,guests:r.Гостей,catalogSection:r.Раздел,ingredients:jsonCell(r['Состав JSON'],[]),composition:String(r['Описание состава']||'').split(' | ').filter(Boolean),photo:r.Фото}))));if(data['Склад'])localStorage.setItem('sunStock',JSON.stringify(data['Склад'].map(r=>({id:r.ID,name:r.Продукт,unit:r.Единица,qty:Number(r.Остаток||0),min:Number(r.Минимум||0),cost:Number(r['Закупочная цена']||0),supplierId:r['Поставщик ID'],lastPurchaseDate:r['Последняя закупка'],lastPurchasePrice:Number(r['Последняя цена']||0)}))));if(data['Финансы'])localStorage.setItem('sunFinanceRecordsV2',JSON.stringify(data['Финансы'].map(r=>({id:r.ID,kind:r.Тип,date:r.Дата,amount:Number(r.Сумма||0),category:r.Категория,description:r.Описание,note:r.Комментарий}))));if(data['Команда'])localStorage.setItem('sunEmployees',JSON.stringify(data['Команда'].map(r=>({id:r.ID,name:r.Имя,role:r.Роль,phone:r.Телефон,active:!['0','false','FALSE',''].includes(String(r.Активен)),note:r.Комментарий}))));if(data['Поставщики'])localStorage.setItem('sunSuppliers',JSON.stringify(data['Поставщики'].map(r=>({id:r.ID,name:r.Название,category:r.Категория,contact:r.Контакт,phone:r.Телефон,email:r.Email,address:r.Адрес,active:!['0','false','FALSE',''].includes(String(r.Активен)),delivery:r.Доставка,minOrder:Number(r['Минимальный заказ']||0),note:r.Комментарий}))));window.SunEnterprise?.recordAudit('Импорт базы',`Excel · ${file.name}`);location.reload();}
|
||
// Excel tools are intentionally not shown in Settings. JSON backup remains the supported import/export UI.
|
||
function inject(){document.getElementById('excelDataCard')?.remove();}
|
||
inject();
|
||
})();
|
||
|
||
/* END enterprise-update.js */
|
||
</script><script>window.SUN_PORTABLE_HTML=true;</script>
|
||
<script>
|
||
/* ===== SAFE MODAL BACKDROP v2 =====
|
||
Prevents accidental modal closing when a pointer/touch starts inside a dialog
|
||
and ends on the dark backdrop, or when the user drags/scrolls on the backdrop.
|
||
A deliberate stationary click on the empty backdrop still closes the modal
|
||
through the existing application handler. */
|
||
(() => {
|
||
if (window.__sunSafeModalBackdropV2) return;
|
||
window.__sunSafeModalBackdropV2 = true;
|
||
|
||
let pointerState = null;
|
||
const MOVE_LIMIT = 8;
|
||
|
||
document.addEventListener('pointerdown', event => {
|
||
const modal = event.target instanceof Element ? event.target.closest('.modal.on') : null;
|
||
if (!modal) {
|
||
pointerState = null;
|
||
return;
|
||
}
|
||
pointerState = {
|
||
modal,
|
||
startedOnBackdrop: event.target === modal,
|
||
x: event.clientX,
|
||
y: event.clientY,
|
||
pointerId: event.pointerId
|
||
};
|
||
}, true);
|
||
|
||
document.addEventListener('click', event => {
|
||
const target = event.target;
|
||
if (!(target instanceof HTMLElement) || !target.classList.contains('modal') || !target.classList.contains('on')) return;
|
||
|
||
const state = pointerState;
|
||
pointerState = null;
|
||
|
||
// Keyboard-generated click: keep normal behavior.
|
||
if (!state) return;
|
||
|
||
const moved = Math.hypot(event.clientX - state.x, event.clientY - state.y) > MOVE_LIMIT;
|
||
const deliberateBackdropClick = state.modal === target && state.startedOnBackdrop && !moved;
|
||
|
||
// The legacy backdrop handler runs later on document. Stop it unless this was
|
||
// a clean click that both started and ended on the empty backdrop.
|
||
if (!deliberateBackdropClick) {
|
||
event.preventDefault();
|
||
event.stopImmediatePropagation();
|
||
}
|
||
}, true);
|
||
|
||
document.addEventListener('pointercancel', () => { pointerState = null; }, true);
|
||
})();
|
||
</script>
|
||
|
||
<script id="sun-delivery-details-module">
|
||
(() => {
|
||
const ids=['deliveryStreet','deliveryHouse','deliveryEntrance','deliveryFloor','deliveryApartment','deliveryOffice'];
|
||
const $=id=>document.getElementById(id);
|
||
const address=$('address');
|
||
if(!address || ids.some(id=>!$(id))) return;
|
||
const val=id=>$(id).value.trim();
|
||
const compose=()=>[
|
||
val('deliveryStreet'),
|
||
val('deliveryHouse')?`д. ${val('deliveryHouse')}`:'',
|
||
val('deliveryEntrance')?`подъезд ${val('deliveryEntrance')}`:'',
|
||
val('deliveryFloor')?`этаж ${val('deliveryFloor')}`:'',
|
||
val('deliveryApartment')?`кв. ${val('deliveryApartment')}`:'',
|
||
val('deliveryOffice')?`офис ${val('deliveryOffice')}`:''
|
||
].filter(Boolean).join(', ');
|
||
const syncAddress=()=>{
|
||
const next=compose();
|
||
if(address.value!==next){address.value=next;address.dispatchEvent(new Event('input',{bubbles:true}));}
|
||
return next;
|
||
};
|
||
ids.forEach(id=>$(id).addEventListener('input',syncAddress));
|
||
window.sunComposeDeliveryAddress=syncAddress;
|
||
|
||
const previousSave=window.saveOrder;
|
||
if(typeof previousSave==='function') window.saveOrder=function(...args){
|
||
syncAddress();
|
||
if(typeof draft!=='undefined'&&draft){
|
||
draft.deliveryStreet=val('deliveryStreet');
|
||
draft.deliveryHouse=val('deliveryHouse');
|
||
draft.deliveryEntrance=val('deliveryEntrance');
|
||
draft.deliveryFloor=val('deliveryFloor');
|
||
draft.deliveryApartment=val('deliveryApartment');
|
||
draft.deliveryOffice=val('deliveryOffice');
|
||
draft.address=address.value;
|
||
}
|
||
return previousSave.apply(this,args);
|
||
};
|
||
|
||
const fillFromDraft=()=>{
|
||
const d=(typeof draft!=='undefined'&&draft)||{};
|
||
const hasParts=['deliveryStreet','deliveryHouse','deliveryEntrance','deliveryFloor','deliveryApartment','deliveryOffice'].some(k=>String(d[k]||'').trim());
|
||
$('deliveryStreet').value=hasParts?(d.deliveryStreet||''):(d.address||'');
|
||
$('deliveryHouse').value=d.deliveryHouse||'';
|
||
$('deliveryEntrance').value=d.deliveryEntrance||'';
|
||
$('deliveryFloor').value=d.deliveryFloor||'';
|
||
$('deliveryApartment').value=d.deliveryApartment||'';
|
||
$('deliveryOffice').value=d.deliveryOffice||'';
|
||
if(hasParts) syncAddress(); else {address.value=d.address||'';address.dispatchEvent(new Event('input',{bubbles:true}));}
|
||
};
|
||
|
||
const previousOpen=window.openOrder;
|
||
if(typeof previousOpen==='function') window.openOrder=function(...args){const result=previousOpen.apply(this,args);fillFromDraft();return result;};
|
||
const previousReset=window.resetDraft;
|
||
if(typeof previousReset==='function') window.resetDraft=function(...args){const result=previousReset.apply(this,args);ids.forEach(id=>$(id).value='');address.value='';address.dispatchEvent(new Event('input',{bubbles:true}));return result;};
|
||
|
||
// New orders begin with empty structured address fields.
|
||
if(!(typeof draft!=='undefined'&&draft&&draft.id)) ids.forEach(id=>$(id).value='');
|
||
})();
|
||
</script>
|
||
<style id="sun-youth-sidebar-style">
|
||
@media (min-width:901px){
|
||
:root{--sun-side:292px!important;--sun-side-collapsed:74px!important;--sy-bg1:#241d45;--sy-bg2:#171d38;--sy-accent:#f2c85f;--sy-violet:#9f8cff;--sy-text:#f8f7ff;--sy-muted:#a9a8c6}
|
||
body.sun-enterprise-sidebar{padding-left:var(--sun-side)!important;background:#f5f6fa!important;transition:padding-left .22s cubic-bezier(.2,.8,.2,1)!important}
|
||
.sun-enterprise-sidebar header{width:var(--sun-side)!important;background:radial-gradient(circle at 12% 0%,rgba(149,116,255,.32),transparent 30%),radial-gradient(circle at 100% 43%,rgba(73,112,207,.18),transparent 38%),linear-gradient(165deg,#32255d 0%,var(--sy-bg1) 42%,var(--sy-bg2) 100%)!important;padding:16px 14px 14px!important;gap:9px!important;box-shadow:9px 0 34px rgba(33,27,73,.13)!important;border-right:1px solid rgba(255,255,255,.07)!important;overflow-x:visible!important}
|
||
.sun-enterprise-sidebar header .brand{min-height:72px!important;padding:5px 7px 13px!important;border-bottom:1px solid rgba(255,255,255,.10)!important;color:#fff!important;font-size:18px!important;display:flex!important;align-items:center!important;letter-spacing:.01em!important}
|
||
.sun-enterprise-sidebar header .brand .real-logo{width:56px!important;height:56px!important;margin-right:11px!important;object-fit:contain!important;filter:none!important}
|
||
.sun-enterprise-sidebar header nav{gap:3px!important;margin-top:1px!important}
|
||
.sun-enterprise-sidebar .sun-nav-home-standalone{margin:3px 3px 7px!important}
|
||
.sun-enterprise-sidebar .sun-nav-group{position:relative!important;margin:0 3px!important;padding:30px 0 9px!important;gap:2px!important;border:0!important;border-bottom:1px solid rgba(255,255,255,.09)!important;background:transparent!important;border-radius:0!important}
|
||
.sun-enterprise-sidebar .sun-nav-group::before{position:absolute!important;left:11px!important;top:10px!important;color:var(--sy-muted)!important;font-size:9.5px!important;font-weight:800!important;letter-spacing:.12em!important;text-transform:uppercase!important}
|
||
.sun-enterprise-sidebar .sun-nav-group::after{display:none!important}
|
||
.sun-enterprise-sidebar header nav .sun-nav-button{position:relative!important;width:100%!important;min-height:44px!important;padding:8px 10px!important;justify-content:flex-start!important;border:1px solid transparent!important;border-radius:12px!important;color:#e9e8f6!important;font-size:13.5px!important;font-weight:650!important;letter-spacing:0!important;transition:background .16s ease,border-color .16s ease,color .16s ease,transform .16s ease!important}
|
||
.sun-enterprise-sidebar header nav .sun-nav-button::before{content:''!important;width:20px!important;height:20px!important;min-width:20px!important;margin:0 12px 0 0!important;border-radius:0!important;background:currentColor!important;opacity:.86!important;-webkit-mask-image:var(--sun-icon)!important;mask-image:var(--sun-icon)!important;-webkit-mask-repeat:no-repeat!important;mask-repeat:no-repeat!important;-webkit-mask-position:center!important;mask-position:center!important;-webkit-mask-size:20px 20px!important;mask-size:20px 20px!important;box-shadow:none!important}
|
||
.sun-enterprise-sidebar header nav .nav-home{--sun-icon:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.9%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M3%2011.5%2012%204l9%207.5%22%2F%3E%3Cpath%20d%3D%22M5%2010.5V20h14v-9.5%22%2F%3E%3Cpath%20d%3D%22M9%2020v-6h6v6%22%2F%3E%3C%2Fsvg%3E")}
|
||
.sun-enterprise-sidebar header nav .nav-new{--sun-icon:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.9%22%20stroke-linecap%3D%22round%22%3E%3Crect%20x%3D%224%22%20y%3D%224%22%20width%3D%2216%22%20height%3D%2216%22%20rx%3D%224%22%2F%3E%3Cpath%20d%3D%22M12%208v8M8%2012h8%22%2F%3E%3C%2Fsvg%3E")}
|
||
.sun-enterprise-sidebar header nav .nav-orders{--sun-icon:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.9%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Crect%20x%3D%225%22%20y%3D%224%22%20width%3D%2214%22%20height%3D%2217%22%20rx%3D%223%22%2F%3E%3Cpath%20d%3D%22M9%204.5V3h6v1.5M8%2010h8M8%2014h8M8%2018h5%22%2F%3E%3C%2Fsvg%3E")}
|
||
.sun-enterprise-sidebar header nav .nav-calendar{--sun-icon:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.9%22%20stroke-linecap%3D%22round%22%3E%3Crect%20x%3D%223.5%22%20y%3D%225%22%20width%3D%2217%22%20height%3D%2216%22%20rx%3D%223%22%2F%3E%3Cpath%20d%3D%22M7.5%203v4M16.5%203v4M3.5%209.5h17M8%2013h.01M12%2013h.01M16%2013h.01M8%2017h.01M12%2017h.01%22%2F%3E%3C%2Fsvg%3E")}
|
||
.sun-enterprise-sidebar header nav .nav-map{--sun-icon:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.9%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M20%2010c0%205.2-8%2011-8%2011S4%2015.2%204%2010a8%208%200%201%201%2016%200Z%22%2F%3E%3Ccircle%20cx%3D%2212%22%20cy%3D%2210%22%20r%3D%222.6%22%2F%3E%3C%2Fsvg%3E")}
|
||
.sun-enterprise-sidebar header nav .nav-print{--sun-icon:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.9%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M7%208V3h10v5M7%2017H5a2%202%200%200%201-2-2v-4a3%203%200%200%201%203-3h12a3%203%200%200%201%203%203v4a2%202%200%200%201-2%202h-2%22%2F%3E%3Crect%20x%3D%227%22%20y%3D%2214%22%20width%3D%2210%22%20height%3D%227%22%20rx%3D%221.5%22%2F%3E%3C%2Fsvg%3E")}
|
||
.sun-enterprise-sidebar header nav .nav-production{--sun-icon:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.9%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M5%2010h14v3a7%207%200%200%201-14%200v-3Z%22%2F%3E%3Cpath%20d%3D%22M3%2010h18M8%206c0-1.4%201-2%201-3M12%206c0-1.4%201-2%201-3M16%206c0-1.4%201-2%201-3%22%2F%3E%3C%2Fsvg%3E")}
|
||
.sun-enterprise-sidebar header nav .nav-prep{--sun-icon:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.9%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M4%207.5%2012%204l8%203.5-8%203.5-8-3.5Z%22%2F%3E%3Cpath%20d%3D%22m4%2012%208%203.5%208-3.5M4%2016.5%2012%2020l8-3.5%22%2F%3E%3C%2Fsvg%3E")}
|
||
.sun-enterprise-sidebar header nav .nav-shopping{--sun-icon:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.9%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M3%204h2l2.2%2010.2a2%202%200%200%200%202%201.6h7.7a2%202%200%200%200%201.9-1.4L21%208H7%22%2F%3E%3Ccircle%20cx%3D%2210%22%20cy%3D%2220%22%20r%3D%221%22%2F%3E%3Ccircle%20cx%3D%2218%22%20cy%3D%2220%22%20r%3D%221%22%2F%3E%3C%2Fsvg%3E")}
|
||
.sun-enterprise-sidebar header nav .nav-stock{--sun-icon:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.9%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22m4%207%208-4%208%204v10l-8%204-8-4V7Z%22%2F%3E%3Cpath%20d%3D%22m4%207%208%204%208-4M12%2011v10%22%2F%3E%3C%2Fsvg%3E")}
|
||
.sun-enterprise-sidebar header nav .nav-routes{--sun-icon:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.9%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Ccircle%20cx%3D%226%22%20cy%3D%2218%22%20r%3D%222%22%2F%3E%3Ccircle%20cx%3D%2218%22%20cy%3D%226%22%20r%3D%222%22%2F%3E%3Cpath%20d%3D%22M8%2018h3a3%203%200%200%200%203-3v-6a3%203%200%200%201%203-3h-1%22%2F%3E%3C%2Fsvg%3E")}
|
||
.sun-enterprise-sidebar header nav .nav-clients{--sun-icon:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.9%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Ccircle%20cx%3D%2212%22%20cy%3D%228%22%20r%3D%223.5%22%2F%3E%3Cpath%20d%3D%22M5%2020a7%207%200%200%201%2014%200%22%2F%3E%3C%2Fsvg%3E")}
|
||
.sun-enterprise-sidebar header nav .nav-money{--sun-icon:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.9%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Crect%20x%3D%223%22%20y%3D%226%22%20width%3D%2218%22%20height%3D%2213%22%20rx%3D%223%22%2F%3E%3Cpath%20d%3D%22M16%2010h5v5h-5a2.5%202.5%200%200%201%200-5ZM7%206V4h10v2%22%2F%3E%3C%2Fsvg%3E")}
|
||
.sun-enterprise-sidebar header nav .nav-team{--sun-icon:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.9%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Ccircle%20cx%3D%229%22%20cy%3D%228%22%20r%3D%223%22%2F%3E%3Ccircle%20cx%3D%2217%22%20cy%3D%2210%22%20r%3D%222.4%22%2F%3E%3Cpath%20d%3D%22M3.5%2020a5.5%205.5%200%200%201%2011%200M14.5%2016.2a4.5%204.5%200%200%201%206%203.8%22%2F%3E%3C%2Fsvg%3E")}
|
||
.sun-enterprise-sidebar header nav .nav-chat{--sun-icon:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.9%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M4%205h16v11H9l-5%204V5Z%22%2F%3E%3Cpath%20d%3D%22M8%209h8M8%2013h5%22%2F%3E%3C%2Fsvg%3E")}
|
||
.sun-enterprise-sidebar header nav .nav-suppliers{--sun-icon:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.9%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M3%206h11v10H3zM14%209h4l3%203v4h-7z%22%2F%3E%3Ccircle%20cx%3D%227%22%20cy%3D%2218%22%20r%3D%222%22%2F%3E%3Ccircle%20cx%3D%2218%22%20cy%3D%2218%22%20r%3D%222%22%2F%3E%3C%2Fsvg%3E")}
|
||
.sun-enterprise-sidebar header nav .nav-stats{--sun-icon:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.9%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M4%2020V10M10%2020V4M16%2020v-7M22%2020V7%22%2F%3E%3C%2Fsvg%3E")}
|
||
.sun-enterprise-sidebar header nav .nav-settings{--sun-icon:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.9%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M4%207h10M18%207h2M4%2017h2M10%2017h10M4%2012h4M12%2012h8%22%2F%3E%3Ccircle%20cx%3D%2216%22%20cy%3D%227%22%20r%3D%222%22%2F%3E%3Ccircle%20cx%3D%228%22%20cy%3D%2217%22%20r%3D%222%22%2F%3E%3Ccircle%20cx%3D%2210%22%20cy%3D%2212%22%20r%3D%222%22%2F%3E%3C%2Fsvg%3E")}
|
||
.sun-enterprise-sidebar header nav .sun-nav-button:hover{background:rgba(255,255,255,.075)!important;border-color:rgba(255,255,255,.06)!important;color:#fff!important;transform:translateX(2px)!important}
|
||
.sun-enterprise-sidebar header nav .sun-nav-button.on{background:linear-gradient(90deg,rgba(174,151,255,.22),rgba(255,255,255,.07))!important;border-color:rgba(204,194,255,.12)!important;color:#fff!important;box-shadow:inset 0 1px rgba(255,255,255,.05),0 8px 18px rgba(9,8,31,.10)!important;transform:none!important}
|
||
.sun-enterprise-sidebar header nav .sun-nav-button.on::before{background:var(--sy-accent)!important;opacity:1!important}
|
||
.sun-enterprise-sidebar header nav .sun-nav-button.on::after{content:''!important;position:absolute!important;left:-3px!important;top:9px!important;bottom:9px!important;width:3px!important;height:auto!important;border-radius:4px!important;background:linear-gradient(#ffe48d,#e4b844)!important;box-shadow:0 0 10px rgba(242,200,95,.35)!important}
|
||
.sun-side-tools{display:grid!important;grid-template-columns:minmax(0,1fr) 46px!important;gap:7px!important;width:auto!important;margin:2px 3px 5px!important}
|
||
.sun-side-tools .sun-side-action{min-height:44px!important;border:1px solid rgba(255,255,255,.095)!important;background:rgba(255,255,255,.065)!important;color:#f3f0ff!important;border-radius:12px!important;padding:8px 11px!important;box-shadow:inset 0 1px rgba(255,255,255,.03)!important;transition:.16s ease!important}
|
||
.sun-side-tools .sun-side-action:hover{background:rgba(255,255,255,.11)!important;border-color:rgba(255,255,255,.15)!important;transform:translateY(-1px)!important}
|
||
#sunGlobalSearchBtn>span:first-child,#sunNotificationsBtn>span:first-child{font-size:0!important;display:block!important;width:19px!important;height:19px!important;min-width:19px!important;background:currentColor!important;-webkit-mask-repeat:no-repeat!important;mask-repeat:no-repeat!important;-webkit-mask-position:center!important;mask-position:center!important;-webkit-mask-size:19px 19px!important;mask-size:19px 19px!important}
|
||
#sunGlobalSearchBtn>span:first-child{-webkit-mask-image:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%3E%3Ccircle%20cx%3D%2211%22%20cy%3D%2211%22%20r%3D%227%22%2F%3E%3Cpath%20d%3D%22m20%2020-4-4%22%2F%3E%3C%2Fsvg%3E")!important;mask-image:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%3E%3Ccircle%20cx%3D%2211%22%20cy%3D%2211%22%20r%3D%227%22%2F%3E%3Cpath%20d%3D%22m20%2020-4-4%22%2F%3E%3C%2Fsvg%3E")!important}
|
||
#sunNotificationsBtn{width:46px!important;padding:0!important;justify-content:center!important;position:relative!important}
|
||
#sunNotificationsBtn>span:first-child{-webkit-mask-image:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.9%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M18%208a6%206%200%200%200-12%200c0%207-3%207-3%209h18c0-2-3-2-3-9Z%22%2F%3E%3Cpath%20d%3D%22M10%2021h4%22%2F%3E%3C%2Fsvg%3E")!important;mask-image:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.9%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M18%208a6%206%200%200%200-12%200c0%207-3%207-3%209h18c0-2-3-2-3-9Z%22%2F%3E%3Cpath%20d%3D%22M10%2021h4%22%2F%3E%3C%2Fsvg%3E")!important}
|
||
#sunNotificationsBtn>span:nth-child(2){display:none!important}
|
||
#sunNotificationsBtn .badge{position:absolute!important;right:-4px!important;top:-5px!important;margin:0!important;min-width:20px!important;height:20px!important;padding:0 5px!important;background:#ff6c7c!important;color:#fff!important;border:2px solid #2c2452!important;border-radius:999px!important;font-size:10px!important;font-weight:850!important;box-shadow:0 4px 12px rgba(255,79,103,.28)!important}
|
||
#sunGlobalSearchBtn kbd{border:0!important;background:rgba(255,255,255,.08)!important;border-radius:6px!important;padding:3px 5px!important;color:#c9c5dc!important}
|
||
.sun-side-footer{margin:4px 3px 0!important;width:auto!important;display:grid!important;gap:5px!important;padding:10px 2px 1px!important;border-top:1px solid rgba(255,255,255,.09)!important}
|
||
.sun-current-user{order:1!important;display:flex!important;align-items:center!important;min-height:34px!important;padding:5px 8px!important;background:transparent!important;border:0!important;border-radius:8px!important;color:#9e9db9!important;font-size:10.5px!important;font-weight:650!important}
|
||
.sun-current-user::before{content:'';width:15px;height:15px;margin-right:7px;background:currentColor;-webkit-mask:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%3E%3Ccircle%20cx%3D%2212%22%20cy%3D%228%22%20r%3D%223%22%2F%3E%3Cpath%20d%3D%22M6%2020a6%206%200%200%201%2012%200%22%2F%3E%3C%2Fsvg%3E") center/15px 15px no-repeat;mask:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%3E%3Ccircle%20cx%3D%2212%22%20cy%3D%228%22%20r%3D%223%22%2F%3E%3Cpath%20d%3D%22M6%2020a6%206%200%200%201%2012%200%22%2F%3E%3C%2Fsvg%3E") center/15px 15px no-repeat;opacity:.8}
|
||
#sunLogoutBtn{order:2!important;min-height:34px!important;padding:5px 8px!important;background:transparent!important;border:0!important;color:#aaa8bd!important;font-size:11px!important;font-weight:600!important;border-radius:8px!important}
|
||
#sunLogoutBtn:hover{background:rgba(255,255,255,.06)!important;color:#fff!important}
|
||
#sunLogoutBtn>span:first-child{font-size:0!important;width:15px;height:15px;background:currentColor;-webkit-mask:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M10%205H5v14h5M13%208l4%204-4%204M8%2012h9%22%2F%3E%3C%2Fsvg%3E") center/15px 15px no-repeat;mask:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M10%205H5v14h5M13%208l4%204-4%204M8%2012h9%22%2F%3E%3C%2Fsvg%3E") center/15px 15px no-repeat}
|
||
.sun-sidebar-collapse{position:fixed!important;z-index:1200!important;cursor:pointer!important;left:calc(var(--sun-side) - 19px)!important;top:94px!important;width:38px!important;height:38px!important;min-height:38px!important;padding:0!important;border-radius:999px!important;background:rgba(255,255,255,.94)!important;color:#433d68!important;border:1px solid rgba(69,57,113,.15)!important;box-shadow:0 8px 24px rgba(34,27,75,.20)!important;backdrop-filter:blur(8px)!important;display:grid!important;place-items:center!important;font-size:0!important;transition:left .22s cubic-bezier(.2,.8,.2,1),transform .16s ease,box-shadow .16s ease!important}
|
||
.sun-sidebar-collapse:hover{transform:scale(1.07)!important;box-shadow:0 10px 28px rgba(34,27,75,.26)!important;background:#fff!important}
|
||
.sun-sidebar-collapse span{display:none!important}
|
||
.sun-sidebar-collapse::before,.sun-sidebar-collapse::after{content:''!important;position:absolute!important;width:8px!important;height:8px!important;border-left:2px solid currentColor!important;border-bottom:2px solid currentColor!important;transform:rotate(45deg)!important}
|
||
.sun-sidebar-collapse::before{margin-left:5px!important}
|
||
.sun-sidebar-collapse::after{margin-left:-5px!important}
|
||
body.sun-side-collapsed{padding-left:var(--sun-side-collapsed)!important}
|
||
.sun-side-collapsed header{width:var(--sun-side-collapsed)!important;padding-inline:9px!important;overflow-x:visible!important}
|
||
.sun-side-collapsed .sun-sidebar-collapse{left:calc(var(--sun-side-collapsed) - 19px)!important}
|
||
.sun-side-collapsed .sun-sidebar-collapse::before,.sun-side-collapsed .sun-sidebar-collapse::after{transform:rotate(225deg)!important}
|
||
.sun-side-collapsed header .brand{justify-content:center!important;min-height:64px!important;padding-inline:0!important;border-bottom:0!important;font-size:0!important}
|
||
.sun-side-collapsed header .brand .real-logo{width:46px!important;height:46px!important;margin:0!important}
|
||
.sun-side-collapsed .sun-side-tools{grid-template-columns:1fr!important;margin-inline:5px!important}
|
||
.sun-side-collapsed #sunGlobalSearchBtn,.sun-side-collapsed #sunNotificationsBtn{width:46px!important;min-width:46px!important;justify-self:center!important;font-size:0!important;padding:0!important}
|
||
.sun-side-collapsed #sunGlobalSearchBtn>span:nth-child(2),.sun-side-collapsed #sunGlobalSearchBtn kbd{display:none!important}
|
||
.sun-side-collapsed .sun-nav-group{margin:0!important;padding:7px 0 5px!important;border-bottom:0!important}
|
||
.sun-side-collapsed .sun-nav-group::before{display:none!important}
|
||
.sun-side-collapsed header nav .sun-nav-button{width:48px!important;min-width:48px!important;height:46px!important;min-height:46px!important;margin:1px auto!important;padding:0!important;justify-content:center!important;font-size:0!important;border-radius:13px!important}
|
||
.sun-side-collapsed header nav .sun-nav-button::before{width:21px!important;height:21px!important;min-width:21px!important;margin:0!important;-webkit-mask-size:21px 21px!important;mask-size:21px 21px!important}
|
||
.sun-side-collapsed header nav .sun-nav-button.on::after{left:-6px!important;top:12px!important;bottom:12px!important}
|
||
.sun-side-collapsed .sun-current-user{font-size:0!important;justify-content:center!important;padding:5px!important}
|
||
.sun-side-collapsed .sun-current-user::before{margin:0!important;width:17px!important;height:17px!important}
|
||
.sun-side-collapsed #sunLogoutBtn{font-size:0!important;justify-content:center!important;padding:6px!important}
|
||
}
|
||
</style>
|
||
<script id="sun-youth-sidebar-finalizer">
|
||
(function(){
|
||
const init=()=>{
|
||
const header=document.querySelector('header');
|
||
const footer=document.querySelector('.sun-side-footer');
|
||
const current=document.getElementById('sunCurrentUserLabel');
|
||
const collapse=document.getElementById('sunSidebarCollapse');
|
||
if(!header||!footer||!current||!collapse) return;
|
||
// Keep the owner/account indicator at the very bottom and intentionally quiet.
|
||
if(current.parentElement!==footer) window.SunSafe.insertBefore(footer,current,footer.firstChild);
|
||
// Floating edge control stays visible in both expanded and collapsed states.
|
||
if(collapse.parentElement!==document.body) document.body.appendChild(collapse);
|
||
const syncCollapse=()=>{
|
||
const collapsed=document.body.classList.contains('sun-side-collapsed');
|
||
collapse.title=collapsed?'Развернуть меню':'Свернуть меню';
|
||
collapse.setAttribute('aria-label',collapsed?'Развернуть меню':'Свернуть меню');
|
||
};
|
||
syncCollapse();
|
||
collapse.addEventListener('click',()=>setTimeout(syncCollapse,0));
|
||
document.querySelectorAll('header nav button').forEach(btn=>{
|
||
const label=(btn.dataset.navLabel||btn.textContent||'').trim();
|
||
if(label) { btn.title=label; btn.setAttribute('aria-label',label); }
|
||
});
|
||
const n=document.getElementById('sunNotificationsBtn');
|
||
if(n){n.title='Уведомления';n.setAttribute('aria-label','Уведомления');}
|
||
const s=document.getElementById('sunGlobalSearchBtn');
|
||
if(s){s.title='Поиск';s.setAttribute('aria-label','Поиск');}
|
||
};
|
||
if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',()=>setTimeout(init,0));
|
||
else setTimeout(init,0);
|
||
})();
|
||
</script>
|
||
|
||
<style id="sun-catalog-density-style">
|
||
/* Catalog density modes + branded no-photo placeholder */
|
||
.sun-catalog-actions{display:flex;align-items:center;gap:9px;flex-wrap:wrap;justify-content:flex-end}
|
||
.sun-catalog-view-switch{display:inline-flex;align-items:center;gap:3px;padding:3px;border:1px solid #e2e4eb;background:#f7f8fb;border-radius:12px;box-shadow:inset 0 1px 0 #fff}
|
||
.sun-catalog-view-switch button{height:31px;border:0;border-radius:9px;background:transparent;color:#6f7484;padding:0 9px;font-size:11.5px;font-weight:750;letter-spacing:.01em;display:inline-flex;align-items:center;gap:6px;white-space:nowrap;transition:.15s ease}
|
||
.sun-catalog-view-switch button:hover{background:#fff;color:#30284f}
|
||
.sun-catalog-view-switch button.on{background:#fff;color:#30284f;box-shadow:0 2px 8px rgba(40,34,75,.12)}
|
||
.sun-catalog-view-switch .view-glyph{width:14px;height:14px;display:inline-grid;place-items:center;position:relative;color:currentColor}
|
||
.sun-catalog-view-switch .view-glyph::before,.sun-catalog-view-switch .view-glyph::after{content:'';position:absolute;border:1.5px solid currentColor;border-radius:2px}
|
||
.sun-catalog-view-switch [data-mode="photo"] .view-glyph::before{inset:1px}
|
||
.sun-catalog-view-switch [data-mode="photo"] .view-glyph::after{width:4px;height:4px;right:2px;top:2px;border-radius:50%;background:currentColor}
|
||
.sun-catalog-view-switch [data-mode="compact"] .view-glyph::before{width:5px;height:5px;left:1px;top:1px;box-shadow:7px 0 0 -1.5px currentColor,0 7px 0 -1.5px currentColor,7px 7px 0 -1.5px currentColor}
|
||
.sun-catalog-view-switch [data-mode="compact"] .view-glyph::after{display:none}
|
||
.sun-catalog-view-switch [data-mode="list"] .view-glyph::before{left:1px;right:1px;top:2px;height:1px;border-width:1px;box-shadow:0 4px 0 currentColor,0 8px 0 currentColor}
|
||
.sun-catalog-view-switch [data-mode="list"] .view-glyph::after{display:none}
|
||
.sun-catalog-view-switch [data-mode="dense"] .view-glyph::before{left:1px;right:1px;top:3px;height:2px;border-width:1px;box-shadow:0 4px 0 currentColor,0 8px 0 currentColor}
|
||
.sun-catalog-view-switch [data-mode="dense"] .view-glyph::after{display:none}
|
||
.tile .ph{overflow:hidden;background:linear-gradient(145deg,#f4f1e9,#e7ebf0)!important;place-items:center!important}
|
||
.tile .ph .sun-ph-logo,.manager .ph .sun-ph-logo{width:56%!important;height:56%!important;max-width:62px!important;max-height:62px!important;object-fit:contain!important;border-radius:0!important;filter:drop-shadow(0 2px 3px rgba(0,0,0,.08));opacity:.92}
|
||
.manager .ph{display:grid!important;place-items:center!important;background:linear-gradient(145deg,#f4f1e9,#e7ebf0)!important;overflow:hidden!important}
|
||
.manager .ph .sun-ph-logo{width:66%!important;height:66%!important}
|
||
|
||
/* Large photo cards — current comfortable view */
|
||
#tiles.sun-view-photo{grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:10px}
|
||
#tiles.sun-view-photo .tile{border-radius:10px;overflow:hidden}
|
||
|
||
/* Compact cards — more positions on screen */
|
||
#tiles.sun-view-compact{grid-template-columns:repeat(auto-fill,minmax(108px,1fr));gap:7px}
|
||
#tiles.sun-view-compact .tile{min-height:116px;padding:6px;border-radius:9px;font-size:12px;line-height:1.18}
|
||
#tiles.sun-view-compact .tile img,#tiles.sun-view-compact .tile>.ph{height:58px}
|
||
#tiles.sun-view-compact .tile>span{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;margin-top:4px}
|
||
#tiles.sun-view-compact .tile-section,#tiles.sun-view-compact .tile-meta{display:none!important}
|
||
#tiles.sun-view-compact .tile-price{display:block;margin-top:4px;font-size:11px;color:#57606c}
|
||
#tiles.sun-view-compact .tile.add{font-size:12px;background:linear-gradient(145deg,#fffaf0,#fff)}
|
||
|
||
|
||
/* Ultra-dense rectangles — no photos, maximum positions on screen */
|
||
#tiles.sun-view-dense{grid-template-columns:repeat(auto-fill,minmax(132px,1fr));gap:5px}
|
||
#tiles.sun-view-dense .tile:not(.add){min-height:46px;height:46px;padding:6px 8px;border-radius:8px;display:grid;grid-template-columns:minmax(0,1fr) auto;grid-template-rows:1fr;gap:6px;align-items:center;text-align:left;background:#f8f9fb}
|
||
#tiles.sun-view-dense .tile:not(.add)>img,#tiles.sun-view-dense .tile:not(.add)>.ph,#tiles.sun-view-dense .tile-section,#tiles.sun-view-dense .tile-meta{display:none!important}
|
||
#tiles.sun-view-dense .tile:not(.add)>span{grid-column:1;grid-row:1;font-size:11.5px;line-height:1.12;margin:0;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||
#tiles.sun-view-dense .tile-price{grid-column:2;grid-row:1;font-size:10.5px;line-height:1;color:#645c7d;white-space:nowrap;margin:0}
|
||
#tiles.sun-view-dense .tile.add{grid-column:1/-1;min-height:40px;height:40px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:11.5px;padding:5px;background:linear-gradient(145deg,#fffaf0,#fff)}
|
||
|
||
/* Dense list — fastest when adding many items */
|
||
#tiles.sun-view-list{grid-template-columns:repeat(auto-fill,minmax(285px,1fr));gap:6px}
|
||
#tiles.sun-view-list .tile:not(.add){min-height:62px;padding:7px 10px;border-radius:10px;display:grid;grid-template-columns:48px minmax(0,1fr) auto;grid-template-rows:auto auto;column-gap:9px;row-gap:1px;align-items:center;text-align:left;background:#f8f9fb}
|
||
#tiles.sun-view-list .tile:not(.add)>img,#tiles.sun-view-list .tile:not(.add)>.ph{grid-column:1;grid-row:1/3;width:46px;height:46px;margin:0;border-radius:8px}
|
||
#tiles.sun-view-list .tile:not(.add)>span{grid-column:2;grid-row:1;font-size:12.5px;line-height:1.18;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;align-self:end}
|
||
#tiles.sun-view-list .tile-section,#tiles.sun-view-list .tile-meta{grid-column:2;grid-row:2;font-size:10.5px!important;line-height:1.15;color:#838999;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;align-self:start}
|
||
#tiles.sun-view-list .tile-meta{display:none!important}
|
||
#tiles.sun-view-list .tile-price{grid-column:3;grid-row:1/3;align-self:center;font-size:12px;color:#30284f;white-space:nowrap;margin-left:6px}
|
||
#tiles.sun-view-list .tile.add{grid-column:1/-1;min-height:48px;border-radius:10px;display:flex;align-items:center;justify-content:center;gap:5px;font-size:12.5px;background:linear-gradient(145deg,#fffaf0,#fff)}
|
||
|
||
@media(max-width:1180px) and (min-width:901px){
|
||
.sun-catalog-view-switch button{padding:0 7px;font-size:0}.sun-catalog-view-switch .view-glyph{margin:0}
|
||
#tiles.sun-view-list{grid-template-columns:1fr}
|
||
}
|
||
@media(max-width:900px){
|
||
.catalog-head{align-items:flex-start;gap:8px;flex-wrap:wrap}.sun-catalog-actions{width:100%;justify-content:space-between}.sun-catalog-view-switch{max-width:100%;overflow:auto}
|
||
#tiles.sun-view-compact{grid-template-columns:repeat(auto-fill,minmax(96px,1fr))}
|
||
#tiles.sun-view-list{grid-template-columns:1fr}
|
||
}
|
||
</style>
|
||
<script id="sun-catalog-density-script">
|
||
(() => {
|
||
const VIEW_KEY='sunCatalogViewModeV1';
|
||
const AUTOSAVE_KEY_LOCAL='sunOrderDraftAutosaveV1';
|
||
const modes=new Set(['photo','compact','list','dense']);
|
||
const getMode=()=>{const value=localStorage.getItem(VIEW_KEY)||'photo';return modes.has(value)?value:'photo';};
|
||
const tiles=document.getElementById('tiles');
|
||
const catalogHead=document.querySelector('#new .catalog .catalog-head');
|
||
const configureButton=catalogHead ? [...catalogHead.querySelectorAll('button')].find(b=>b.textContent.trim()==='Настроить') : null;
|
||
|
||
function setMode(mode){
|
||
if(!modes.has(mode)) mode='photo';
|
||
localStorage.setItem(VIEW_KEY,mode);
|
||
if(tiles){tiles.classList.remove('sun-view-photo','sun-view-compact','sun-view-list','sun-view-dense');tiles.classList.add(`sun-view-${mode}`);}
|
||
document.querySelectorAll('.sun-catalog-view-switch button').forEach(btn=>btn.classList.toggle('on',btn.dataset.mode===mode));
|
||
}
|
||
|
||
if(catalogHead && configureButton && !document.querySelector('.sun-catalog-view-switch')){
|
||
const actions=document.createElement('div'); actions.className='sun-catalog-actions';
|
||
const switcher=document.createElement('div'); switcher.className='sun-catalog-view-switch'; switcher.setAttribute('role','group'); switcher.setAttribute('aria-label','Вид каталога');
|
||
switcher.innerHTML=`
|
||
<button type="button" data-mode="photo" title="Фото — крупные карточки"><span class="view-glyph"></span><span>Фото</span></button>
|
||
<button type="button" data-mode="compact" title="Компактно — больше позиций на экране"><span class="view-glyph"></span><span>Компактно</span></button>
|
||
<button type="button" data-mode="list" title="Список — плотный вид с маленькой фотографией"><span class="view-glyph"></span><span>Список</span></button>
|
||
<button type="button" data-mode="dense" title="Мини — без фотографий, максимум позиций"><span class="view-glyph"></span><span>Мини</span></button>`;
|
||
switcher.addEventListener('click',event=>{const btn=event.target.closest('button[data-mode]');if(btn)setMode(btn.dataset.mode);});
|
||
window.SunSafe.insertBefore(catalogHead,actions,configureButton);
|
||
actions.appendChild(switcher); actions.appendChild(configureButton);
|
||
}
|
||
setMode(getMode());
|
||
|
||
// Universal editor wording for every catalog tab.
|
||
const priceCaption=document.getElementById('boxPriceCaption');
|
||
const categorySelect=document.getElementById('itemCategory');
|
||
const saveBoxButton=document.getElementById('saveBoxButton');
|
||
function updateEditorWording(){
|
||
if(priceCaption) priceCaption.textContent='Цена, ₽';
|
||
if(saveBoxButton) saveBoxButton.textContent='Сохранить';
|
||
}
|
||
if(typeof window.editBox==='function'){
|
||
const previousEditBox=window.editBox;
|
||
window.editBox=function(...args){const result=previousEditBox.apply(this,args);updateEditorWording(categorySelect?.value||0);return result;};
|
||
}
|
||
if(categorySelect){
|
||
const previousCategoryChange=categorySelect.onchange;
|
||
categorySelect.onchange=function(event){const result=previousCategoryChange?.call(this,event);updateEditorWording(this.value);return result;};
|
||
}
|
||
updateEditorWording(categorySelect?.value||0);
|
||
|
||
// Clicking “Новый заказ” after working with a saved order always opens a clean draft.
|
||
// Opening an order through “Заказы → Изменить” still loads that saved order normally.
|
||
document.addEventListener('click',event=>{
|
||
const button=event.target.closest('header nav button');
|
||
if(!button || button.textContent.trim()!=='Новый заказ') return;
|
||
try{
|
||
if(typeof draft!=='undefined' && draft && Number(draft.id||0)>0){
|
||
localStorage.removeItem(AUTOSAVE_KEY_LOCAL);
|
||
if(typeof window.resetDraft==='function') window.resetDraft();
|
||
if(typeof details==='function') details(false);
|
||
const orderTab=document.getElementById('orderTab');
|
||
orderTab?.scrollIntoView({behavior:'smooth',block:'nearest'});
|
||
}
|
||
}catch(_){/* keep navigation usable even if a legacy draft is malformed */}
|
||
},true);
|
||
})();
|
||
</script>
|
||
|
||
|
||
<style id="sun-order-details-clean-style">
|
||
/* Simplified order details requested: only client/delivery data + order economics. */
|
||
#details .event-card-section,#details .staff-assignment,#openEventCardBtn{display:none!important}
|
||
#details .sun-guests-details-field{grid-column:1/-1;max-width:280px;margin:0 0 2px}
|
||
#details .sun-guests-details-field span{display:block}
|
||
#details .sun-guests-details-field input{width:100%}
|
||
@media(max-width:680px){#details .sun-guests-details-field{max-width:none}}
|
||
#orderEconomyPanel>.order-finance-panel:first-child,#order-payment-modal{display:none!important}
|
||
</style>
|
||
|
||
<style id="sun-dashboard-route-v3-style">
|
||
#orderGeoBox{display:none!important}
|
||
/* Dashboard v3 */
|
||
#dashboard .dash3-head{display:flex;justify-content:space-between;align-items:flex-start;gap:16px;flex-wrap:wrap;margin-bottom:16px}
|
||
#dashboard .dash3-head h1{margin:0;color:var(--n);font-size:26px}#dashboard .dash3-head p{margin:5px 0 0;color:#717887}
|
||
#dashboard .dash3-actions{display:flex;gap:8px;flex-wrap:wrap}
|
||
#dashboard .dash3-grid{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(320px,.75fr);gap:14px}
|
||
#dashboard .dash3-panel{border:1px solid #e1e4e9;background:#fff;border-radius:16px;overflow:hidden;box-shadow:0 8px 28px rgba(24,30,55,.055)}
|
||
#dashboard .dash3-panel-head{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:14px 16px;border-bottom:1px solid #eceef2}
|
||
#dashboard .dash3-panel-head h2{margin:0;color:#27324c;font-size:16px}#dashboard .dash3-panel-head small{color:#8a91a0}
|
||
#dashboard .dash3-next{padding:18px;background:linear-gradient(145deg,#28204f 0%,#3d2d74 55%,#25375e 100%);color:#fff;min-height:220px;position:relative;overflow:hidden}
|
||
#dashboard .dash3-next:after{content:"";position:absolute;width:230px;height:230px;border-radius:50%;right:-80px;top:-110px;background:rgba(255,255,255,.08)}
|
||
#dashboard .dash3-next-top{display:flex;justify-content:space-between;gap:10px;position:relative;z-index:1}.dash3-kicker{font-size:11px;text-transform:uppercase;letter-spacing:.12em;color:#c9c4e4}
|
||
#dashboard .dash3-next h2{font-size:24px;margin:12px 0 7px;color:#fff}.dash3-next p{margin:4px 0;color:#dedbef;line-height:1.35}
|
||
#dashboard .dash3-next-bottom{display:flex;align-items:flex-end;justify-content:space-between;gap:12px;margin-top:22px;position:relative;z-index:1}.dash3-next-money{font-size:22px;font-weight:800;color:#f3d27a}
|
||
#dashboard .dash3-next .dash3-open{border:1px solid rgba(255,255,255,.28);background:rgba(255,255,255,.12);color:#fff;border-radius:10px;padding:9px 13px;font-weight:700}
|
||
#dashboard .dash3-status{display:inline-flex;padding:5px 9px;border-radius:999px;background:rgba(255,255,255,.12);font-size:11px;color:#fff}
|
||
#dashboard .dash3-orders{padding:2px 14px}.dash3-order{display:grid;grid-template-columns:74px 1fr auto;gap:10px;align-items:center;padding:12px 0;border-bottom:1px solid #eceef2}.dash3-order:last-child{border-bottom:0}
|
||
#dashboard .dash3-order-date{font-weight:800;color:#342c66}.dash3-order-date small{display:block;font-weight:500;color:#8a91a0;margin-top:3px}.dash3-order-main b{display:block;color:#27324c}.dash3-order-main span{display:block;color:#7a8290;font-size:12px;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
#dashboard .dash3-order-price{font-weight:800;color:#27324c;text-align:right}.dash3-order-price button{display:block;margin:5px 0 0 auto}
|
||
#dashboard .dash3-row{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(300px,.85fr);gap:14px;margin-top:14px}
|
||
#dashboard .dash3-chart{padding:16px}.dash3-bars{height:180px;display:flex;align-items:flex-end;gap:6px;border-bottom:1px solid #e7e9ee;padding:8px 2px 0}
|
||
#dashboard .dash3-barcol{flex:1;min-width:0;height:100%;display:flex;align-items:flex-end;justify-content:flex-end;flex-direction:column;gap:5px}.dash3-bar{width:100%;min-height:3px;border-radius:7px 7px 2px 2px;background:linear-gradient(180deg,#8269d8,#4b5796);opacity:.92}.dash3-barcol small{width:100%;text-align:center;color:#8a91a0;font-size:9px}
|
||
#dashboard .dash3-chart-note{display:flex;justify-content:space-between;gap:8px;margin-top:10px;color:#7b8290;font-size:11px}
|
||
#dashboard .dash3-donut-wrap{display:grid;grid-template-columns:150px 1fr;align-items:center;gap:18px;padding:16px}.dash3-donut{width:138px;height:138px;border-radius:50%;position:relative;margin:auto}.dash3-donut:after{content:"";position:absolute;inset:28px;background:#fff;border-radius:50%;box-shadow:inset 0 0 0 1px #edf0f3}.dash3-donut-center{position:absolute;inset:0;display:grid;place-items:center;z-index:1;text-align:center;font-weight:800;color:#27324c}.dash3-donut-center small{display:block;font-weight:500;color:#8a91a0;font-size:10px}
|
||
#dashboard .dash3-legend{display:grid;gap:7px}.dash3-legend-row{display:grid;grid-template-columns:10px 1fr auto;gap:7px;align-items:center;font-size:11px;color:#68717a}.dash3-legend-row i{width:9px;height:9px;border-radius:50%}.dash3-legend-row b{color:#27324c}
|
||
#dashboard .dash3-popular{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:10px;padding:14px}.dash3-dish{border:1px solid #e5e8ed;border-radius:13px;overflow:hidden;background:#fafbfc;min-width:0}.dash3-dish img{display:block;width:100%;height:92px;object-fit:cover;background:#eef0f4}.dash3-dish-body{padding:9px}.dash3-dish b{display:block;color:#27324c;font-size:12px;line-height:1.25}.dash3-dish span{display:block;color:#7d8492;font-size:11px;margin-top:5px}
|
||
#dashboard .dash3-empty{padding:24px 16px;color:#8a91a0;text-align:center}
|
||
@media(max-width:1100px){#dashboard .dash3-grid,#dashboard .dash3-row{grid-template-columns:1fr}#dashboard .dash3-popular{grid-template-columns:repeat(3,minmax(0,1fr))}}
|
||
@media(max-width:650px){#dashboard .dash3-popular{grid-template-columns:repeat(2,minmax(0,1fr))}#dashboard .dash3-donut-wrap{grid-template-columns:1fr}.dash3-order{grid-template-columns:62px 1fr}.dash3-order-price{grid-column:2}.dash3-next-bottom{align-items:flex-start;flex-direction:column}}
|
||
|
||
/* Order double click */
|
||
#ordersList .row{cursor:pointer}
|
||
#ordersList .row:hover{box-shadow:0 4px 14px rgba(38,42,70,.06)}
|
||
#ordersList .row.sun-order-open-flash{outline:2px solid rgba(111,91,208,.25)}
|
||
|
||
/* Route preview */
|
||
#orderGeoBox{display:none!important}
|
||
.sun-route-box{margin:10px 0 14px;border:1px solid #dedfea;border-radius:15px;background:linear-gradient(145deg,#fff,#f8f8fc);padding:14px}
|
||
.sun-route-head{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.sun-route-head b{color:#29244e}.sun-route-head small{color:#7a8190}
|
||
.sun-route-status{display:flex;align-items:center;gap:7px;font-size:12px;color:#747b89;margin-top:5px}.sun-route-dot{width:9px;height:9px;border-radius:50%;background:#b7bdc6}.sun-route-status.ok .sun-route-dot{background:#55a46c}.sun-route-status.warn .sun-route-dot{background:#d4a343}
|
||
.sun-route-actions{display:flex;gap:7px;flex-wrap:wrap;margin:11px 0}.sun-route-actions button{font-size:12px;padding:7px 10px}
|
||
.sun-route-meta{display:flex;gap:8px;flex-wrap:wrap;margin:9px 0}.sun-route-chip{font-size:11px;color:#566071;background:#f0f1f6;border-radius:999px;padding:5px 8px}
|
||
.sun-route-preview{height:245px;border-radius:12px;overflow:hidden;background:#edf0f3;border:1px solid #dfe3e7;position:relative}
|
||
.sun-route-placeholder{height:100%;display:grid;place-items:center;text-align:center;padding:20px;color:#7b8290}.sun-route-placeholder img{width:68px;height:50px;object-fit:contain;display:block;margin:0 auto 9px}
|
||
.route-map-canvas{position:relative;width:100%;height:100%;overflow:hidden;background:#e9edf0}.route-map-canvas .osm-tile{position:absolute;width:256px;height:256px;max-width:none;user-select:none;pointer-events:none}.route-map-svg{position:absolute;inset:0;width:100%;height:100%;z-index:2;pointer-events:none}
|
||
.route-map-marker{position:absolute;z-index:4;width:28px;height:28px;border-radius:50%;display:grid;place-items:center;font-weight:800;font-size:11px;color:#fff;border:3px solid #fff;box-shadow:0 3px 10px rgba(30,35,50,.25);transform:translate(-50%,-50%)}.route-map-marker.start{background:#303c61}.route-map-marker.finish{background:#6f5bd0}
|
||
.route-map-credit{position:absolute;right:5px;bottom:4px;z-index:5;background:rgba(255,255,255,.82);padding:2px 5px;border-radius:4px;font-size:9px;color:#68717a}
|
||
.route-page-head{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;flex-wrap:wrap}.route-page-head p{margin:5px 0 0;color:#707887;max-width:780px}
|
||
.route-toolbar{display:grid;grid-template-columns:170px 190px minmax(220px,1fr);gap:9px;margin:14px 0}.route-cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:13px}
|
||
.route-card{border:1px solid #e0e3e8;border-radius:15px;background:#fff;overflow:hidden;box-shadow:0 6px 20px rgba(25,30,55,.045)}.route-card .sun-route-preview{border:0;border-radius:0;height:210px}
|
||
.route-card-body{padding:12px}.route-card-head{display:flex;justify-content:space-between;gap:10px}.route-card h3{margin:0;color:#28324c;font-size:15px}.route-card p{margin:5px 0;color:#6d7583;font-size:12px;line-height:1.35}.route-card-actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:9px}.route-card-actions button{font-size:11px;padding:6px 8px}
|
||
.route-badge{font-size:10px;font-weight:800;padding:4px 8px;border-radius:999px;background:#f0eefb;color:#5a49a8;white-space:nowrap}
|
||
.sun-doc-route{margin-top:18px;border:1px solid #d9dde0;border-radius:10px;overflow:hidden;break-inside:avoid}.sun-doc-route-head{padding:9px 11px;background:#f8f6f0;display:flex;justify-content:space-between;gap:10px}.sun-doc-route .sun-route-preview{height:250px;border:0;border-radius:0}.sun-doc-route-foot{padding:8px 11px;color:#68717a;font-size:11px}
|
||
@media(max-width:700px){.route-toolbar{grid-template-columns:1fr}.route-cards{grid-template-columns:1fr}.sun-route-preview{height:210px}}
|
||
@media print{.sun-doc-route .sun-route-preview{height:220px}.route-map-credit{font-size:7px}}
|
||
</style>
|
||
<script id="sun-dashboard-route-v3">
|
||
(() => {
|
||
const q = s => document.querySelector(s);
|
||
const qa = s => [...document.querySelectorAll(s)];
|
||
const esc=window.SunSafe.escapeHTML;
|
||
const fmt = v => typeof window.money === 'function' ? window.money(v) : `${Math.round(Number(v||0)).toLocaleString('ru-RU')} ₽`;
|
||
const orderTotal = o => typeof window.orderTotalValue === 'function' ? Number(window.orderTotalValue(o)||0) : Number(o?.total||0);
|
||
const statusColors = {'Новый':'#6f9fc1','В работе':'#9074c0','Собран':'#e0b242','Передан курьеру':'#df8c43','Отдан заказчику':'#62a77a','Отменён':'#c96b6b'};
|
||
const todayKey = () => { const d=new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; };
|
||
const addDays = (iso,n) => { const d=new Date(`${iso}T12:00:00`); d.setDate(d.getDate()+n); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; };
|
||
const stamp = o => `${o?.date||'9999-99-99'}T${o?.time||'23:59'}`;
|
||
const dateLabel = raw => { if(!raw)return'—'; const d=new Date(`${raw}T12:00:00`); return Number.isNaN(d.getTime())?raw:d.toLocaleDateString('ru-RU',{day:'2-digit',month:'2-digit'}); };
|
||
|
||
/* ---------- Dashboard v3 ---------- */
|
||
const dashboard = q('#dashboard');
|
||
const nav = q('header nav');
|
||
const dashboardButton = nav ? qa('header nav button').find(b => b.textContent.trim()==='Главная') : null;
|
||
|
||
function openDashboardOrder(id){
|
||
if(typeof window.openOrder==='function') window.openOrder(Number(id));
|
||
}
|
||
window.sunDashboardOpenOrder = openDashboardOrder;
|
||
|
||
function dashboardGo(label){
|
||
const b = qa('header nav button').find(x=>x.textContent.trim()===label);
|
||
if(b) b.click();
|
||
}
|
||
window.sunDashboardGo = dashboardGo;
|
||
|
||
function dashboardBars(valid){
|
||
const start=todayKey(), days=Array.from({length:14},(_,i)=>{
|
||
const date=addDays(start,i), list=valid.filter(o=>o.date===date);
|
||
return {date,count:list.length,total:list.reduce((s,o)=>s+orderTotal(o),0)};
|
||
});
|
||
const max=Math.max(1,...days.map(x=>x.count));
|
||
return `<div class="dash3-bars">${days.map((d,i)=>`<div class="dash3-barcol" title="${esc(dateLabel(d.date))}: ${d.count} заказов"><div class="dash3-bar" style="height:${d.count?Math.max(7,d.count/max*100):3}%"></div><small>${i%2===0?esc(dateLabel(d.date)):''}</small></div>`).join('')}</div><div class="dash3-chart-note"><span>Ближайшие 14 дней</span><b>${days.reduce((s,d)=>s+d.count,0)} заказов</b></div>`;
|
||
}
|
||
|
||
function dashboardDonut(valid){
|
||
const names=['Новый','В работе','Собран','Передан курьеру','Отдан заказчику'];
|
||
const rows=names.map(name=>({name,count:valid.filter(o=>(o.status||'Новый')===name).length,color:statusColors[name]}));
|
||
const total=rows.reduce((s,r)=>s+r.count,0);
|
||
let acc=0,parts=[];
|
||
rows.forEach(r=>{ if(!r.count||!total)return; const a=acc/total*360; acc+=r.count; const b=acc/total*360; parts.push(`${r.color} ${a}deg ${b}deg`); });
|
||
const bg=parts.length?`conic-gradient(${parts.join(',')})`:'#eef0f4';
|
||
return `<div class="dash3-donut-wrap"><div class="dash3-donut" style="background:${bg}"><div class="dash3-donut-center"><div>${total}<small>заказов</small></div></div></div><div class="dash3-legend">${rows.map(r=>`<div class="dash3-legend-row"><i style="background:${r.color}"></i><span>${r.name}</span><b>${r.count}</b></div>`).join('')}</div></div>`;
|
||
}
|
||
|
||
function dashboardPopular(valid){
|
||
const end=todayKey(),start=addDays(end,-29);
|
||
const recent=valid.filter(o=>String(o.date||'')>=start&&String(o.date||'')<=end);
|
||
const map=new Map();
|
||
recent.forEach(o=>(o.lines||[]).forEach(l=>{
|
||
const item=(typeof boxes!=='undefined'?boxes:[]).find(b=>String(b.id)===String(l.id));
|
||
if(!item)return;
|
||
const qty=Number(l.qty||0),sales=qty*Number(item.price||0);
|
||
const prev=map.get(String(item.id))||{item,qty:0,sales:0};
|
||
prev.qty+=qty;prev.sales+=sales;map.set(String(item.id),prev);
|
||
}));
|
||
return [...map.values()].sort((a,b)=>b.sales-a.sales||b.qty-a.qty).slice(0,6);
|
||
}
|
||
|
||
function dashboardRevenue(valid){
|
||
const end=todayKey(),days=Array.from({length:30},(_,i)=>{
|
||
const date=addDays(end,i-29),list=valid.filter(o=>o.date===date);
|
||
return {date,total:list.reduce((sum,o)=>sum+orderTotal(o),0)};
|
||
});
|
||
const max=Math.max(1,...days.map(x=>x.total)),sum=days.reduce((a,x)=>a+x.total,0);
|
||
return `<div class="dash3-bars">${days.map((d,i)=>`<div class="dash3-barcol" title="${esc(dateLabel(d.date))}: ${esc(fmt(d.total))}"><div class="dash3-bar" style="height:${d.total?Math.max(5,d.total/max*100):3}%"></div><small>${i%5===0||i===29?esc(dateLabel(d.date)):''}</small></div>`).join('')}</div><div class="dash3-chart-note"><span>Последние 30 дней</span><b>${esc(fmt(sum))}</b></div>`;
|
||
}
|
||
|
||
function renderDashboardV3(){
|
||
if(!dashboard || typeof orders==='undefined') return;
|
||
const valid=orders.filter(o=>(o.status||'Новый')!=='Отменён');
|
||
const now=new Date();
|
||
const upcoming=valid.filter(o=>{
|
||
if(['Отдан заказчику'].includes(o.status||'Новый')) return false;
|
||
const d=new Date(`${o.date||'1900-01-01'}T${o.time||'00:00'}:00`);
|
||
return !Number.isNaN(d.getTime()) && d.getTime()>=now.getTime()-30*60*1000;
|
||
}).sort((a,b)=>stamp(a).localeCompare(stamp(b)));
|
||
const nearest=upcoming[0]||null;
|
||
const following=upcoming.slice(1,6);
|
||
const popular=dashboardPopular(valid);
|
||
|
||
const nearestHtml=nearest?`<div class="dash3-next">
|
||
<div class="dash3-next-top"><div><div class="dash3-kicker">Ближайший заказ</div><h2>№${esc(nearest.id)} · ${esc(nearest.event||'Заказ')}</h2><p>${esc(dateLabel(nearest.date))} · ${esc(nearest.time||'—')} · ${esc(nearest.contact||'Клиент не указан')}</p><p>${esc(nearest.address||'Адрес не указан')}</p></div><span class="dash3-status">${esc(nearest.status||'Новый')}</span></div>
|
||
<div class="dash3-next-bottom"><div><div class="dash3-next-money">${esc(fmt(orderTotal(nearest)))}</div><small style="color:#c9c4e4">Следующий по времени активный заказ</small></div><button type="button" class="dash3-open" onclick="sunDashboardOpenOrder('${esc(nearest.id)}')">Открыть заказ</button></div>
|
||
</div>`:`<div class="dash3-next"><div class="dash3-kicker">Ближайший заказ</div><h2>Активных заказов пока нет</h2><p>Когда появится новый заказ, он автоматически будет показан здесь.</p></div>`;
|
||
|
||
const followingHtml=following.length?following.map(o=>`<div class="dash3-order"><div class="dash3-order-date">${esc(dateLabel(o.date))}<small>${esc(o.time||'')}</small></div><div class="dash3-order-main"><b>№${esc(o.id)} · ${esc(o.event||'Заказ')}</b><span>${esc(o.contact||'Клиент не указан')} · ${esc(o.address||'Без адреса')}</span></div><div class="dash3-order-price">${esc(fmt(orderTotal(o)))}<button type="button" class="mini" onclick="sunDashboardOpenOrder('${esc(o.id)}')">Открыть</button></div></div>`).join(''):`<div class="dash3-empty">Следующих заказов пока нет.</div>`;
|
||
|
||
const popularHtml=popular.length?popular.map((x,i)=>`<div class="dash3-dish"><img src="${esc(x.item.photo||(window.CateriumBranding.documentImage()))}" onerror="this.src='${window.CateriumBranding.logoHTML()}'" alt=""><div class="dash3-dish-body"><b>${i+1}. ${esc(x.item.name||'Позиция')}</b><span>${x.qty} шт. · ${esc(fmt(x.sales))}</span></div></div>`).join(''):`<div class="dash3-empty" style="grid-column:1/-1">За последние 30 дней продаж по позициям пока нет.</div>`;
|
||
|
||
dashboard.innerHTML=`<div class="dash3-head"><div><h1>Главная</h1><p>Ближайшие заказы и короткая картина по работе кейтеринга.</p></div><div class="dash3-actions"><button class="primary" type="button" onclick="sunDashboardGo('Новый заказ')">+ Новый заказ</button><button class="outline" type="button" onclick="sunDashboardGo('Заказы')">Все заказы</button></div></div>
|
||
<div class="dash3-grid">
|
||
<section class="dash3-panel">${nearestHtml}</section>
|
||
<section class="dash3-panel"><div class="dash3-panel-head"><h2>Следующие заказы</h2><small>${following.length}</small></div><div class="dash3-orders">${followingHtml}</div></section>
|
||
</div>
|
||
<div class="dash3-row">
|
||
<section class="dash3-panel"><div class="dash3-panel-head"><h2>Загрузка по дням</h2><small>14 дней</small></div><div class="dash3-chart">${dashboardBars(valid)}</div></section>
|
||
</div>
|
||
<section class="dash3-panel" style="margin-top:14px"><div class="dash3-panel-head"><h2>Лидеры продаж</h2><small>последние 30 дней</small></div><div class="dash3-popular">${popularHtml}</div></section>`;
|
||
}
|
||
window.renderDashboardV3=renderDashboardV3;
|
||
|
||
if(dashboardButton){
|
||
dashboardButton.onclick=()=>{
|
||
qa('.view').forEach(v=>v.classList.remove('on')); dashboard.classList.add('on');
|
||
qa('header nav button').forEach(b=>b.classList.remove('on')); dashboardButton.classList.add('on');
|
||
renderDashboardV3();
|
||
};
|
||
}
|
||
if(dashboard?.classList.contains('on')) renderDashboardV3();
|
||
|
||
/* ---------- Double click orders ---------- */
|
||
const ordersList=q('#ordersList');
|
||
if(ordersList && !ordersList.dataset.dblOpen){
|
||
ordersList.dataset.dblOpen='1';
|
||
ordersList.addEventListener('dblclick',event=>{
|
||
if(event.target.closest('button,select,input,textarea,a,label')) return;
|
||
const row=event.target.closest('.row'); if(!row) return;
|
||
const first=row.querySelector('span'); const m=String(first?.textContent||'').match(/(\d+)/); if(!m)return;
|
||
row.classList.add('sun-order-open-flash');
|
||
setTimeout(()=>openDashboardOrder(Number(m[1])),70);
|
||
});
|
||
}
|
||
const ordersHint=q('#orders .hint');
|
||
if(ordersHint) ordersHint.textContent='Двойной щелчок по заказу открывает его. Также можно использовать кнопку «Изменить».';
|
||
|
||
/* ---------- Fixed-origin route previews ---------- */
|
||
const ORIGIN_ADDRESS='Ленинградская область, Ломоносовский район, деревня Телези, 20';
|
||
const ORIGIN_LABEL='Телези, 20';
|
||
const ORIGIN_COORDS={lat:59.701058,lon:29.977269};
|
||
const ROUTE_PROVIDER='OSRM / OpenStreetMap';
|
||
|
||
function currentDraftAddress(){
|
||
if(window.SunStaticMap?.orderMapAddress) return window.SunStaticMap.orderMapAddress(draft);
|
||
return String(draft?.address||'').trim();
|
||
}
|
||
function orderAddress(order){
|
||
if(window.SunStaticMap?.orderMapAddress) return window.SunStaticMap.orderMapAddress(order);
|
||
return String(order?.address||'').trim();
|
||
}
|
||
function geoOK(g){ return g && Number.isFinite(Number(g.lat)) && Number.isFinite(Number(g.lon)); }
|
||
function routeOK(r){ return r && Array.isArray(r.coords) && r.coords.length>1 && geoOK(r.origin) && geoOK(r.destination); }
|
||
function routeMatches(r,geo,address){
|
||
const originCorrect=routeOK(r)&&Number(r.routeVersion||0)===3&&Math.abs(Number(r.origin.lat)-ORIGIN_COORDS.lat)<0.00002&&Math.abs(Number(r.origin.lon)-ORIGIN_COORDS.lon)<0.00002;
|
||
return originCorrect && geoOK(geo) && String(r.sourceAddress||'')===String(address||'') && Math.abs(Number(r.destination.lat)-Number(geo.lat))<0.00002 && Math.abs(Number(r.destination.lon)-Number(geo.lon))<0.00002;
|
||
}
|
||
function km(v){return `${(Number(v||0)/1000).toLocaleString('ru-RU',{maximumFractionDigits:1})} км`;}
|
||
function mins(v){const n=Math.max(1,Math.round(Number(v||0)/60));return n>=60?`${Math.floor(n/60)} ч ${n%60?`${n%60} мин`:''}`:`${n} мин`;}
|
||
|
||
async function geocodeOrigin(){
|
||
return {lat:ORIGIN_COORDS.lat,lon:ORIGIN_COORDS.lon,formatted:ORIGIN_ADDRESS};
|
||
}
|
||
|
||
function simplifyCoords(coords,max=220){
|
||
if(!Array.isArray(coords)||coords.length<=max)return coords||[];
|
||
const step=Math.ceil(coords.length/max),out=[];
|
||
for(let i=0;i<coords.length;i+=step)out.push(coords[i]);
|
||
const last=coords[coords.length-1];
|
||
if(out[out.length-1]!==last)out.push(last);
|
||
return out;
|
||
}
|
||
|
||
async function fetchRoute(origin,destination,sourceAddress){
|
||
const url=`https://router.project-osrm.org/route/v1/driving/${Number(origin.lon)},${Number(origin.lat)};${Number(destination.lon)},${Number(destination.lat)}?overview=full&geometries=geojson&steps=false`;
|
||
const r=await sunFetchWithTimeout(url,{mode:'cors',credentials:'omit'},8000);
|
||
if(!r.ok)throw new Error('Сервис маршрута временно недоступен.');
|
||
const d=await r.json(),route=d?.routes?.[0],coords=route?.geometry?.coordinates;
|
||
if(!route||!Array.isArray(coords)||coords.length<2)throw new Error('Не удалось построить автомобильный маршрут.');
|
||
return {
|
||
origin:{lat:Number(origin.lat),lon:Number(origin.lon),address:ORIGIN_ADDRESS},
|
||
destination:{lat:Number(destination.lat),lon:Number(destination.lon),address:sourceAddress},
|
||
coords:simplifyCoords(coords.map(c=>[Number(c[0]),Number(c[1])])),
|
||
distance:Number(route.distance||0),duration:Number(route.duration||0),
|
||
sourceAddress:String(sourceAddress||''),builtAt:new Date().toISOString(),provider:ROUTE_PROVIDER,routeVersion:3
|
||
};
|
||
}
|
||
|
||
function merc(lon,lat){
|
||
const x=(Number(lon)+180)/360;
|
||
const s=Math.sin(Math.max(-85.05112878,Math.min(85.05112878,Number(lat)))*Math.PI/180);
|
||
const y=0.5-Math.log((1+s)/(1-s))/(4*Math.PI);
|
||
return [x,y];
|
||
}
|
||
|
||
function routeMedia(route,width=760,height=245){
|
||
if(!routeOK(route)) return `<div class="sun-route-placeholder"><div><img src="${window.CateriumBranding.logoHTML()}" alt="${window.CateriumBranding.nameHTML()}"><b>Маршрут ещё не построен</b><div style="margin-top:5px">Старт всегда: ${esc(ORIGIN_LABEL)}</div></div></div>`;
|
||
const pts=route.coords.map(c=>merc(c[0],c[1]));
|
||
let minX=Math.min(...pts.map(p=>p[0])),maxX=Math.max(...pts.map(p=>p[0])),minY=Math.min(...pts.map(p=>p[1])),maxY=Math.max(...pts.map(p=>p[1]));
|
||
if(maxX-minX<1e-6){minX-=1e-6;maxX+=1e-6} if(maxY-minY<1e-6){minY-=1e-6;maxY+=1e-6}
|
||
let z=18;
|
||
for(;z>3;z--){const world=256*Math.pow(2,z);if((maxX-minX)*world<=width-76 && (maxY-minY)*world<=height-62)break;}
|
||
const world=256*Math.pow(2,z),cx=(minX+maxX)/2,cy=(minY+maxY)/2,left=cx*world-width/2,top=cy*world-height/2;
|
||
const tx0=Math.floor(left/256),tx1=Math.floor((left+width)/256),ty0=Math.floor(top/256),ty1=Math.floor((top+height)/256),limit=Math.pow(2,z);
|
||
let tileSvg='';
|
||
for(let ty=ty0;ty<=ty1;ty++)for(let tx=tx0;tx<=tx1;tx++){
|
||
const wx=((tx%limit)+limit)%limit;if(ty<0||ty>=limit)continue;
|
||
tileSvg+=`<image href="https://tile.openstreetmap.org/${z}/${wx}/${ty}.png" x="${Math.round(tx*256-left)}" y="${Math.round(ty*256-top)}" width="256" height="256" preserveAspectRatio="none"/>`;
|
||
}
|
||
const xy=pts.map(p=>[p[0]*world-left,p[1]*world-top]);
|
||
const poly=xy.map(p=>`${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' ');
|
||
const markerSource=Array.isArray(route.stops)&&route.stops.length?
|
||
[{lat:route.origin.lat,lon:route.origin.lon,label:'A',kind:'start'},...route.stops.map((x,i)=>({lat:x.lat,lon:x.lon,label:String(i+1),kind:'finish'}))]:
|
||
[{lat:route.origin.lat,lon:route.origin.lon,label:'A',kind:'start'},{lat:route.destination.lat,lon:route.destination.lon,label:'B',kind:'finish'}];
|
||
const markerSvg=markerSource.map(m=>{const p=merc(m.lon,m.lat),x=p[0]*world-left,y=p[1]*world-top,fill=m.kind==='start'?'#303c61':'#6f5bd0';return `<g transform="translate(${x.toFixed(1)} ${y.toFixed(1)})"><circle r="13" fill="${fill}" stroke="#fff" stroke-width="3"/><text x="0" y="4" text-anchor="middle" font-size="10" font-weight="800" fill="#fff">${esc(m.label)}</text></g>`;}).join('');
|
||
return `<div class="route-map-canvas"><svg class="route-map-svg" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none">${tileSvg}<polyline points="${poly}" fill="none" stroke="rgba(255,255,255,.96)" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/><polyline points="${poly}" fill="none" stroke="#6f5bd0" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>${markerSvg}</svg><div class="route-map-credit">© OpenStreetMap contributors</div></div>`;
|
||
}
|
||
|
||
function yandexRouteUrl(route,address){
|
||
if(routeOK(route)){
|
||
const a=route.origin,b=route.destination;
|
||
return `https://yandex.ru/maps/?rtext=${encodeURIComponent(`${a.lat},${a.lon}~${b.lat},${b.lon}`)}&rtt=auto`;
|
||
}
|
||
return `https://yandex.ru/maps/?rtext=${encodeURIComponent(`${ORIGIN_ADDRESS}~${address||''}`)}&rtt=auto`;
|
||
}
|
||
|
||
async function ensureDraftRoute({silent=false,force=false}={}){
|
||
const address=currentDraftAddress();
|
||
if(!address){if(!silent)alert('Сначала укажите улицу и дом.');return null;}
|
||
let destination=null;
|
||
try{destination=await window.SunStaticMap?.ensureDraftGeo?.({silent:true,force});}catch(_){}
|
||
if(!geoOK(destination)){if(!silent)alert('Не удалось определить адрес доставки. Проверьте улицу, дом и интернет.');return null;}
|
||
if(!force&&routeMatches(draft.routePreview,destination,address))return draft.routePreview;
|
||
try{
|
||
setDraftRouteStatus('Строим маршрут…','warn');
|
||
const origin=await geocodeOrigin(),route=await fetchRoute(origin,destination,address);
|
||
draft.routePreview=route;
|
||
if(draft.id){
|
||
const i=orders.findIndex(o=>String(o.id)===String(draft.id));
|
||
if(i>=0){orders[i].routePreview=structuredClone(route);persist();}
|
||
}
|
||
renderDraftRoute(); return route;
|
||
}catch(e){renderDraftRoute();if(!silent)alert(e?.message||'Не удалось построить маршрут.');return null;}
|
||
}
|
||
|
||
async function ensureOrderRoute(order,{silent=false,force=false}={}){
|
||
if(!order)return null;
|
||
const address=orderAddress(order);if(!address){if(!silent)alert('В заказе не указан адрес.');return null;}
|
||
let destination=null;try{destination=await window.SunStaticMap?.ensureOrderGeo?.(order,{silent:true,force});}catch(_){}
|
||
if(!geoOK(destination)){if(!silent)alert('Не удалось определить адрес доставки.');return null;}
|
||
if(!force&&routeMatches(order.routePreview,destination,address))return order.routePreview;
|
||
try{
|
||
const origin=await geocodeOrigin(),route=await fetchRoute(origin,destination,address);
|
||
order.routePreview=route;persist();
|
||
if(draft?.id&&String(draft.id)===String(order.id))draft.routePreview=structuredClone(route);
|
||
return route;
|
||
}catch(e){if(!silent)alert(e?.message||'Не удалось построить маршрут.');return null;}
|
||
}
|
||
|
||
function setDraftRouteStatus(text,kind=''){
|
||
const st=q('#sunRouteStatus');if(!st)return;st.className=`sun-route-status ${kind}`;const span=st.querySelector('span');if(span)span.textContent=text;
|
||
}
|
||
|
||
const details=q('#details');
|
||
let routeBox=q('#sunRouteBox');
|
||
if(false && details && !routeBox){
|
||
routeBox=document.createElement('div');routeBox.id='sunRouteBox';routeBox.className='sun-route-box';
|
||
const old=q('#orderGeoBox');
|
||
routeBox.innerHTML=`<div class="sun-route-head"><div><b>Маршрут доставки</b><div class="sun-route-status" id="sunRouteStatus"><i class="sun-route-dot"></i><span>Маршрут ещё не построен</span></div></div><small>Старт: ${esc(ORIGIN_LABEL)}</small></div><div class="sun-route-actions"><button type="button" class="primary" id="sunRouteBuild">Построить маршрут</button><button type="button" class="outline" id="sunRouteOpen">Открыть маршрут</button></div><div id="sunRouteMeta" class="sun-route-meta"></div><div id="sunRoutePreview" class="sun-route-preview"></div>`;
|
||
if(old)old.insertAdjacentElement('afterend',routeBox);else details.appendChild(routeBox);
|
||
}
|
||
|
||
function renderDraftRoute(){
|
||
if(!routeBox)return;
|
||
const address=currentDraftAddress(),geo=draft?.geo,route=draft?.routePreview,valid=routeMatches(route,geo,address);
|
||
const preview=q('#sunRoutePreview'),meta=q('#sunRouteMeta'),build=q('#sunRouteBuild');
|
||
if(!address){
|
||
setDraftRouteStatus('Укажите улицу и дом','');
|
||
meta.innerHTML=`<span class="sun-route-chip">Старт: ${esc(ORIGIN_LABEL)}</span>`;
|
||
preview.innerHTML=routeMedia(null);build.textContent='Построить маршрут';return;
|
||
}
|
||
if(!valid){
|
||
setDraftRouteStatus('Нужно построить маршрут','warn');
|
||
meta.innerHTML=`<span class="sun-route-chip">${esc(ORIGIN_LABEL)} → ${esc(address)}</span>`;
|
||
preview.innerHTML=routeMedia(null);build.textContent='Построить маршрут';return;
|
||
}
|
||
setDraftRouteStatus('Маршрут готов','ok');
|
||
meta.innerHTML=`<span class="sun-route-chip">${esc(ORIGIN_LABEL)} → ${esc(address)}</span><span class="sun-route-chip">${esc(km(route.distance))}</span><span class="sun-route-chip">≈ ${esc(mins(route.duration))}</span>`;
|
||
preview.innerHTML=routeMedia(route);build.textContent='Обновить маршрут';
|
||
}
|
||
|
||
q('#sunRouteBuild')?.addEventListener('click',async()=>{await ensureDraftRoute({force:true});renderRoutePage();});
|
||
q('#sunRouteOpen')?.addEventListener('click',()=>{
|
||
const address=currentDraftAddress();
|
||
window.open(yandexRouteUrl(draft?.routePreview,address),'_blank','noopener');
|
||
});
|
||
|
||
['deliveryStreet','deliveryHouse','address'].forEach(id=>q(`#${id}`)?.addEventListener('input',()=>setTimeout(renderDraftRoute,0)));
|
||
|
||
/* Replace the old map page with route cards. */
|
||
const mapView=q('#order-map-view');
|
||
const mapButton=qa('header nav button').find(b=>b.classList.contains('nav-map')||b.textContent.trim()==='Карта');
|
||
|
||
function routePageShell(){
|
||
if(!mapView)return;
|
||
mapView.innerHTML=`<div class="route-page-head"><div><h1>Маршруты доставки</h1><p>На каждом превью показан автомобильный маршрут от постоянной точки <b>${esc(ORIGIN_LABEL)}</b> до адреса заказа. Для навигации курьера маршрут открывается в Яндекс Картах.</p></div></div>
|
||
<div class="route-toolbar"><label>Период<select id="routePeriod"><option value="today">Сегодня</option><option value="tomorrow">Завтра</option><option value="week" selected>7 дней</option><option value="month">30 дней</option><option value="all">Все заказы</option></select></label><label>Статус<select id="routeStatus"><option value="all">Все статусы</option>${Object.keys(statusColors).map(s=>`<option>${esc(s)}</option>`).join('')}</select></label><label>Поиск<input id="routeSearch" placeholder="Заказ, клиент, адрес..."></label></div><div id="routeCards" class="route-cards"></div>`;
|
||
q('#routePeriod')?.addEventListener('change',renderRoutePage);
|
||
q('#routeStatus')?.addEventListener('change',renderRoutePage);
|
||
q('#routeSearch')?.addEventListener('input',renderRoutePage);
|
||
}
|
||
|
||
function filteredRouteOrders(){
|
||
const period=q('#routePeriod')?.value||'week',status=q('#routeStatus')?.value||'all',search=(q('#routeSearch')?.value||'').trim().toLowerCase(),today=todayKey();
|
||
return orders.slice().filter(o=>{
|
||
if((o.status||'Новый')==='Отменён')return false;
|
||
if(status!=='all'&&(o.status||'Новый')!==status)return false;
|
||
if(period==='today'&&o.date!==today)return false;
|
||
if(period==='tomorrow'&&o.date!==addDays(today,1))return false;
|
||
if(period==='week'&&((o.date||'')<today||(o.date||'')>addDays(today,6)))return false;
|
||
if(period==='month'&&((o.date||'')<today||(o.date||'')>addDays(today,29)))return false;
|
||
if(search&&!`${o.id} ${o.event||''} ${o.contact||''} ${o.address||''}`.toLowerCase().includes(search))return false;
|
||
return true;
|
||
}).sort((a,b)=>stamp(a).localeCompare(stamp(b)));
|
||
}
|
||
|
||
function renderRoutePage(){
|
||
const wrap=q('#routeCards');if(!wrap)return;
|
||
const list=filteredRouteOrders();
|
||
wrap.innerHTML=list.length?list.map(o=>{
|
||
const address=orderAddress(o),valid=routeMatches(o.routePreview,o.geo,address),r=o.routePreview;
|
||
return `<article class="route-card"><div class="sun-route-preview">${valid?routeMedia(r,760,210):routeMedia(null,760,210)}</div><div class="route-card-body"><div class="route-card-head"><div><h3>№${esc(o.id)} · ${esc(o.event||'Заказ')}</h3><p>${esc(dateLabel(o.date))} · ${esc(o.time||'—')} · ${esc(o.contact||'Клиент не указан')}</p></div><span class="route-badge">${valid?`${esc(km(r.distance))} · ${esc(mins(r.duration))}`:'маршрут не построен'}</span></div><p><b>${esc(ORIGIN_LABEL)}</b> → ${esc(address||'Адрес не указан')}</p><div class="route-card-actions"><button class="outline" type="button" onclick="sunRouteOpenOrder('${esc(o.id)}')">Заказ</button>${address?`<button class="${valid?'outline':'primary'}" type="button" onclick="sunRouteBuildOrder('${esc(o.id)}')">${valid?'Обновить':'Построить маршрут'}</button><button class="outline" type="button" onclick="sunRouteOpenYandex('${esc(o.id)}')">Открыть маршрут</button>`:''}</div></div></article>`;
|
||
}).join(''):`<div class="dash3-empty" style="grid-column:1/-1">Заказов по выбранным фильтрам нет.</div>`;
|
||
}
|
||
|
||
window.sunRouteOpenOrder=id=>openDashboardOrder(id);
|
||
window.sunRouteBuildOrder=async id=>{const o=orders.find(x=>String(x.id)===String(id));if(!o)return;const btn=document.activeElement;if(btn?.tagName==='BUTTON'){btn.disabled=true;btn.textContent='Строим…';}await ensureOrderRoute(o,{force:true});if(btn?.tagName==='BUTTON')btn.disabled=false;renderRoutePage();};
|
||
window.sunRouteOpenYandex=id=>{const o=orders.find(x=>String(x.id)===String(id));if(!o)return;const address=orderAddress(o);window.open(yandexRouteUrl(o.routePreview,address),'_blank','noopener');};
|
||
|
||
if(mapView&&mapButton){
|
||
routePageShell();
|
||
mapButton.onclick=()=>{
|
||
qa('.view').forEach(v=>v.classList.remove('on'));mapView.classList.add('on');
|
||
qa('header nav button').forEach(b=>b.classList.remove('on'));mapButton.classList.add('on');
|
||
renderRoutePage();
|
||
};
|
||
}
|
||
|
||
/* Keep route panel in sync when an order is opened/reset. */
|
||
if(typeof window.openOrder==='function'){
|
||
const prev=window.openOrder;
|
||
window.openOrder=id=>{const result=prev(id);setTimeout(renderDraftRoute,0);return result;};
|
||
}
|
||
if(typeof window.resetDraft==='function'){
|
||
const prev=window.resetDraft;
|
||
window.resetDraft=()=>{const result=prev();setTimeout(renderDraftRoute,0);return result;};
|
||
}
|
||
|
||
/* Replace point map in printable order documents with route preview when available. */
|
||
function injectRouteIntoPrint(){
|
||
const doc=q('#preview .sun-doc');if(!doc)return;
|
||
doc.querySelectorAll('.sun-doc-map,.sun-doc-route').forEach(x=>x.remove());
|
||
const address=currentDraftAddress(),r=draft?.routePreview;
|
||
if(!routeMatches(r,draft?.geo,address))return;
|
||
const block=document.createElement('div');block.className='sun-doc-route';
|
||
block.innerHTML=`<div class="sun-doc-route-head"><b>Маршрут доставки</b><span>${esc(ORIGIN_LABEL)} → ${esc(address)}</span></div><div class="sun-route-preview">${routeMedia(r,760,250)}</div><div class="sun-doc-route-foot">${esc(km(r.distance))} · примерно ${esc(mins(r.duration))} · © OpenStreetMap contributors</div>`;
|
||
const footer=doc.querySelector('.sun-doc-footer');if(footer)footer.insertAdjacentElement('beforebegin',block);else doc.appendChild(block);
|
||
}
|
||
document.addEventListener('click',e=>{
|
||
const b=e.target.closest('button');if(!b)return;
|
||
if(b.textContent.trim()==='Бланк заказа') setTimeout(injectRouteIntoPrint,40);
|
||
},true);
|
||
const previewModal=q('#preview');
|
||
if(previewModal){
|
||
new MutationObserver(()=>{
|
||
if(q('#preview .sun-doc-map'))setTimeout(injectRouteIntoPrint,0);
|
||
}).observe(previewModal,{childList:true,subtree:true});
|
||
}
|
||
|
||
window.SunDeliveryRoute={originAddress:ORIGIN_ADDRESS,buildDraft:ensureDraftRoute,buildOrder:ensureOrderRoute,routeMedia,yandexRouteUrl};
|
||
renderDraftRoute();
|
||
})();
|
||
</script>
|
||
|
||
|
||
<script id="sun-final-cleanup-v6">
|
||
(()=>{
|
||
const ROUTE_FIX_KEY='sunRouteUnifiedFixV3';
|
||
try{
|
||
if(localStorage.getItem(ROUTE_FIX_KEY)!=='1'){
|
||
let list=[];try{list=JSON.parse(localStorage.getItem('sunOrders')||'[]')||[]}catch(_){list=[]}
|
||
let changed=false;
|
||
if(Array.isArray(list)) list.forEach(o=>{if(o&&('routePreview' in o||'geo' in o)){delete o.routePreview;delete o.geo;changed=true;}});
|
||
if(changed)localStorage.setItem('sunOrders',JSON.stringify(list));
|
||
localStorage.removeItem('sunStaticMapGeocodeCacheV2');
|
||
localStorage.setItem(ROUTE_FIX_KEY,'1');
|
||
try{if(typeof orders!=='undefined'&&Array.isArray(orders)){orders.forEach(o=>{if(o){delete o.routePreview;delete o.geo;}});}}catch(_){}
|
||
try{if(typeof draft!=='undefined'&&draft){delete draft.routePreview;delete draft.geo;}}catch(_){}
|
||
}
|
||
}catch(_){}
|
||
const removeProduction=()=>{
|
||
document.querySelectorAll('header nav button').forEach(b=>{if((b.dataset.navLabel||b.textContent||'').trim()==='Производство')b.remove();});
|
||
document.getElementById('production')?.remove();
|
||
};
|
||
removeProduction();
|
||
try{localStorage.removeItem('sunRouteBaseV1');localStorage.removeItem('sunRouteOriginTelezi20V1');}catch(_){}
|
||
setTimeout(removeProduction,0);setTimeout(removeProduction,250);
|
||
})();
|
||
</script>
|
||
|
||
<style id="sun-print-clean-layout-v1">
|
||
/* Clean order actions: stable, equal-sized buttons. */
|
||
#new .actions{
|
||
display:grid!important;
|
||
grid-template-columns:repeat(2,minmax(0,1fr))!important;
|
||
gap:8px!important;
|
||
align-items:stretch!important;
|
||
}
|
||
#new .actions>button{
|
||
width:100%!important;
|
||
min-width:0!important;
|
||
min-height:42px!important;
|
||
margin:0!important;
|
||
display:flex!important;
|
||
align-items:center!important;
|
||
justify-content:center!important;
|
||
text-align:center!important;
|
||
line-height:1.15!important;
|
||
}
|
||
#new .actions>#deleteOrderBtn{grid-column:1/-1!important;}
|
||
#dashboard .dash3-row{grid-template-columns:1fr!important;}
|
||
/* Route/map should not occupy space inside New order -> Details. */
|
||
#details #orderGeoBox,#details #sunRouteBox,.sun-route-box{display:none!important;}
|
||
/* One calm preview size for order form, receipt and preparations. */
|
||
#preview{
|
||
padding:22px!important;
|
||
align-items:flex-start!important;
|
||
overflow:auto!important;
|
||
}
|
||
#preview .dialog{
|
||
width:min(980px,calc(100vw - 44px))!important;
|
||
max-width:980px!important;
|
||
min-height:min(680px,calc(100vh - 44px))!important;
|
||
max-height:calc(100vh - 44px)!important;
|
||
padding:24px!important;
|
||
border-radius:16px!important;
|
||
overflow:auto!important;
|
||
margin:0 auto!important;
|
||
}
|
||
#preview .sun-doc,
|
||
#preview .sun-receipt,
|
||
#preview .prep-doc,
|
||
#preview .sun-custom-doc{
|
||
width:100%!important;
|
||
max-width:900px!important;
|
||
margin-left:auto!important;
|
||
margin-right:auto!important;
|
||
}
|
||
#preview .sun-receipt{max-width:900px!important;}
|
||
#preview .sun-doc-actions,
|
||
#preview .prep-doc-actions,
|
||
#preview .sun-custom-actions{
|
||
position:sticky!important;
|
||
bottom:-24px!important;
|
||
z-index:20!important;
|
||
background:rgba(255,255,255,.97)!important;
|
||
border-top:1px solid #eceef1!important;
|
||
padding:12px 0 2px!important;
|
||
margin-top:20px!important;
|
||
}
|
||
@media(max-width:620px){
|
||
#new .actions{grid-template-columns:1fr!important;}
|
||
#new .actions>#deleteOrderBtn{grid-column:auto!important;}
|
||
#preview{padding:10px!important;}
|
||
#preview .dialog{
|
||
width:calc(100vw - 20px)!important;
|
||
min-height:calc(100vh - 20px)!important;
|
||
max-height:calc(100vh - 20px)!important;
|
||
padding:15px!important;
|
||
border-radius:12px!important;
|
||
}
|
||
}
|
||
@media print{
|
||
#preview{padding:0!important;overflow:visible!important;}
|
||
#preview .dialog{width:100%!important;max-width:none!important;min-height:0!important;max-height:none!important;padding:8mm!important;border-radius:0!important;overflow:visible!important;}
|
||
#preview .sun-doc,#preview .sun-receipt,#preview .prep-doc,#preview .sun-custom-doc{max-width:none!important;}
|
||
}
|
||
</style>
|
||
<script id="sun-print-clean-behavior-v1">
|
||
(() => {
|
||
// Keep the main order buttons in a stable visual order.
|
||
const orderActions=[...document.querySelectorAll('#new .actions')].find(a=>[...a.querySelectorAll('button')].some(b=>b.textContent.trim()==='Сохранить'));
|
||
const deleteButton=document.getElementById('deleteOrderBtn');
|
||
if(orderActions&&deleteButton) orderActions.appendChild(deleteButton);
|
||
|
||
// Defensive cleanup: receipts must never inherit a delivery map/route from another preview.
|
||
const observer = new MutationObserver(() => {
|
||
const doc=document.querySelector('#preview .sun-doc');
|
||
if(!doc)return;
|
||
const title=doc.querySelector('.sun-doc-title h2')?.textContent||'';
|
||
if(/Товарный чек/i.test(title)) doc.querySelectorAll('.sun-doc-map,.sun-doc-route').forEach(x=>x.remove());
|
||
});
|
||
const preview=document.getElementById('preview');
|
||
if(preview) observer.observe(preview,{childList:true,subtree:true});
|
||
})();
|
||
</script>
|
||
|
||
<style id="sun-orders-card-view-style">
|
||
#orders .sun-orders-viewbar{display:flex;align-items:center;justify-content:space-between;gap:14px;margin:0 0 14px}
|
||
#orders .sun-orders-viewbar h1{margin:0}
|
||
#orders .sun-orders-view-switch{display:inline-flex;gap:3px;padding:4px;background:#eef0f5;border:1px solid #e0e3ea;border-radius:12px}
|
||
#orders .sun-orders-view-switch button{border:0;background:transparent;color:#72798a;border-radius:9px;padding:8px 11px;font-size:12px;font-weight:700;display:inline-flex;align-items:center;gap:6px}
|
||
#orders .sun-orders-view-switch button.on{background:#fff;color:#312a65;box-shadow:0 2px 7px rgba(35,31,71,.08)}
|
||
#orders .sun-orders-view-switch svg{width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round}
|
||
#orders.sun-orders-cards .head,#orders.sun-orders-cards #ordersList{display:none!important}
|
||
#orders:not(.sun-orders-cards) #sunOrderCards{display:none!important}
|
||
#sunOrderCards{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}
|
||
.sun-order-card{position:relative;background:#fff;border:1px solid #e4e6ec;border-radius:16px;padding:14px;box-shadow:0 2px 8px rgba(28,33,55,.035);transition:.16s ease;cursor:pointer;min-width:0}
|
||
.sun-order-card:hover{border-color:#d8d4e9;box-shadow:0 7px 22px rgba(35,31,71,.08);transform:translateY(-1px)}
|
||
.sun-order-card.sun-order-open-flash{outline:2px solid rgba(111,91,208,.25)}
|
||
.sun-order-card-top{display:grid;grid-template-columns:78px minmax(0,1fr);gap:12px;align-items:start}
|
||
.sun-order-card-date{border-radius:13px;background:linear-gradient(145deg,#f1eff9,#f7f7fb);border:1px solid #e4e0f0;text-align:center;padding:9px 6px;color:#3d356e}
|
||
.sun-order-card-date strong{display:block;font-size:25px;line-height:1;font-weight:850;letter-spacing:-.04em}
|
||
.sun-order-card-date span{display:block;margin-top:5px;font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.06em}
|
||
.sun-order-card-date small{display:block;margin-top:5px;color:#747b8b;font-size:11px;font-weight:700}
|
||
.sun-order-card-title{min-width:0;padding-top:2px}
|
||
.sun-order-card-title .sun-order-number{font-size:10px;color:#969cab;font-weight:800;letter-spacing:.07em;text-transform:uppercase;margin-bottom:4px}
|
||
.sun-order-card-title h3{margin:0;color:#27314b;font-size:15px;line-height:1.28;overflow-wrap:anywhere}
|
||
.sun-order-card-client{margin-top:7px;color:#626b7a;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.sun-order-card-address{margin-top:10px;padding:9px 10px;background:#f8f9fb;border-radius:10px;color:#697180;font-size:11.5px;min-height:38px;line-height:1.35;overflow-wrap:anywhere}
|
||
.sun-order-card-bottom{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:11px;padding-top:11px;border-top:1px solid #eceef2}
|
||
.sun-order-card-total strong{display:block;color:#27314b;font-size:16px}.sun-order-card-total small{display:block;color:#969cab;font-size:10px;margin-top:2px}
|
||
.sun-order-card-actions{display:flex;gap:6px}.sun-order-card-actions button{padding:6px 8px;font-size:11px}
|
||
.sun-order-card-status{margin-top:9px;width:100%;border:1px solid #e1e4e9;background:#fff;border-radius:9px;padding:7px 9px;font-size:11px;color:#4f5868}
|
||
.sun-order-card-empty{grid-column:1/-1;padding:34px 18px;text-align:center;color:#8c93a0;border:1px dashed #d9dde5;border-radius:14px;background:#fafbfc}
|
||
@media(max-width:1180px){#sunOrderCards{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||
@media(max-width:720px){#orders .sun-orders-viewbar{align-items:flex-start;flex-direction:column}#sunOrderCards{grid-template-columns:1fr}.sun-order-card-top{grid-template-columns:72px minmax(0,1fr)}}
|
||
</style>
|
||
<script id="sun-orders-card-view-script">
|
||
(()=>{
|
||
const section=document.getElementById('orders');
|
||
const list=document.getElementById('ordersList');
|
||
if(!section||!list||document.getElementById('sunOrderCards')) return;
|
||
const h1=section.querySelector(':scope > h1');
|
||
const bar=document.createElement('div'); bar.className='sun-orders-viewbar';
|
||
const title=document.createElement('h1'); title.textContent='Заказы';
|
||
const toggle=document.createElement('div'); toggle.className='sun-orders-view-switch'; toggle.setAttribute('aria-label','Вид заказов');
|
||
toggle.innerHTML=`
|
||
<button type="button" data-orders-view="list" title="Список"><svg viewBox="0 0 24 24"><path d="M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01"/></svg>Список</button>
|
||
<button type="button" data-orders-view="cards" title="Карточки"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>Карточки</button>`;
|
||
bar.append(title,toggle);
|
||
if(h1) h1.replaceWith(bar); else section.prepend(bar);
|
||
const cards=document.createElement('div'); cards.id='sunOrderCards';
|
||
list.insertAdjacentElement('afterend',cards);
|
||
|
||
const esc=window.SunSafe.escapeHTML;
|
||
const fmt=v=>typeof window.money==='function'?window.money(v):`${Math.round(Number(v||0)).toLocaleString('ru-RU')} ₽`;
|
||
const total=o=>typeof window.orderTotalValue==='function'?Number(window.orderTotalValue(o)||0):Number(o?.total||0);
|
||
const monthName=d=>d.toLocaleDateString('ru-RU',{month:'short'}).replace('.','');
|
||
const dateParts=raw=>{const d=raw?new Date(`${raw}T12:00:00`):null;if(!d||Number.isNaN(d.getTime()))return{day:'—',month:'',weekday:'Дата'};return{day:String(d.getDate()).padStart(2,'0'),month:monthName(d),weekday:d.toLocaleDateString('ru-RU',{weekday:'short'}).replace('.','')}};
|
||
function currentOrders(){try{return Array.isArray(orders)?orders:JSON.parse(localStorage.getItem('sunOrders')||'[]')}catch(_){return[]}}
|
||
function renderCards(){
|
||
const arr=currentOrders();
|
||
cards.innerHTML=arr.length?arr.map(o=>{
|
||
const d=dateParts(o.date), phone=o.phone?` · ${esc(o.phone)}`:'';
|
||
const statuses=['Новый','В работе','Собран','Передан курьеру','Отдан заказчику','Отменён'];
|
||
return `<article class="sun-order-card" data-order-id="${esc(o.id)}" tabindex="0" title="Двойной щелчок — открыть заказ">
|
||
<div class="sun-order-card-top">
|
||
<div class="sun-order-card-date"><strong>${esc(d.day)}</strong><span>${esc(d.month)}</span><small>${esc(o.time||d.weekday)}</small></div>
|
||
<div class="sun-order-card-title"><div class="sun-order-number">Заказ № ${esc(o.id)}</div><h3>${esc(o.event||'Заказ')}</h3><div class="sun-order-card-client">${esc(o.contact||'Клиент не указан')}${phone}</div></div>
|
||
</div>
|
||
<div class="sun-order-card-address">${esc(o.address||'Адрес не указан')}</div>
|
||
<select class="sun-order-card-status" data-card-status="${esc(o.id)}" aria-label="Статус заказа">${statuses.map(s=>`<option ${s===(o.status||'Новый')?'selected':''}>${s}</option>`).join('')}</select>
|
||
<div class="sun-order-card-bottom"><div class="sun-order-card-total"><strong>${esc(fmt(total(o)))}</strong><small>сумма заказа</small></div><div class="sun-order-card-actions"><button class="mini" type="button" data-card-open="${esc(o.id)}">Открыть</button><button class="mini danger" type="button" data-card-delete="${esc(o.id)}">Удалить</button></div></div>
|
||
</article>`;
|
||
}).join(''):'<div class="sun-order-card-empty">Сохранённых заказов пока нет.</div>';
|
||
}
|
||
function setView(view){
|
||
const isCards=view==='cards'; section.classList.toggle('sun-orders-cards',isCards);
|
||
toggle.querySelectorAll('button').forEach(b=>b.classList.toggle('on',b.dataset.ordersView===view));
|
||
try{localStorage.setItem('sunOrdersView',view)}catch(_){}
|
||
if(isCards)renderCards();
|
||
}
|
||
toggle.addEventListener('click',e=>{const b=e.target.closest('[data-orders-view]');if(b)setView(b.dataset.ordersView)});
|
||
cards.addEventListener('change',e=>{const s=e.target.closest('[data-card-status]');if(!s)return;if(typeof window.status==='function')window.status(Number(s.dataset.cardStatus),s.value);setTimeout(renderCards,0)});
|
||
cards.addEventListener('click',e=>{
|
||
const open=e.target.closest('[data-card-open]'); if(open){e.stopPropagation();window.openOrder?.(Number(open.dataset.cardOpen));return;}
|
||
const del=e.target.closest('[data-card-delete]'); if(del){e.stopPropagation();window.deleteOrderById?.(Number(del.dataset.cardDelete));setTimeout(renderCards,0);return;}
|
||
});
|
||
cards.addEventListener('dblclick',e=>{if(e.target.closest('button,select,input,textarea,a,label'))return;const card=e.target.closest('.sun-order-card');if(!card)return;card.classList.add('sun-order-open-flash');setTimeout(()=>window.openOrder?.(Number(card.dataset.orderId)),70)});
|
||
cards.addEventListener('keydown',e=>{if(e.key!=='Enter'||e.target.closest('button,select,input,textarea,a,label'))return;const card=e.target.closest('.sun-order-card');if(card)window.openOrder?.(Number(card.dataset.orderId))});
|
||
|
||
const prior=window.renderOrders;
|
||
if(typeof prior==='function') window.renderOrders=()=>{prior();if(section.classList.contains('sun-orders-cards'))renderCards();};
|
||
window.sunRenderOrderCards=renderCards;
|
||
let saved='list';try{saved=localStorage.getItem('sunOrdersView')||'list'}catch(_){}
|
||
setView(saved==='cards'?'cards':'list');
|
||
const hint=section.querySelector(':scope > .hint');if(hint)hint.textContent='Выберите удобный вид. Двойной щелчок по строке или карточке открывает заказ.';
|
||
})();
|
||
</script>
|
||
|
||
|
||
<style id="sun-orders-compact-toolbar-style">
|
||
/* Compact order cards: date remains the strongest visual anchor. */
|
||
#sunOrderCards{grid-template-columns:repeat(auto-fill,minmax(215px,1fr));gap:9px!important}
|
||
.sun-order-card{padding:10px!important;border-radius:13px!important;border-color:#e7e7ef!important;background:linear-gradient(145deg,#ffffff 0%,#f8f7fd 100%)!important;box-shadow:0 4px 14px rgba(37,42,72,.045)!important}
|
||
.sun-order-card:nth-child(4n+2){background:linear-gradient(145deg,#ffffff 0%,#f4f9fd 100%)!important}
|
||
.sun-order-card:nth-child(4n+3){background:linear-gradient(145deg,#ffffff 0%,#f4faf7 100%)!important}
|
||
.sun-order-card:nth-child(4n+4){background:linear-gradient(145deg,#ffffff 0%,#fff9f0 100%)!important}
|
||
.sun-order-card:hover{border-color:#d9d6e9!important;box-shadow:0 8px 22px rgba(43,39,83,.085)!important}
|
||
.sun-order-card-top{grid-template-columns:58px minmax(0,1fr)!important;gap:9px!important}
|
||
.sun-order-card-date{border-radius:11px!important;padding:7px 4px!important;background:rgba(255,255,255,.78)!important;box-shadow:inset 0 0 0 1px rgba(111,91,208,.08)}
|
||
.sun-order-card-date strong{font-size:21px!important}.sun-order-card-date span{font-size:9.5px!important;margin-top:3px!important}.sun-order-card-date small{font-size:9.5px!important;margin-top:3px!important}
|
||
.sun-order-card-title{padding-top:0!important}.sun-order-card-title .sun-order-number{font-size:8.5px!important;margin-bottom:2px!important}.sun-order-card-title h3{font-size:13px!important;line-height:1.2!important;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.sun-order-card-client{margin-top:4px!important;font-size:10.5px!important}
|
||
.sun-order-card-address{margin-top:7px!important;padding:6px 8px!important;border-radius:8px!important;font-size:10px!important;min-height:29px!important;line-height:1.25!important;background:rgba(255,255,255,.62)!important;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||
.sun-order-card-status{margin-top:6px!important;border-radius:8px!important;padding:5px 7px!important;font-size:10px!important;background:rgba(255,255,255,.78)!important}
|
||
.sun-order-card-bottom{gap:7px!important;margin-top:7px!important;padding-top:7px!important}.sun-order-card-total strong{font-size:13px!important}.sun-order-card-total small{font-size:8.5px!important;margin-top:1px!important}.sun-order-card-actions{gap:4px!important}.sun-order-card-actions button{padding:4px 6px!important;font-size:9.5px!important;border-radius:7px!important}
|
||
|
||
/* Move catalog categories into the top catalog toolbar and free the former middle column. */
|
||
#new .work{grid-template-columns:minmax(0,1.72fr) minmax(350px,.88fr)!important;gap:12px!important}
|
||
#new .catalog{min-width:0}
|
||
#new .sun-catalog-top-toolbar{display:flex;align-items:center;justify-content:space-between;gap:10px;margin:10px 0 5px;padding:7px;background:#f7f8fb;border:1px solid #eaebf0;border-radius:13px;min-width:0}
|
||
#new .sun-catalog-top-toolbar .cats{display:flex!important;align-items:center;gap:5px;flex-wrap:wrap;flex:1 1 auto;padding:0!important;margin:0!important;background:transparent!important;border-radius:0!important;min-width:0}
|
||
#new .sun-catalog-top-toolbar .cats p{display:none!important}
|
||
#new .sun-catalog-top-toolbar .cats button{width:auto!important;min-width:0!important;margin:0!important;padding:7px 10px!important;border-radius:9px!important;font-size:11px!important;line-height:1!important;white-space:nowrap;box-shadow:none!important;transform:none!important;outline-width:1px!important;outline-offset:1px!important}
|
||
#new .sun-catalog-top-toolbar .sun-catalog-view-switch{flex:0 0 auto;margin:0!important;background:#eceef4!important;border-radius:10px!important;padding:3px!important}
|
||
#new .sun-catalog-top-toolbar .sun-catalog-view-switch button{height:30px!important;padding:0 8px!important;font-size:10.5px!important;border-radius:8px!important}
|
||
#new .catalog-head{gap:10px}
|
||
#new .catalog-head .sun-catalog-actions{margin-left:auto}
|
||
#new .catalog-head .sun-catalog-actions>.sun-catalog-view-switch{display:none!important}
|
||
#new .tiles{margin-top:8px!important}
|
||
|
||
@media(max-width:1180px){
|
||
#new .work{grid-template-columns:1fr!important}
|
||
#new .work>.catalog,#new .work>section.card{grid-column:1!important}
|
||
#new .sun-catalog-top-toolbar{align-items:flex-start;flex-direction:column}
|
||
#new .sun-catalog-top-toolbar .sun-catalog-view-switch{max-width:100%;overflow:auto}
|
||
}
|
||
@media(max-width:720px){
|
||
#sunOrderCards{grid-template-columns:repeat(auto-fill,minmax(190px,1fr))!important}
|
||
.sun-order-card-top{grid-template-columns:54px minmax(0,1fr)!important}
|
||
#new .sun-catalog-top-toolbar .cats{flex-wrap:wrap!important;overflow:visible;width:100%;padding-bottom:2px!important}
|
||
#new .sun-catalog-top-toolbar .cats button{flex:0 0 auto!important}
|
||
}
|
||
</style>
|
||
<script id="sun-orders-compact-toolbar-script">
|
||
(()=>{
|
||
const catalog=document.querySelector('#new .catalog');
|
||
const head=catalog?.querySelector('.catalog-head');
|
||
const cats=document.querySelector('#new .work > .cats') || document.querySelector('#new .cats');
|
||
const switcher=document.querySelector('#new .sun-catalog-view-switch');
|
||
if(catalog && head && cats && switcher && !catalog.querySelector('.sun-catalog-top-toolbar')){
|
||
const toolbar=document.createElement('div');
|
||
toolbar.className='sun-catalog-top-toolbar';
|
||
head.insertAdjacentElement('afterend',toolbar);
|
||
toolbar.appendChild(cats);
|
||
toolbar.appendChild(switcher);
|
||
}
|
||
})();
|
||
</script>
|
||
|
||
<!-- ===== ORDER VIEWER + DASHBOARD V4 ===== -->
|
||
<style id="sun-order-viewer-dashboard-v4-style">
|
||
/* Keep operational/event blocks out of the order details screen. */
|
||
#details .event-card-section,#details .staff-assignment,#openEventCardBtn{display:none!important}
|
||
|
||
/* Dedicated read-only order viewer. */
|
||
#sun-order-viewer{z-index:12000}
|
||
#sun-order-viewer .dialog{width:min(980px,96vw);max-height:92vh;padding:0;border-radius:20px;overflow:auto;background:#f6f7fb;box-shadow:0 28px 80px rgba(24,29,55,.28)}
|
||
.sun-order-view-head{position:sticky;top:0;z-index:2;display:flex;justify-content:space-between;align-items:flex-start;gap:14px;padding:18px 20px;background:rgba(255,255,255,.96);backdrop-filter:blur(14px);border-bottom:1px solid #e8eaf0}
|
||
.sun-order-view-title small{display:block;color:#8d94a3;font-size:11px;font-weight:800;letter-spacing:.08em;text-transform:uppercase;margin-bottom:5px}.sun-order-view-title h2{margin:0;color:#27314b;font-size:24px;line-height:1.15}.sun-order-view-title p{margin:6px 0 0;color:#71798a;font-size:13px}
|
||
.sun-order-view-close{width:38px;height:38px;border:0;border-radius:11px;background:#f1f2f6;color:#4b5363;font-size:24px;line-height:1;display:grid;place-items:center}
|
||
.sun-order-view-body{padding:16px 18px 20px}.sun-order-view-hero{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(260px,.65fr);gap:12px}.sun-order-view-card{background:#fff;border:1px solid #e5e7ed;border-radius:16px;padding:15px;box-shadow:0 5px 20px rgba(32,38,67,.035)}
|
||
.sun-order-view-main{background:linear-gradient(145deg,#2d2855,#3b3970 58%,#2b4a68);color:#fff;border:0;position:relative;overflow:hidden}.sun-order-view-main:after{content:"";position:absolute;width:220px;height:220px;border-radius:50%;right:-90px;top:-120px;background:rgba(255,255,255,.07)}
|
||
.sun-order-view-main .sun-order-view-date{font-size:13px;color:#d9d6ec;position:relative;z-index:1}.sun-order-view-main h3{font-size:25px;margin:9px 0 10px;color:#fff;position:relative;z-index:1}.sun-order-view-main p{margin:5px 0;color:#e3e1f1;line-height:1.4;position:relative;z-index:1}.sun-order-view-status{display:inline-flex;margin-top:10px;padding:5px 9px;border-radius:999px;background:rgba(255,255,255,.13);font-size:11px;font-weight:800;position:relative;z-index:1}
|
||
.sun-order-view-money{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}.sun-order-view-money div{padding:11px;border-radius:12px;background:#f7f8fb}.sun-order-view-money small{display:block;color:#8a91a0;font-size:10px}.sun-order-view-money b{display:block;color:#29324b;font-size:16px;margin-top:4px}.sun-order-view-money .accent{background:#f3effd}.sun-order-view-money .accent b{color:#5b48a6}
|
||
.sun-order-view-info{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:12px}.sun-order-view-info h3,.sun-order-view-items h3{margin:0 0 10px;color:#30394f;font-size:14px}.sun-order-view-list{display:grid;gap:8px}.sun-order-view-list div{display:grid;grid-template-columns:95px 1fr;gap:8px;font-size:12px;line-height:1.35}.sun-order-view-list span{color:#9399a6}.sun-order-view-list b{color:#4a5260;font-weight:700;overflow-wrap:anywhere}.sun-order-view-note{white-space:pre-wrap;color:#59616f;font-size:12px;line-height:1.45;background:#fafbfc;border-radius:11px;padding:10px;min-height:42px}
|
||
.sun-order-view-items{margin-top:12px}.sun-order-view-table-wrap{overflow:auto;border:1px solid #e7e9ee;border-radius:12px}.sun-order-view-table{width:100%;border-collapse:collapse;min-width:560px}.sun-order-view-table th,.sun-order-view-table td{padding:9px 10px;border-bottom:1px solid #eceef2;text-align:left;font-size:12px}.sun-order-view-table th{color:#858c9a;background:#f8f9fb;font-size:10px;text-transform:uppercase;letter-spacing:.04em}.sun-order-view-table td{color:#454d5c}.sun-order-view-table tr:last-child td{border-bottom:0}.sun-order-view-table .num{text-align:right;white-space:nowrap}
|
||
.sun-order-view-actions{position:sticky;bottom:0;z-index:2;display:flex;gap:7px;flex-wrap:wrap;padding:13px 18px;background:rgba(255,255,255,.97);backdrop-filter:blur(12px);border-top:1px solid #e7e9ee}.sun-order-view-actions button{min-height:38px;border-radius:10px;padding:8px 12px}.sun-order-view-actions .spacer{flex:1}
|
||
|
||
/* Dashboard V4 */
|
||
#dashboard .dash4{max-width:1500px;margin:0 auto}.dash4-head{display:flex;justify-content:space-between;align-items:flex-start;gap:16px;flex-wrap:wrap;margin-bottom:12px}.dash4-head h1{margin:0;color:#27314b;font-size:27px}.dash4-head p{margin:5px 0 0;color:#7b8290}
|
||
.dash4-quick{display:grid;grid-template-columns:repeat(6,minmax(110px,1fr));gap:8px;margin-bottom:12px}.dash4-qbtn{display:flex;align-items:center;gap:9px;min-height:54px;padding:9px 11px;border:1px solid #e4e6ec;border-radius:14px;background:linear-gradient(145deg,#fff,#f7f6fc);color:#394159;text-align:left;box-shadow:0 4px 16px rgba(35,40,68,.035)}.dash4-qbtn:hover{border-color:#d8d2ee;box-shadow:0 8px 22px rgba(45,40,86,.07);transform:translateY(-1px)}.dash4-qicon{width:31px;height:31px;border-radius:10px;display:grid;place-items:center;background:#f0edfb;color:#6553b6;flex:0 0 auto}.dash4-qicon svg{width:17px;height:17px;stroke:currentColor;fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}.dash4-qbtn:nth-child(2n) .dash4-qicon{background:#edf6fb;color:#4f87a8}.dash4-qbtn:nth-child(3n) .dash4-qicon{background:#eef8f2;color:#4e8d69}.dash4-qbtn b{font-size:12px}.dash4-qbtn small{display:block;color:#9298a4;font-size:9.5px;margin-top:2px}
|
||
.dash4-kpis{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin-bottom:12px}.dash4-kpi{background:#fff;border:1px solid #e6e8ed;border-radius:13px;padding:10px 12px;display:flex;justify-content:space-between;align-items:center;gap:8px}.dash4-kpi small{color:#9198a5;font-size:10px}.dash4-kpi b{display:block;color:#29324c;font-size:19px;margin-top:3px}.dash4-kpi i{font-style:normal;width:31px;height:31px;border-radius:10px;display:grid;place-items:center;background:#f4f2fb;color:#6655b5;font-weight:900}
|
||
.dash4-main{display:grid;grid-template-columns:minmax(0,1.12fr) minmax(360px,.88fr);gap:12px}.dash4-panel{background:#fff;border:1px solid #e4e6eb;border-radius:16px;overflow:hidden;box-shadow:0 6px 22px rgba(34,39,66,.04)}.dash4-panel-head{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:11px 13px;border-bottom:1px solid #eceef2}.dash4-panel-head h2{margin:0;color:#30394f;font-size:14px}.dash4-panel-head small{color:#9298a4;font-size:10px}
|
||
.dash4-next{padding:15px;background:linear-gradient(145deg,#29254f,#403875 55%,#2d5067);color:#fff;min-height:175px;position:relative;overflow:hidden}.dash4-next:after{content:"";position:absolute;width:190px;height:190px;border-radius:50%;right:-70px;top:-95px;background:rgba(255,255,255,.07)}.dash4-next-top{display:flex;justify-content:space-between;gap:8px;position:relative;z-index:1}.dash4-next-kicker{font-size:9px;text-transform:uppercase;letter-spacing:.13em;color:#c9c5e2}.dash4-next h2{font-size:20px;color:#fff;margin:8px 0 5px}.dash4-next p{margin:3px 0;color:#dedbec;font-size:12px;line-height:1.35}.dash4-next-status{font-size:9px;font-weight:800;padding:4px 7px;border-radius:999px;background:rgba(255,255,255,.12);height:max-content}.dash4-next-foot{display:flex;justify-content:space-between;align-items:flex-end;gap:9px;margin-top:16px;position:relative;z-index:1}.dash4-next-foot b{font-size:18px;color:#f0d280}.dash4-next-foot button{border:1px solid rgba(255,255,255,.25);background:rgba(255,255,255,.1);color:#fff;border-radius:9px;padding:7px 10px;font-weight:700;font-size:11px}
|
||
.dash4-orders{padding:0 12px}.dash4-order{display:grid;grid-template-columns:62px minmax(0,1fr) auto;gap:8px;align-items:center;padding:9px 0;border-bottom:1px solid #eef0f3}.dash4-order:last-child{border-bottom:0}.dash4-order-date{font-weight:850;color:#493d86;font-size:12px}.dash4-order-date small{display:block;color:#969ca8;font-size:9px;margin-top:2px}.dash4-order-main{min-width:0}.dash4-order-main b{display:block;color:#343c50;font-size:11.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dash4-order-main span{display:block;color:#8a919f;font-size:9.5px;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dash4-order button{padding:5px 7px;font-size:9.5px}
|
||
.dash4-bottom{display:grid;grid-template-columns:minmax(260px,.62fr) minmax(0,1.38fr);gap:12px;margin-top:12px}.dash4-load{padding:11px 13px}.dash4-load-bars{height:76px;display:flex;align-items:flex-end;gap:4px;border-bottom:1px solid #eceef2;padding-top:4px}.dash4-load-col{flex:1;min-width:0;height:100%;display:flex;flex-direction:column;justify-content:flex-end;align-items:center;gap:3px}.dash4-load-bar{width:100%;min-height:2px;border-radius:4px 4px 1px 1px;background:linear-gradient(180deg,#8070d1,#5769a6)}.dash4-load-col small{font-size:7.5px;color:#9ba1ac}.dash4-load-note{display:flex;justify-content:space-between;gap:8px;margin-top:7px;font-size:9.5px;color:#8a919e}.dash4-load-note b{color:#4b5363}
|
||
.dash4-leaders{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:7px;padding:10px}.dash4-leader{display:grid;grid-template-columns:36px minmax(0,1fr);gap:7px;align-items:center;padding:7px;border:1px solid #eceef2;border-radius:11px;background:#fafbfc;min-width:0}.dash4-leader img{width:36px;height:36px;border-radius:9px;object-fit:cover;background:#eef0f3}.dash4-leader b{display:block;color:#3c4558;font-size:9.5px;line-height:1.2;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.dash4-leader span{display:block;color:#9298a4;font-size:8.5px;margin-top:3px}.dash4-empty{padding:20px;color:#9298a4;text-align:center;font-size:11px}
|
||
@media(max-width:1100px){.dash4-quick{grid-template-columns:repeat(3,1fr)}.dash4-main,.dash4-bottom{grid-template-columns:1fr}.dash4-leaders{grid-template-columns:repeat(3,1fr)}}
|
||
@media(max-width:700px){#sun-order-viewer .dialog{width:96vw}.sun-order-view-hero,.sun-order-view-info{grid-template-columns:1fr}.sun-order-view-money{grid-template-columns:1fr 1fr}.dash4-quick{grid-template-columns:repeat(2,1fr)}.dash4-kpis{grid-template-columns:1fr 1fr}.dash4-leaders{grid-template-columns:repeat(2,1fr)}.dash4-order{grid-template-columns:55px minmax(0,1fr)}.dash4-order>button{grid-column:2;justify-self:end}}
|
||
</style>
|
||
<script id="sun-order-viewer-dashboard-v4-script">
|
||
(()=>{
|
||
const q=s=>document.querySelector(s), qa=s=>[...document.querySelectorAll(s)];
|
||
const esc=window.SunSafe.escapeHTML;
|
||
const fmt=v=>typeof window.money==='function'?window.money(v):`${Math.round(Number(v||0)).toLocaleString('ru-RU')} ₽`;
|
||
const getOrders=()=>{try{return Array.isArray(orders)?orders:JSON.parse(localStorage.getItem('sunOrders')||'[]')}catch(_){return[]}};
|
||
const getBoxes=()=>{try{return Array.isArray(boxes)?boxes:JSON.parse(localStorage.getItem('sunBoxes')||'[]')}catch(_){return[]}};
|
||
const total=o=>typeof window.orderTotalValue==='function'?Number(window.orderTotalValue(o)||0):Number(o?.total||0);
|
||
const paid=o=>Math.max(0,Number(o?.prepayment||0));
|
||
const dateLong=raw=>{if(!raw)return'Дата не указана';const d=new Date(`${raw}T12:00:00`);return Number.isNaN(d.getTime())?raw:d.toLocaleDateString('ru-RU',{weekday:'long',day:'numeric',month:'long',year:'numeric'})};
|
||
const dateShort=raw=>{if(!raw)return'—';const d=new Date(`${raw}T12:00:00`);return Number.isNaN(d.getTime())?raw:d.toLocaleDateString('ru-RU',{day:'2-digit',month:'2-digit'})};
|
||
const dayKey=()=>{const d=new Date();return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`};
|
||
const addDays=(iso,n)=>{const d=new Date(`${iso}T12:00:00`);d.setDate(d.getDate()+n);return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`};
|
||
|
||
/* Restore the order blank in the New order action bar, while keeping the event card hidden. */
|
||
function restoreOrderBlank(){
|
||
const actions=qa('#new .actions').find(a=>[...a.querySelectorAll('button')].some(b=>b.textContent.trim()==='Сохранить')); if(!actions)return;
|
||
const allButtons=qa('#new button');
|
||
const blank=allButtons.find(b=>b.textContent.trim()==='Бланк заказа');
|
||
if(blank){
|
||
blank.hidden=false;blank.removeAttribute('aria-hidden');blank.tabIndex=0;blank.style.removeProperty('display');
|
||
const receipt=[...actions.querySelectorAll('button')].find(b=>b.textContent.trim()==='Товарный чек');
|
||
if(receipt&&receipt.parentNode===actions){
|
||
if(blank.parentNode!==actions||blank.nextSibling!==receipt)window.SunSafe.insertBefore(actions,blank,receipt);
|
||
}else if(blank.parentNode!==actions){
|
||
actions.appendChild(blank);
|
||
}
|
||
}
|
||
const eventBtn=q('#openEventCardBtn');if(eventBtn)eventBtn.style.setProperty('display','none','important');
|
||
}
|
||
restoreOrderBlank(); setTimeout(restoreOrderBlank,100);
|
||
|
||
/* Read-only order viewer modal. */
|
||
let viewer=q('#sun-order-viewer');
|
||
if(!viewer){viewer=document.createElement('div');viewer.id='sun-order-viewer';viewer.className='modal';viewer.innerHTML='<div class="dialog"></div>';document.body.appendChild(viewer);}
|
||
function itemPrice(line,item){const direct=Number(line?.price);return Number.isFinite(direct)&&direct>=0?direct:Number(item?.price||0)}
|
||
function viewerHtml(o){
|
||
const boxList=getBoxes();
|
||
const t=total(o),p=paid(o),balance=Math.max(0,t-p);
|
||
const deliveryMeta=[['Подъезд',o.deliveryEntrance],['Этаж',o.deliveryFloor],['Квартира',o.deliveryApartment],['Офис',o.deliveryOffice]].filter(x=>String(x[1]??'').trim());
|
||
const rows=(o.lines||[]).map(line=>{const item=boxList.find(x=>String(x.id)===String(line.id));const price=itemPrice(line,item),qty=Math.max(0,Number(line.qty||0));return `<tr><td><b>${esc(item?.name||line.name||`Позиция ${line.id}`)}</b></td><td class="num">${esc(qty)}</td><td class="num">${esc(fmt(price))}</td><td class="num"><b>${esc(fmt(price*qty))}</b></td></tr>`}).join('');
|
||
return `<div class="sun-order-view-head"><div class="sun-order-view-title"><small>Просмотр заказа</small><h2>Заказ № ${esc(o.id)}</h2><p>${esc(o.event||'Заказ')}</p></div><button class="sun-order-view-close" type="button" data-viewer-close>×</button></div>
|
||
<div class="sun-order-view-body">
|
||
<div class="sun-order-view-hero">
|
||
<section class="sun-order-view-card sun-order-view-main"><div class="sun-order-view-date">${esc(dateLong(o.date))} · ${esc(o.time||'время не указано')}</div><h3>${esc(o.event||'Заказ')}</h3><p>${esc(o.contact||'Клиент не указан')}${o.phone?` · ${esc(o.phone)}`:''}</p><p>${esc(o.address||'Адрес доставки не указан')}</p><span class="sun-order-view-status">${esc(o.status||'Новый')}</span></section>
|
||
<section class="sun-order-view-card"><h3 style="margin:0 0 11px;color:#30394f;font-size:14px">Оплата</h3><div class="sun-order-view-money"><div><small>Сумма</small><b>${esc(fmt(t))}</b></div><div><small>Предоплата</small><b>${esc(fmt(p))}</b></div><div class="accent"><small>К оплате</small><b>${esc(fmt(balance))}</b></div></div></section>
|
||
</div>
|
||
<div class="sun-order-view-info">
|
||
<section class="sun-order-view-card"><h3>Клиент и доставка</h3><div class="sun-order-view-list"><div><span>Клиент</span><b>${esc(o.contact||'—')}</b></div><div><span>Телефон</span><b>${esc(o.phone||'—')}</b></div><div><span>Адрес</span><b>${esc(o.address||'—')}</b></div>${deliveryMeta.map(([k,v])=>`<div><span>${esc(k)}</span><b>${esc(v)}</b></div>`).join('')}</div></section>
|
||
<section class="sun-order-view-card"><h3>Примечание</h3><div class="sun-order-view-note">${esc(o.note||'Примечаний нет.')}</div></section>
|
||
</div>
|
||
<section class="sun-order-view-card sun-order-view-items"><h3>Состав заказа · ${(o.lines||[]).length} поз.</h3><div class="sun-order-view-table-wrap"><table class="sun-order-view-table"><thead><tr><th>Позиция</th><th class="num">Кол-во</th><th class="num">Цена</th><th class="num">Сумма</th></tr></thead><tbody>${rows||'<tr><td colspan="4">В заказе нет позиций.</td></tr>'}</tbody></table></div></section>
|
||
</div>
|
||
<div class="sun-order-view-actions"><button class="primary" type="button" data-viewer-action="edit" data-id="${esc(o.id)}">Редактировать</button><button class="outline" type="button" data-viewer-action="blank" data-id="${esc(o.id)}">Бланк заказа</button><button class="outline" type="button" data-viewer-action="receipt" data-id="${esc(o.id)}">Товарный чек</button><button class="outline" type="button" data-viewer-action="prep" data-id="${esc(o.id)}">Заготовки</button><span class="spacer"></span><button class="outline" type="button" data-viewer-close>Закрыть</button></div>`;
|
||
}
|
||
function openViewer(id){const o=getOrders().find(x=>String(x.id)===String(id));if(!o)return;viewer.querySelector('.dialog').innerHTML=viewerHtml(o);if(typeof window.modal==='function')window.modal('sun-order-viewer');else viewer.classList.add('on');}
|
||
function closeViewer(){if(typeof window.closeModal==='function')window.closeModal('sun-order-viewer');else viewer.classList.remove('on')}
|
||
window.sunOrderViewerOpen=openViewer;
|
||
viewer.addEventListener('click',e=>{
|
||
if(e.target.closest('[data-viewer-close]')){closeViewer();return;}
|
||
const b=e.target.closest('[data-viewer-action]');if(!b)return;const id=Number(b.dataset.id),action=b.dataset.viewerAction;closeViewer();
|
||
if(action==='edit'){window.openOrder?.(id);return;}
|
||
window.openOrder?.(id);
|
||
setTimeout(()=>{
|
||
if(action==='prep'){
|
||
if(typeof window.openPreview==='function'){window.__sunOrderViewerReturnId=id;window.__sunPrintPreviewReturn=false;window.openPreview();}
|
||
return;
|
||
}
|
||
const label=action==='blank'?'Бланк заказа':'Товарный чек';
|
||
const target=qa('#new .actions button').find(x=>x.textContent.trim()===label);
|
||
if(target){window.__sunOrderViewerReturnId=id;window.__sunPrintPreviewReturn=false;target.click();}
|
||
},120);
|
||
});
|
||
|
||
/* Double click in Orders now opens the viewer instead of navigating to New order. */
|
||
document.addEventListener('dblclick',e=>{
|
||
if(!e.target.closest('#orders')||e.target.closest('button,select,input,textarea,a,label'))return;
|
||
const card=e.target.closest('.sun-order-card');const row=e.target.closest('#ordersList .row');if(!card&&!row)return;
|
||
let id=card?.dataset.orderId;if(!id&&row){const m=String(row.querySelector('span')?.textContent||'').match(/(\d+)/);id=m?.[1];}
|
||
if(!id)return;e.preventDefault();e.stopImmediatePropagation();window.openOrder?.(Number(id));
|
||
},true);
|
||
|
||
/* Dashboard V4: compact, informative and action-focused. */
|
||
const dashboard=q('#dashboard'), nav=q('header nav'), dashBtn=nav?qa('header nav button').find(b=>b.textContent.trim()==='Главная'):null;
|
||
const icon=name=>({
|
||
plus:'<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>',
|
||
orders:'<svg viewBox="0 0 24 24"><path d="M7 4h10M7 9h10M7 14h10M7 19h7M4 4h.01M4 9h.01M4 14h.01M4 19h.01"/></svg>',
|
||
print:'<svg viewBox="0 0 24 24"><path d="M7 8V4h10v4M7 17H5a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2M7 14h10v6H7z"/></svg>',
|
||
calendar:'<svg viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M8 3v4M16 3v4M3 10h18"/></svg>',
|
||
clients:'<svg viewBox="0 0 24 24"><circle cx="9" cy="8" r="3"/><path d="M3 20c0-3 2.5-5 6-5s6 2 6 5M16 7a3 3 0 0 1 0 6M17 15c2.4.4 4 2 4 5"/></svg>',
|
||
route:'<svg viewBox="0 0 24 24"><circle cx="5" cy="6" r="2"/><circle cx="19" cy="18" r="2"/><path d="M7 6h4a3 3 0 0 1 3 3v6a3 3 0 0 0 3 3"/></svg>'
|
||
}[name]||'');
|
||
function go(label){if(typeof window.sunDashboardGo==='function')window.sunDashboardGo(label);else qa('header nav button').find(b=>b.textContent.trim()===label)?.click()}
|
||
window.sunDashboardV4Go=go;
|
||
function renderDash(){
|
||
if(!dashboard)return;const arr=getOrders(),today=dayKey();const active=arr.filter(o=>(o.status||'Новый')!=='Отменён');
|
||
const upcoming=active.filter(o=>`${o.date||'9999-99-99'}T${o.time||'23:59'}`>=`${today}T00:00`).sort((a,b)=>`${a.date||''}T${a.time||''}`.localeCompare(`${b.date||''}T${b.time||''}`));
|
||
const nearest=upcoming[0], following=upcoming.slice(1,6);const end7=addDays(today,6);const next7=active.filter(o=>o.date>=today&&o.date<=end7);
|
||
const start30=addDays(today,-29), last30=active.filter(o=>o.date>=start30&&o.date<=today);const clients30=new Set(last30.map(o=>String(o.phone||o.contact||'').trim().toLowerCase()).filter(Boolean)).size;const units30=last30.reduce((s,o)=>s+(o.lines||[]).reduce((n,l)=>n+Number(l.qty||0),0),0);const todayCount=active.filter(o=>o.date===today).length;
|
||
const days=Array.from({length:14},(_,i)=>{const date=addDays(today,i);return{date,count:active.filter(o=>o.date===date).length}});const maxLoad=Math.max(1,...days.map(x=>x.count));
|
||
const boxList=getBoxes(),leaderMap=new Map();last30.forEach(o=>(o.lines||[]).forEach(l=>{const item=boxList.find(x=>String(x.id)===String(l.id));if(!item)return;const qty=Number(l.qty||0),price=itemPrice(l,item),prev=leaderMap.get(String(item.id))||{item,qty:0,sales:0};prev.qty+=qty;prev.sales+=qty*price;leaderMap.set(String(item.id),prev)}));const leaders=[...leaderMap.values()].sort((a,b)=>b.sales-a.sales||b.qty-a.qty).slice(0,5);
|
||
const quick=[['plus','Новый заказ','Создать заказ'],['orders','Заказы','Все заказы'],['print','Печать','Документы'],['calendar','Календарь','Расписание'],['clients','Клиенты','База клиентов'],['route','Маршруты','Курьеры']];
|
||
const nearestHtml=nearest?`<div class="dash4-next"><div class="dash4-next-top"><div><div class="dash4-next-kicker">Ближайший заказ</div><h2>№${esc(nearest.id)} · ${esc(nearest.event||'Заказ')}</h2><p>${esc(dateLong(nearest.date))} · ${esc(nearest.time||'—')}</p><p>${esc(nearest.contact||'Клиент не указан')} · ${esc(nearest.address||'Адрес не указан')}</p></div><span class="dash4-next-status">${esc(nearest.status||'Новый')}</span></div><div class="dash4-next-foot"><b>${esc(fmt(total(nearest)))}</b><button type="button" onclick="sunOrderViewerOpen('${esc(nearest.id)}')">Посмотреть</button></div></div>`:`<div class="dash4-next"><div class="dash4-next-kicker">Ближайший заказ</div><h2>Заказов пока нет</h2><p>Ближайший активный заказ появится здесь автоматически.</p></div>`;
|
||
const followHtml=following.length?following.map(o=>`<div class="dash4-order"><div class="dash4-order-date">${esc(dateShort(o.date))}<small>${esc(o.time||'')}</small></div><div class="dash4-order-main"><b>№${esc(o.id)} · ${esc(o.event||'Заказ')}</b><span>${esc(o.contact||'Клиент')} · ${esc(o.address||'Без адреса')}</span></div><button class="mini" type="button" onclick="sunOrderViewerOpen('${esc(o.id)}')">Открыть</button></div>`).join(''):'<div class="dash4-empty">Следующих заказов пока нет.</div>';
|
||
const loadHtml=`<div class="dash4-load-bars">${days.map((d,i)=>`<div class="dash4-load-col" title="${esc(dateShort(d.date))}: ${d.count}"><div class="dash4-load-bar" style="height:${d.count?Math.max(8,d.count/maxLoad*100):3}%"></div><small>${i%2===0?esc(dateShort(d.date)):''}</small></div>`).join('')}</div><div class="dash4-load-note"><span>14 дней</span><b>${days.reduce((s,d)=>s+d.count,0)} заказов</b></div>`;
|
||
const leaderHtml=leaders.length?leaders.map((x,i)=>`<div class="dash4-leader"><img src="${esc(x.item.photo||(window.CateriumBranding.documentImage()))}" onerror="this.src='${window.CateriumBranding.logoHTML()}'" alt=""><div><b>${i+1}. ${esc(x.item.name||'Позиция')}</b><span>${x.qty} шт. · ${esc(fmt(x.sales))}</span></div></div>`).join(''):'<div class="dash4-empty" style="grid-column:1/-1">За последние 30 дней продаж пока нет.</div>';
|
||
dashboard.innerHTML=`<div class="dash4"><div class="dash4-head"><div><h1>Главная</h1><p>Ближайшие заказы, быстрые действия и короткая сводка.</p></div></div><div class="dash4-quick">${quick.map(([ic,label,sub])=>`<button class="dash4-qbtn" type="button" onclick="sunDashboardV4Go('${label}')"><span class="dash4-qicon">${icon(ic)}</span><span><b>${label}</b><small>${sub}</small></span></button>`).join('')}</div><div class="dash4-kpis"><div class="dash4-kpi"><div><small>Заказов сегодня</small><b>${todayCount}</b></div><i>С</i></div><div class="dash4-kpi"><div><small>Ближайшие 7 дней</small><b>${next7.length}</b></div><i>7</i></div><div class="dash4-kpi"><div><small>Клиентов за 30 дней</small><b>${clients30}</b></div><i>К</i></div><div class="dash4-kpi"><div><small>Позиций за 30 дней</small><b>${units30}</b></div><i>×</i></div></div><div class="dash4-main"><section class="dash4-panel">${nearestHtml}</section><section class="dash4-panel"><div class="dash4-panel-head"><h2>Следующие заказы</h2><small>${following.length}</small></div><div class="dash4-orders">${followHtml}</div></section></div><div class="dash4-bottom"><section class="dash4-panel"><div class="dash4-panel-head"><h2>Загрузка</h2><small>короткий график</small></div><div class="dash4-load">${loadHtml}</div></section><section class="dash4-panel"><div class="dash4-panel-head"><h2>Лидеры продаж</h2><small>последние 30 дней</small></div><div class="dash4-leaders">${leaderHtml}</div></section></div></div>`;
|
||
}
|
||
window.renderDashboardV4=renderDash;
|
||
if(dashBtn){dashBtn.onclick=()=>{qa('.view').forEach(v=>v.classList.remove('on'));dashboard.classList.add('on');qa('header nav button').forEach(b=>b.classList.remove('on'));dashBtn.classList.add('on');renderDash();};}
|
||
if(dashboard?.classList.contains('on'))renderDash();
|
||
})();
|
||
</script>
|
||
|
||
|
||
|
||
<!-- ===== ORDER TABLE REFERENCE + RESIZABLE WORKSPACE v1 ===== -->
|
||
<style id="sun-order-reference-resize-style">
|
||
/* Order table closer to the supplied reference: readable names and tactile quantity controls. */
|
||
#orderForm .sun-order-lines-table{border-radius:10px!important;border-color:#dfe3e6!important;box-shadow:none!important}
|
||
#orderForm .sun-order-line-head,
|
||
#orderForm .sun-order-line-row{grid-template-columns:minmax(260px,1fr) 175px 105px 125px!important;gap:12px!important}
|
||
#orderForm .sun-order-line-head{padding:11px 14px!important;background:#fff!important;color:#141b22!important;font-size:15px!important;font-weight:800!important;text-transform:none!important;letter-spacing:0!important;border-bottom:1px solid #dfe3e6!important}
|
||
#orderForm .sun-order-line-head span:first-child{text-align:left!important}
|
||
#orderForm .sun-order-line-head span:nth-child(n+2){text-align:center!important}
|
||
#orderForm .sun-order-line-head span:last-child{text-align:right!important}
|
||
#orderForm .sun-order-line-row{min-height:61px!important;padding:7px 14px!important;border-bottom:1px solid #e3e6e8!important;background:#fff!important}
|
||
#orderForm .sun-order-line-row:hover{background:#fbfcfd!important}
|
||
#orderForm .sun-order-line-name{font-size:17px!important;font-weight:500!important;color:#0f1720!important;line-height:1.2!important;padding-right:6px!important}
|
||
#orderForm .sun-order-line-qty{display:grid!important;grid-template-columns:38px 58px 38px!important;justify-content:center!important;gap:5px!important;align-items:center!important}
|
||
#orderForm .sun-order-line-qty button{width:38px!important;height:40px!important;padding:0!important;border:0!important;background:transparent!important;color:#76aee7!important;font-size:36px!important;font-weight:400!important;line-height:36px!important;border-radius:6px!important;box-shadow:none!important}
|
||
#orderForm .sun-order-line-qty button:hover{background:#eef6ff!important;color:#4e97df!important}
|
||
#orderForm .sun-order-line-qty input{width:58px!important;height:40px!important;padding:3px 4px!important;border:1px solid #cbd0d4!important;border-radius:7px!important;background:#fff!important;color:#1d2730!important;text-align:center!important;font-size:17px!important;font-weight:500!important;box-shadow:inset 0 1px 2px rgba(0,0,0,.035)!important}
|
||
#orderForm .sun-order-line-price{font-size:16px!important;color:#1e2932!important;text-align:center!important;white-space:nowrap!important}
|
||
#orderForm .sun-order-line-sum{font-size:16px!important;color:#111b24!important;text-align:right!important;white-space:nowrap!important}
|
||
#orderForm .sun-order-line-sum b{font-weight:500!important}
|
||
#orderForm .sun-order-line-total{grid-template-columns:minmax(260px,1fr) 175px 105px 125px!important;gap:12px!important;padding:12px 14px!important;background:#fff!important;border-top:1px solid #dfe3e6!important}
|
||
#orderForm .sun-order-line-total span{grid-column:1/4!important;text-align:left!important;color:#0f1720!important;font-size:17px!important;font-weight:800!important}
|
||
#orderForm .sun-order-line-total b{grid-column:4!important;text-align:right!important;color:#0f1720!important;font-size:17px!important;font-weight:800!important}
|
||
|
||
/* Desktop splitter between catalog and order pane. */
|
||
#new .work.sun-resizable-work{position:relative!important;align-items:stretch!important;grid-template-columns:minmax(0,2fr) 10px minmax(0,1fr)!important}
|
||
#new .work.sun-resizable-work>.catalog{grid-column:1!important;min-width:0!important}
|
||
#new .work.sun-resizable-work>.sun-work-splitter{grid-column:2!important}
|
||
#new .work.sun-resizable-work>.sun-order-pane{grid-column:3!important;min-width:0!important}
|
||
.sun-work-splitter{width:10px;min-width:10px;align-self:stretch;cursor:col-resize;position:relative;border-radius:8px;background:transparent;touch-action:none;user-select:none}
|
||
.sun-work-splitter:before{content:"";position:absolute;left:4px;top:12px;bottom:12px;width:2px;border-radius:3px;background:#d9dde4;transition:background .15s,width .15s,left .15s}
|
||
.sun-work-splitter:after{content:"⋮";position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:18px;height:34px;border:1px solid #e2e5ea;background:#fff;border-radius:9px;display:grid;place-items:center;color:#a1a8b2;font-size:18px;line-height:1;box-shadow:0 2px 8px rgba(23,32,47,.06);transition:border-color .15s,box-shadow .15s,color .15s}
|
||
.sun-work-splitter:hover:before,.sun-work-splitter.sun-dragging:before{left:3px;width:4px;background:#9c8fd0}
|
||
.sun-work-splitter:hover:after,.sun-work-splitter.sun-dragging:after{border-color:#c9c0e8;color:#7565b5;box-shadow:0 4px 12px rgba(78,64,137,.12)}
|
||
body.sun-splitting{cursor:col-resize!important;user-select:none!important}
|
||
body.sun-splitting *{cursor:col-resize!important}
|
||
|
||
@media(max-width:1180px){
|
||
#new .work.sun-resizable-work{grid-template-columns:1fr!important}
|
||
#new .work.sun-resizable-work>.catalog,#new .work.sun-resizable-work>.sun-order-pane{grid-column:1!important}
|
||
#new .work.sun-resizable-work>.sun-work-splitter{display:none!important}
|
||
}
|
||
@media(max-width:760px){
|
||
#orderForm .sun-order-line-head{display:none!important}
|
||
#orderForm .sun-order-line-row{grid-template-columns:minmax(0,1fr) 125px!important;gap:7px 8px!important;padding:10px!important}
|
||
#orderForm .sun-order-line-name{grid-column:1/-1!important;font-size:15px!important}
|
||
#orderForm .sun-order-line-qty{justify-content:start!important;grid-template-columns:34px 54px 34px!important}
|
||
#orderForm .sun-order-line-qty button{width:34px!important;height:34px!important;font-size:31px!important;line-height:31px!important}
|
||
#orderForm .sun-order-line-qty input{width:54px!important;height:34px!important;font-size:15px!important}
|
||
#orderForm .sun-order-line-price{font-size:13px!important;text-align:right!important}
|
||
#orderForm .sun-order-line-sum{grid-column:2!important;font-size:14px!important}
|
||
#orderForm .sun-order-line-total{grid-template-columns:1fr auto!important;padding:11px!important}
|
||
#orderForm .sun-order-line-total span{grid-column:1!important;font-size:15px!important}
|
||
#orderForm .sun-order-line-total b{grid-column:2!important;font-size:16px!important}
|
||
}
|
||
</style>
|
||
<script id="sun-order-reference-resize-script">
|
||
(()=>{
|
||
const work=document.querySelector('#new .work');
|
||
const catalog=work?.querySelector(':scope > .catalog');
|
||
if(!work||!catalog)return;
|
||
const orderPane=[...work.children].find(el=>el!==catalog&&!el.classList.contains('sun-work-splitter')&&el.matches('section.card'));
|
||
if(!orderPane)return;
|
||
orderPane.classList.add('sun-order-pane');
|
||
work.classList.add('sun-resizable-work');
|
||
|
||
let splitter=work.querySelector(':scope > .sun-work-splitter');
|
||
if(!splitter){
|
||
splitter=document.createElement('div');
|
||
splitter.className='sun-work-splitter';
|
||
splitter.setAttribute('role','separator');
|
||
splitter.setAttribute('aria-orientation','vertical');
|
||
splitter.setAttribute('aria-label','Изменить ширину каталога и заказа');
|
||
splitter.title='Потяните, чтобы изменить ширину. Двойной щелчок — сбросить.';
|
||
catalog.insertAdjacentElement('afterend',splitter);
|
||
}
|
||
|
||
const STORAGE_KEY='sunNewOrderSplitRatioV5';
|
||
const defaultRatio=2/3;
|
||
const clamp=(v,min,max)=>Math.max(min,Math.min(max,v));
|
||
function savedRatio(){
|
||
try{const n=Number(localStorage.getItem(STORAGE_KEY));return Number.isFinite(n)&&n>.2&&n<.8?n:defaultRatio}catch(_){return defaultRatio}
|
||
}
|
||
function applyRatio(ratio,save=false){
|
||
if(matchMedia('(max-width:1180px)').matches){work.style.removeProperty('grid-template-columns');return;}
|
||
const rect=work.getBoundingClientRect();
|
||
const splitterW=10;
|
||
const minLeft=480,minRight=340;
|
||
const usable=Math.max(1,rect.width-splitterW);
|
||
const left=clamp(Math.round(usable*ratio),minLeft,Math.max(minLeft,usable-minRight));
|
||
const actual=left/usable;
|
||
work.style.setProperty('grid-template-columns',`${left}px ${splitterW}px minmax(${minRight}px,1fr)`,'important');
|
||
splitter.setAttribute('aria-valuenow',String(Math.round(actual*100)));
|
||
if(save){try{localStorage.setItem(STORAGE_KEY,String(actual))}catch(_){}}
|
||
}
|
||
requestAnimationFrame(()=>requestAnimationFrame(()=>applyRatio(savedRatio())));
|
||
|
||
let dragging=false;
|
||
function move(clientX){
|
||
const rect=work.getBoundingClientRect();
|
||
const usable=Math.max(1,rect.width-10);
|
||
const ratio=(clientX-rect.left)/usable;
|
||
applyRatio(ratio,true);
|
||
}
|
||
splitter.addEventListener('pointerdown',e=>{
|
||
if(e.button!==0)return;
|
||
dragging=true;splitter.classList.add('sun-dragging');document.body.classList.add('sun-splitting');
|
||
try{splitter.setPointerCapture(e.pointerId)}catch(_){}
|
||
e.preventDefault();
|
||
});
|
||
splitter.addEventListener('pointermove',e=>{if(dragging)move(e.clientX)});
|
||
const stop=()=>{if(!dragging)return;dragging=false;splitter.classList.remove('sun-dragging');document.body.classList.remove('sun-splitting')};
|
||
splitter.addEventListener('pointerup',stop);splitter.addEventListener('pointercancel',stop);
|
||
splitter.addEventListener('dblclick',()=>{try{localStorage.removeItem(STORAGE_KEY)}catch(_){}applyRatio(defaultRatio,true)});
|
||
let resizeFrame=0;
|
||
const reflow=()=>{cancelAnimationFrame(resizeFrame);resizeFrame=requestAnimationFrame(()=>{if(!dragging)applyRatio(savedRatio());});};
|
||
window.addEventListener('resize',reflow,{passive:true});
|
||
if('ResizeObserver' in window){
|
||
const observer=new ResizeObserver(reflow);observer.observe(work);
|
||
}
|
||
setTimeout(reflow,80);setTimeout(reflow,350);
|
||
})();
|
||
</script>
|
||
|
||
<script id="sun-order-viewer-preview-return-fix">
|
||
(()=>{
|
||
if(window.__sunOrderViewerPreviewReturnPatched)return;
|
||
window.__sunOrderViewerPreviewReturnPatched=true;
|
||
const previousCloseModal=window.closeModal;
|
||
if(typeof previousCloseModal!=='function')return;
|
||
|
||
function restoreOrderViewer(id){
|
||
const orderId=Number(id);
|
||
if(!Number.isFinite(orderId))return;
|
||
const ordersButton=[...document.querySelectorAll('header nav button')].find(button=>String(button.dataset.navLabel||button.textContent||'').trim()==='Заказы');
|
||
if(ordersButton){ordersButton.click();}
|
||
else if(typeof window.show==='function'){
|
||
try{window.show('orders',document.querySelector('header nav button:nth-of-type(2)'));}catch(_){}
|
||
}
|
||
setTimeout(()=>window.sunOrderViewerOpen?.(orderId),40);
|
||
}
|
||
|
||
window.closeModal=function(id,...args){
|
||
const returnId=id==='preview'?window.__sunOrderViewerReturnId:null;
|
||
if(id==='preview'&&returnId){
|
||
window.__sunOrderViewerReturnId=null;
|
||
window.__sunPrintPreviewReturn=false;
|
||
}
|
||
const result=previousCloseModal.call(this,id,...args);
|
||
if(id==='preview'&&returnId)restoreOrderViewer(returnId);
|
||
return result;
|
||
};
|
||
})();
|
||
</script>
|
||
|
||
|
||
<!-- ===== COMPACT ORDER NAMES + LOYALTY CARD IN DETAILS v1 ===== -->
|
||
<style id="sun-order-compact-loyalty-style">
|
||
/* Keep product names compact and on a single line. */
|
||
#orderForm .sun-order-line-head,
|
||
#orderForm .sun-order-line-row{grid-template-columns:minmax(220px,1fr) 150px 92px 115px!important;gap:10px!important}
|
||
#orderForm .sun-order-line-row{min-height:50px!important;padding:6px 12px!important}
|
||
#orderForm .sun-order-line-name{font-size:14px!important;font-weight:500!important;line-height:1.15!important;white-space:nowrap!important;overflow:hidden!important;text-overflow:ellipsis!important;padding-right:8px!important}
|
||
#orderForm .sun-order-line-price,#orderForm .sun-order-line-sum{font-size:14px!important}
|
||
#orderForm .sun-order-line-total{grid-template-columns:minmax(220px,1fr) 150px 92px 115px!important;gap:10px!important;padding:10px 12px!important}
|
||
#orderForm .sun-order-line-total span,#orderForm .sun-order-line-total b{font-size:15px!important}
|
||
#details .sun-loyalty-order-field input{letter-spacing:.02em}
|
||
@media(max-width:760px){
|
||
#orderForm .sun-order-line-row{grid-template-columns:minmax(0,1fr) 122px!important;min-height:0!important}
|
||
#orderForm .sun-order-line-name{font-size:13px!important;grid-column:1/-1!important}
|
||
#orderForm .sun-order-line-total{grid-template-columns:1fr auto!important}
|
||
}
|
||
</style>
|
||
<script id="sun-order-loyalty-details-script">
|
||
(()=>{
|
||
const detailsGrid=document.querySelector('#details .sun-delivery-details');
|
||
const phoneInput=document.getElementById('phone');
|
||
const contactInput=document.getElementById('contact');
|
||
if(!detailsGrid||!phoneInput||!contactInput)return;
|
||
|
||
let field=document.getElementById('orderLoyaltyCard');
|
||
if(!field){
|
||
const label=document.createElement('label');
|
||
label.className='sun-loyalty-order-field';
|
||
label.innerHTML='<span>Номер карты лояльности</span><input id="orderLoyaltyCard" autocomplete="off" placeholder="">';
|
||
const phoneLabel=phoneInput.closest('label');
|
||
phoneLabel?.insertAdjacentElement('afterend',label);
|
||
field=label.querySelector('input');
|
||
}
|
||
|
||
const STORE_KEY='sunClientLoyaltyV1';
|
||
const defaults=()=>({enabled:false,cardNumber:'',mode:'fixed',fixedDiscount:0,baseDiscount:0,stepAmount:50000,stepPercent:1,maxDiscount:10});
|
||
const normalizePhone=value=>String(value||'').replace(/\D/g,'');
|
||
const keyFromValues=(phone,name)=>{const p=normalizePhone(phone);if(p)return `p:${p}`;const n=String(name||'').trim().toLowerCase();return n?`n:${n}`:'';};
|
||
const readStore=()=>{try{const v=JSON.parse(localStorage.getItem(STORE_KEY)||'{}');return v&&typeof v==='object'?v:{}}catch(_){return {}}};
|
||
const writeStore=value=>{try{localStorage.setItem(STORE_KEY,JSON.stringify(value||{}))}catch(_){}};
|
||
const currentDraft=()=>{try{return draft}catch(_){return null}};
|
||
|
||
function cardFromClient(){
|
||
const key=keyFromValues(phoneInput.value,contactInput.value);
|
||
if(!key)return '';
|
||
return String(readStore()[key]?.cardNumber||'').trim();
|
||
}
|
||
function loadField(){
|
||
const d=currentDraft();
|
||
const saved=String(d?.loyaltyCardNumber||d?.loyaltyCard||'').trim();
|
||
field.value=saved||cardFromClient();
|
||
}
|
||
function syncDraft(){
|
||
const d=currentDraft();
|
||
if(d)d.loyaltyCardNumber=String(field.value||'').trim();
|
||
}
|
||
function syncClientStore(){
|
||
const card=String(field.value||'').trim();
|
||
const key=keyFromValues(phoneInput.value,contactInput.value);
|
||
if(!key||!card)return;
|
||
const store=readStore();
|
||
store[key]={...defaults(),...(store[key]||{}),cardNumber:card};
|
||
writeStore(store);
|
||
}
|
||
function maybePrefill(){
|
||
if(String(field.value||'').trim())return;
|
||
const card=cardFromClient();
|
||
if(card)field.value=card;
|
||
}
|
||
|
||
field.addEventListener('input',syncDraft);
|
||
phoneInput.addEventListener('blur',maybePrefill);
|
||
contactInput.addEventListener('blur',maybePrefill);
|
||
|
||
if(typeof window.saveOrder==='function'){
|
||
const previous=window.saveOrder;
|
||
window.saveOrder=function(...args){
|
||
syncDraft();
|
||
syncClientStore();
|
||
return previous.apply(this,args);
|
||
};
|
||
}
|
||
if(typeof window.openOrder==='function'){
|
||
const previous=window.openOrder;
|
||
window.openOrder=function(...args){
|
||
const result=previous.apply(this,args);
|
||
setTimeout(loadField,0);
|
||
return result;
|
||
};
|
||
}
|
||
if(typeof window.resetDraft==='function'){
|
||
const previous=window.resetDraft;
|
||
window.resetDraft=function(...args){
|
||
const result=previous.apply(this,args);
|
||
field.value='';
|
||
const d=currentDraft();if(d){d.loyaltyCardNumber='';delete d.loyaltyCard;}
|
||
return result;
|
||
};
|
||
}
|
||
|
||
function decorateNames(){
|
||
document.querySelectorAll('#orderForm .sun-order-line-name').forEach(el=>{el.title=el.textContent.trim();});
|
||
}
|
||
if(typeof window.render==='function'){
|
||
const previous=window.render;
|
||
window.render=function(...args){const result=previous.apply(this,args);decorateNames();return result;};
|
||
}
|
||
decorateNames();
|
||
loadField();
|
||
})();
|
||
</script>
|
||
|
||
|
||
|
||
<!-- ===== ULTRA COMPACT SINGLE-LINE ORDER TABLE v2 ===== -->
|
||
<style id="sun-order-ultracompact-style">
|
||
/* Four columns always stay on one line on desktop, even when the order pane is about one third of the workspace. */
|
||
#orderForm .sun-order-lines-table{border-radius:9px!important}
|
||
#orderForm .sun-order-line-head,
|
||
#orderForm .sun-order-line-row,
|
||
#orderForm .sun-order-line-total{grid-template-columns:minmax(104px,1fr) 82px 62px 76px!important;gap:4px!important}
|
||
#orderForm .sun-order-line-head{padding:7px 8px!important;font-size:10.5px!important;line-height:1!important;white-space:nowrap!important}
|
||
#orderForm .sun-order-line-row{min-height:42px!important;padding:5px 8px!important}
|
||
#orderForm .sun-order-line-name{font-size:12.5px!important;font-weight:500!important;line-height:1.1!important;white-space:nowrap!important;overflow:hidden!important;text-overflow:ellipsis!important;padding-right:4px!important}
|
||
#orderForm .sun-order-line-qty{grid-template-columns:20px 34px 20px!important;gap:4px!important;justify-content:center!important}
|
||
#orderForm .sun-order-line-qty button{width:20px!important;height:28px!important;font-size:25px!important;line-height:25px!important;border-radius:5px!important}
|
||
#orderForm .sun-order-line-qty input{width:34px!important;height:28px!important;padding:2px!important;font-size:13px!important;border-radius:6px!important}
|
||
#orderForm .sun-order-line-price,#orderForm .sun-order-line-sum{font-size:12.5px!important;line-height:1!important;white-space:nowrap!important}
|
||
#orderForm .sun-order-line-price{text-align:center!important}
|
||
#orderForm .sun-order-line-sum{text-align:right!important}
|
||
#orderForm .sun-order-line-total{padding:8px!important}
|
||
#orderForm .sun-order-line-total span{grid-column:1/4!important;text-align:left!important;font-size:13px!important}
|
||
#orderForm .sun-order-line-total b{grid-column:4!important;text-align:right!important;font-size:13.5px!important}
|
||
@media(max-width:1180px){
|
||
#orderForm .sun-order-line-head,#orderForm .sun-order-line-row,#orderForm .sun-order-line-total{grid-template-columns:minmax(150px,1fr) 92px 72px 88px!important}
|
||
}
|
||
@media(max-width:760px){
|
||
#orderForm .sun-order-line-head{display:none!important}
|
||
#orderForm .sun-order-line-row{grid-template-columns:minmax(0,1fr) 112px!important;gap:6px 8px!important;padding:8px!important}
|
||
#orderForm .sun-order-line-name{grid-column:1/-1!important;font-size:13px!important}
|
||
#orderForm .sun-order-line-qty{grid-template-columns:26px 44px 26px!important;justify-content:start!important}
|
||
#orderForm .sun-order-line-qty button{width:26px!important;height:30px!important;font-size:27px!important}
|
||
#orderForm .sun-order-line-qty input{width:44px!important;height:30px!important}
|
||
#orderForm .sun-order-line-price{font-size:12px!important;text-align:right!important}
|
||
#orderForm .sun-order-line-sum{grid-column:2!important;font-size:13px!important}
|
||
#orderForm .sun-order-line-total{grid-template-columns:1fr auto!important}
|
||
#orderForm .sun-order-line-total span{grid-column:1!important}
|
||
#orderForm .sun-order-line-total b{grid-column:2!important}
|
||
}
|
||
</style>
|
||
|
||
|
||
<!-- ===== REMOVE DASHBOARD + NEW ORDER DEFAULT 2:1 LAYOUT ===== -->
|
||
<script id="sun-remove-dashboard-default-layout">
|
||
(()=>{
|
||
const dashboard=document.getElementById('dashboard');
|
||
const homeButton=[...document.querySelectorAll('header nav button')].find(b=>b.textContent.trim()==='Главная');
|
||
const wasDashboardActive=Boolean(dashboard?.classList.contains('on')||homeButton?.classList.contains('on'));
|
||
|
||
if(homeButton) homeButton.remove();
|
||
if(dashboard) dashboard.remove();
|
||
|
||
if(wasDashboardActive){
|
||
const newView=document.getElementById('new');
|
||
const newButton=[...document.querySelectorAll('header nav button')].find(b=>b.textContent.trim()==='Новый заказ');
|
||
document.querySelectorAll('.view').forEach(v=>v.classList.remove('on'));
|
||
newView?.classList.add('on');
|
||
document.querySelectorAll('header nav button').forEach(b=>b.classList.remove('on'));
|
||
newButton?.classList.add('on');
|
||
}
|
||
})();
|
||
</script>
|
||
|
||
<!-- ===== STABILITY + MOTION POLISH v1 ===== -->
|
||
<style id="sun-stability-motion-style">
|
||
@media screen{
|
||
html{scroll-behavior:smooth}
|
||
body{overflow-x:hidden}
|
||
body.sun-modal-open{overflow:hidden!important}
|
||
.view.on{animation:sun-view-enter .18s cubic-bezier(.2,.75,.25,1)}
|
||
@keyframes sun-view-enter{from{opacity:.35;transform:translateY(5px)}to{opacity:1;transform:none}}
|
||
|
||
/* Modals remain in the layout as invisible fixed layers so both opening and
|
||
closing can animate, even when older modules remove the .on class directly. */
|
||
.modal{
|
||
display:grid!important;opacity:0;visibility:hidden;pointer-events:none;
|
||
transition:opacity .18s ease,visibility 0s linear .18s;
|
||
backdrop-filter:blur(0);-webkit-backdrop-filter:blur(0);
|
||
}
|
||
.modal>.dialog{
|
||
opacity:0;transform:translateY(12px) scale(.985);
|
||
transition:transform .2s cubic-bezier(.2,.8,.25,1),opacity .16s ease;
|
||
scrollbar-gutter:stable;overscroll-behavior:contain;
|
||
}
|
||
.modal.on{
|
||
opacity:1;visibility:visible;pointer-events:auto;transition-delay:0s;
|
||
backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);
|
||
}
|
||
.modal.on>.dialog{opacity:1;transform:translateY(0) scale(1)}
|
||
.modal:not(.on)>.dialog{pointer-events:none}
|
||
|
||
button,.mini,.primary,.outline,.danger{transition:transform .14s ease,box-shadow .14s ease,background-color .14s ease,border-color .14s ease,opacity .14s ease}
|
||
button:not(:disabled):active{transform:translateY(1px) scale(.99)}
|
||
button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,[tabindex]:focus-visible{
|
||
outline:3px solid rgba(111,91,208,.28)!important;outline-offset:2px!important
|
||
}
|
||
|
||
/* Prevent the catalog category buttons and the view selector from ever
|
||
overlapping when the window or the draggable columns are narrow. */
|
||
#new .sun-catalog-top-toolbar{flex-wrap:wrap!important;align-items:center!important;overflow:visible!important}
|
||
#new .sun-catalog-top-toolbar .cats{flex:1 1 430px!important;min-width:280px!important;width:auto!important;overflow:visible!important}
|
||
#new .sun-catalog-top-toolbar .cats button{flex:0 0 auto!important;min-width:max-content!important;width:auto!important;position:relative;z-index:1}
|
||
#new .sun-catalog-top-toolbar .sun-catalog-view-switch{position:relative!important;z-index:4!important;flex:0 0 auto!important;max-width:100%;overflow:auto}
|
||
#new .sun-catalog-top-toolbar .sun-catalog-view-switch button{flex:0 0 auto}
|
||
|
||
#tiles .tile{content-visibility:auto;contain-intrinsic-size:150px}
|
||
#tiles.sun-view-dense .tile{contain-intrinsic-size:46px}
|
||
#tiles.sun-view-list .tile{contain-intrinsic-size:62px}
|
||
}
|
||
@media screen and (max-width:1450px){
|
||
#new .sun-catalog-top-toolbar .cats{flex-basis:100%!important}
|
||
#new .sun-catalog-top-toolbar .sun-catalog-view-switch{margin-left:0!important}
|
||
}
|
||
@media (prefers-reduced-motion:reduce){
|
||
.view.on,.modal,.modal>.dialog,button,.mini,.primary,.outline,.danger{animation:none!important;transition:none!important;scroll-behavior:auto!important}
|
||
}
|
||
@media print{
|
||
#preview.modal{opacity:1!important;visibility:visible!important;pointer-events:auto!important;backdrop-filter:none!important}
|
||
#preview.modal>.dialog{opacity:1!important;transform:none!important}
|
||
}
|
||
</style>
|
||
<script id="sun-stability-motion-script">
|
||
(()=>{
|
||
'use strict';
|
||
if(window.__sunStabilityMotionV1)return;window.__sunStabilityMotionV1=true;
|
||
const modalState=new WeakMap();
|
||
let layer=1200;
|
||
let backdropPress=null;
|
||
const MOVE_LIMIT=9;
|
||
|
||
const openModals=()=>[...document.querySelectorAll('.modal.on')];
|
||
const topModal=()=>openModals().sort((a,b)=>(Number(getComputedStyle(a).zIndex)||0)-(Number(getComputedStyle(b).zIndex)||0)).pop();
|
||
const syncBody=()=>document.body.classList.toggle('sun-modal-open',openModals().length>0);
|
||
|
||
function onOpened(modal){
|
||
if(!modalState.has(modal))modalState.set(modal,{opener:document.activeElement});
|
||
modal.setAttribute('role','dialog');modal.setAttribute('aria-modal','true');modal.setAttribute('aria-hidden','false');
|
||
modal.style.zIndex=String(++layer);syncBody();
|
||
requestAnimationFrame(()=>{
|
||
const active=document.activeElement;
|
||
if(active&&modal.contains(active))return;
|
||
const focusable=modal.querySelector('[autofocus],input:not([type="hidden"]):not([disabled]),select:not([disabled]),textarea:not([disabled]),button:not([disabled]),[tabindex]:not([tabindex="-1"])');
|
||
focusable?.focus({preventScroll:true});
|
||
});
|
||
}
|
||
function onClosed(modal){
|
||
modal.setAttribute('aria-hidden','true');syncBody();
|
||
const state=modalState.get(modal);modalState.delete(modal);
|
||
const opener=state?.opener;
|
||
if(opener&&opener.isConnected)setTimeout(()=>{if(!openModals().length||!document.activeElement||document.activeElement===document.body)opener.focus?.({preventScroll:true});},190);
|
||
}
|
||
|
||
document.querySelectorAll('.modal').forEach(m=>{m.setAttribute('aria-hidden',m.classList.contains('on')?'false':'true');if(m.classList.contains('on'))onOpened(m);});
|
||
const observer=new MutationObserver(records=>{
|
||
records.forEach(record=>{
|
||
const modal=record.target;if(!(modal instanceof HTMLElement)||!modal.classList.contains('modal'))return;
|
||
const was=modal.dataset.sunObservedOpen==='1',now=modal.classList.contains('on');
|
||
if(now&&!was){modal.dataset.sunObservedOpen='1';onOpened(modal);}
|
||
else if(!now&&was){modal.dataset.sunObservedOpen='0';onClosed(modal);}
|
||
});
|
||
});
|
||
document.querySelectorAll('.modal').forEach(m=>{m.dataset.sunObservedOpen=m.classList.contains('on')?'1':'0';observer.observe(m,{attributes:true,attributeFilter:['class']});});
|
||
const bodyObserver=new MutationObserver(records=>{
|
||
records.forEach(record=>{
|
||
record.addedNodes.forEach(node=>{
|
||
if(!(node instanceof HTMLElement))return;
|
||
const nested=[...node.querySelectorAll('.modal')];
|
||
const modals=node.matches('.modal')?[node,...nested]:nested;
|
||
modals.forEach(m=>{m.dataset.sunObservedOpen=m.classList.contains('on')?'1':'0';m.setAttribute('aria-hidden',m.classList.contains('on')?'false':'true');observer.observe(m,{attributes:true,attributeFilter:['class']});if(m.classList.contains('on'))onOpened(m);});
|
||
node.querySelectorAll('img').forEach(enhanceImage);
|
||
});
|
||
});
|
||
});
|
||
bodyObserver.observe(document.body,{childList:true,subtree:true});
|
||
|
||
/* Capture backdrop clicks before the older generic handler. Only a stationary
|
||
press that starts and ends on the same backdrop is allowed to close. */
|
||
document.addEventListener('pointerdown',event=>{
|
||
const modal=event.target instanceof Element?event.target.closest('.modal.on'):null;
|
||
backdropPress=modal&&event.target===modal?{modal,x:event.clientX,y:event.clientY,id:event.pointerId,moved:false}:null;
|
||
},true);
|
||
document.addEventListener('pointermove',event=>{
|
||
if(!backdropPress||backdropPress.id!==event.pointerId)return;
|
||
if(Math.hypot(event.clientX-backdropPress.x,event.clientY-backdropPress.y)>MOVE_LIMIT)backdropPress.moved=true;
|
||
},true);
|
||
document.addEventListener('click',event=>{
|
||
const modal=event.target instanceof Element?event.target.closest('.modal.on'):null;
|
||
if(!modal||event.target!==modal)return;
|
||
const state=backdropPress;backdropPress=null;
|
||
event.preventDefault();event.stopImmediatePropagation();
|
||
if(!state||state.modal!==modal||state.moved)return;
|
||
window.closeModal?.(modal.id);
|
||
},true);
|
||
document.addEventListener('pointercancel',()=>{backdropPress=null;},true);
|
||
|
||
document.addEventListener('keydown',event=>{
|
||
if(event.key!=='Escape'||event.isComposing)return;
|
||
const modal=topModal();if(!modal?.id)return;
|
||
event.preventDefault();event.stopImmediatePropagation();window.closeModal?.(modal.id);
|
||
},true);
|
||
|
||
function enhanceImage(img){
|
||
if(!(img instanceof HTMLImageElement))return;
|
||
if(!img.loading)img.loading='lazy';img.decoding='async';
|
||
}
|
||
document.querySelectorAll('img').forEach(enhanceImage);
|
||
document.addEventListener('error',event=>{
|
||
const img=event.target;if(!(img instanceof HTMLImageElement)||img.dataset.sunFallback==='1')return;
|
||
if(String(img.getAttribute('src')||'').includes((window.CateriumBranding.documentImage())))return;
|
||
img.dataset.sunFallback='1';img.src=(window.CateriumBranding.documentImage());img.alt=img.alt||(window.CateriumBranding.documentName());
|
||
},true);
|
||
|
||
/* Expose a small health snapshot for troubleshooting without changing the UI. */
|
||
window.SunAppHealth={
|
||
snapshot:()=>({
|
||
activeView:document.querySelector('.view.on')?.id||'',
|
||
openModals:openModals().map(m=>m.id),
|
||
duplicateIds:(()=>{const ids=new Map();document.querySelectorAll('[id]').forEach(e=>ids.set(e.id,(ids.get(e.id)||0)+1));return [...ids].filter(([,n])=>n>1).map(([id,n])=>({id,n}));})(),
|
||
orders:(()=>{try{return JSON.parse(localStorage.getItem('sunOrders')||'[]').length}catch(_){return 0}})(),
|
||
catalog:(()=>{try{return JSON.parse(localStorage.getItem('sunBoxes')||'[]').length}catch(_){return 0}})()
|
||
})
|
||
};
|
||
})();
|
||
</script>
|
||
|
||
|
||
<!-- ===== OPTIONAL ORDER STATUS + PRICE/BALANCE FIX v2 ===== -->
|
||
<style id="sun-order-status-optional-style-v2">
|
||
#details .sun-order-status-field{grid-column:1/-1!important;display:grid!important;grid-template-columns:minmax(0,1fr) auto!important;gap:7px!important;align-items:end!important}
|
||
#details .sun-order-status-field>span{grid-column:1/-1;color:#68717a;font-size:13px}
|
||
#details .sun-order-status-field select{min-width:0;width:100%}
|
||
#details .sun-order-status-clear{height:35px;min-width:74px;padding:6px 10px;border:1px solid #d9dde0;border-radius:7px;background:#fff;color:#7a5560;font-weight:700}
|
||
#details .sun-order-status-clear:hover{background:#fff7f8;border-color:#e5c8ce}
|
||
.sun-order-view-status.sun-status-empty{display:none!important}
|
||
.sun-doc-badge.sun-status-empty{display:none!important}
|
||
#new .sun-order-pane{container-type:inline-size}
|
||
#new .sun-order-pane .money-form{grid-template-columns:repeat(3,minmax(0,1fr))!important;min-width:0!important}
|
||
#new .sun-order-pane .money-form label,#new .sun-order-pane .money-form input{min-width:0!important;width:100%!important}
|
||
#new .sun-order-pane .order-summary{min-width:0!important}
|
||
#new .sun-order-pane .order-summary p{gap:10px!important;min-width:0!important}
|
||
#new .sun-order-pane .order-summary p b{white-space:nowrap!important;flex:0 0 auto!important}
|
||
#orderForm .sun-order-line-price{display:flex!important;align-items:center!important;justify-content:center!important}
|
||
#orderForm .sun-order-line-price input{width:100%!important;max-width:92px!important;min-width:0!important;height:28px!important;padding:2px 5px!important;border:1px solid #cbd0d4!important;border-radius:6px!important;background:#fff!important;color:#1d2730!important;text-align:center!important;font-size:12.5px!important;font-weight:600!important}
|
||
#orderForm .sun-order-line-price input:focus{outline:none!important;border-color:#7d70c7!important;box-shadow:0 0 0 3px rgba(111,91,208,.12)!important}
|
||
@container (max-width:680px){#new .sun-order-pane .money-form{grid-template-columns:repeat(2,minmax(0,1fr))!important}#new .sun-order-pane .money-form label:last-child{grid-column:1/-1!important}}
|
||
@container (max-width:520px){#new .sun-order-pane .money-form{grid-template-columns:1fr!important}#new .sun-order-pane .money-form label:last-child{grid-column:auto!important}}
|
||
@media(max-width:680px){#details .sun-order-status-field{grid-template-columns:minmax(0,1fr) auto!important}}
|
||
</style>
|
||
<script id="sun-order-status-optional-script-v2">
|
||
(()=>{
|
||
'use strict';
|
||
if(window.__sunOrderStatusOptionalV2)return;window.__sunOrderStatusOptionalV2=true;
|
||
const STATUS_VALUES=['Новый','В работе','Собран','Передан курьеру','Отдан заказчику','Отменён'];
|
||
const NONE='';
|
||
const NONE_SENTINEL='__sun_no_status__';
|
||
const moneyFmt=v=>typeof money==='function'?money(v):`${Math.round(Number(v||0)).toLocaleString('ru-RU')} ₽`;
|
||
const itemById=id=>boxes.find(x=>String(x.id)===String(id));
|
||
const unitPrice=line=>{
|
||
const direct=Number(line?.price);
|
||
return Number.isFinite(direct)&&direct>=0?direct:Math.max(0,Number(itemById(line?.id)?.price||0));
|
||
};
|
||
const currentOrder=()=>orders.find(o=>String(o.id)===String(draft?.id));
|
||
|
||
const pane=document.querySelector('#new .work > section.card');
|
||
pane?.classList.add('sun-order-pane');
|
||
|
||
// Keep the shared pricing functions: they already preserve per-order prices,
|
||
// include delivery once, and apply the saved manual/promo discounts.
|
||
|
||
// Add optional status to Details.
|
||
const detailsGrid=document.querySelector('#details .sun-delivery-details');
|
||
let statusSelect=document.getElementById('orderStatusOptional');
|
||
if(detailsGrid&&!statusSelect){
|
||
const wrap=document.createElement('label');
|
||
wrap.className='sun-order-status-field';
|
||
wrap.innerHTML=`<span>Статус заказа</span><select id="orderStatusOptional" aria-label="Статус заказа"><option value="">Без статуса</option>${STATUS_VALUES.map(s=>`<option value="${s}">${s}</option>`).join('')}</select><button class="sun-order-status-clear" id="orderStatusClear" type="button">Убрать</button>`;
|
||
detailsGrid.appendChild(wrap);statusSelect=wrap.querySelector('select');
|
||
wrap.querySelector('#orderStatusClear').onclick=()=>{statusSelect.value='';draft.status='';};
|
||
statusSelect.onchange=()=>{draft.status=statusSelect.value;};
|
||
}
|
||
const syncStatusField=(fresh=false)=>{
|
||
if(!statusSelect)return;
|
||
const value=fresh?'Новый':String(draft?.status||'');
|
||
statusSelect.value=STATUS_VALUES.includes(value)?value:'';
|
||
};
|
||
|
||
// Turn price text into editable input after the existing renderer finishes.
|
||
function decorateLinePrices(){
|
||
const rows=[...document.querySelectorAll('#orderForm .sun-order-line-row')];
|
||
rows.forEach((row,index)=>{
|
||
const line=draft.lines?.[index];if(!line)return;
|
||
if(!Object.prototype.hasOwnProperty.call(line,'price')||!Number.isFinite(Number(line.price)))line.price=Math.max(0,Number(itemById(line.id)?.price||0));
|
||
const price=unitPrice(line),qty=Math.max(0,Number(line.qty||0));
|
||
const priceCell=row.querySelector('.sun-order-line-price');
|
||
const sumCell=row.querySelector('.sun-order-line-sum');
|
||
if(priceCell){
|
||
priceCell.innerHTML=`<input type="text" inputmode="numeric" pattern="[0-9]*" autocomplete="off" value="${Math.round(price)}" aria-label="Цена позиции">`;
|
||
const input=priceCell.querySelector('input');
|
||
input.oninput=()=>{const clean=String(input.value||'').replace(/\D+/g,'').slice(0,9);if(input.value!==clean)input.value=clean;};
|
||
input.onchange=()=>{
|
||
// Do not rebuild the whole order table from the blur/change event of
|
||
// the focused price input: replacing its parent DOM during blur can
|
||
// trigger Chromium NotFoundError and make the interface feel frozen.
|
||
line.price=Math.max(0,Math.round(Number(String(input.value||'').replace(/\D+/g,'')||0)));
|
||
const next=unitPrice(line),nextQty=Math.max(0,Number(line.qty||0));
|
||
if(sumCell)sumCell.innerHTML=`<b>${moneyFmt(next*nextQty)}</b>`;
|
||
const totalEl=document.querySelector('#orderForm .sun-order-line-total b');
|
||
if(totalEl)totalEl.textContent=moneyFmt(calcOrderTotal());
|
||
updateOrderSummary();
|
||
};
|
||
}
|
||
if(sumCell)sumCell.innerHTML=`<b>${moneyFmt(price*qty)}</b>`;
|
||
});
|
||
const totalEl=document.querySelector('#orderForm .sun-order-line-total b');
|
||
if(totalEl)totalEl.textContent=moneyFmt(calcOrderTotal());
|
||
updateOrderSummary();
|
||
}
|
||
const priorRender=window.render;
|
||
if(typeof priorRender==='function')window.render=function(...args){const r=priorRender.apply(this,args);decorateLinePrices();return r;};
|
||
|
||
// New positions remember their catalog price at the moment they are added.
|
||
const priorAdd=window.add;
|
||
if(typeof priorAdd==='function')window.add=function(id,...args){
|
||
const before=(draft.lines||[]).find(x=>String(x.id)===String(id));
|
||
const r=priorAdd.call(this,id,...args);
|
||
const line=(draft.lines||[]).find(x=>String(x.id)===String(id));
|
||
if(line&&!before&&!Object.prototype.hasOwnProperty.call(line,'price'))line.price=Math.max(0,Number(itemById(id)?.price||0));
|
||
decorateLinePrices();return r;
|
||
};
|
||
|
||
function normalizeNoStatus(value){return value===NONE_SENTINEL?'':String(value||'')}
|
||
function finalizeSavedStatus(wanted){
|
||
draft.status=normalizeNoStatus(wanted);
|
||
const saved=currentOrder();if(saved)saved.status=draft.status;
|
||
persist();window.renderOrders?.();decorateOrderStatusUI();
|
||
}
|
||
|
||
const priorSave=window.saveOrder;
|
||
if(typeof priorSave==='function')window.saveOrder=function(...args){
|
||
(draft.lines||[]).forEach(line=>{if(!Object.prototype.hasOwnProperty.call(line,'price'))line.price=Math.max(0,Number(itemById(line.id)?.price||0));});
|
||
const wanted=statusSelect?statusSelect.value:String(draft.status||'Новый');
|
||
draft.status=wanted||NONE_SENTINEL; // prevents old core from forcing "Новый"
|
||
const result=priorSave.apply(this,args);
|
||
if(result&&typeof result.then==='function')return result.then(value=>{finalizeSavedStatus(wanted);return value;},err=>{draft.status=normalizeNoStatus(wanted);throw err;});
|
||
finalizeSavedStatus(wanted);return result;
|
||
};
|
||
|
||
const priorOpen=window.openOrder;
|
||
if(typeof priorOpen==='function')window.openOrder=function(...args){const r=priorOpen.apply(this,args);setTimeout(()=>{syncStatusField(false);decorateLinePrices();decorateOrderStatusUI();},0);return r;};
|
||
const priorReset=window.resetDraft;
|
||
if(typeof priorReset==='function')window.resetDraft=function(...args){const r=priorReset.apply(this,args);draft.status='Новый';setTimeout(()=>{syncStatusField(true);decorateLinePrices();},0);return r;};
|
||
|
||
const priorStatus=window.status;
|
||
window.status=function(id,value){
|
||
const order=orders.find(o=>String(o.id)===String(id));if(order)order.status=String(value||'');
|
||
if(String(draft?.id)===String(id)){draft.status=String(value||'');syncStatusField(false);}
|
||
persist();window.renderOrders?.();decorateOrderStatusUI();
|
||
try{if(typeof priorStatus==='function'&&value){} }catch(_){}
|
||
};
|
||
|
||
function ensureBlankOption(select){
|
||
if(!select)return;
|
||
if(![...select.options].some(o=>o.value===''))select.insertAdjacentHTML('afterbegin','<option value="">Без статуса</option>');
|
||
}
|
||
function decorateOrderStatusUI(){
|
||
document.querySelectorAll('#ordersList .row').forEach(row=>{
|
||
const id=String(row.querySelector('span')?.textContent||'').replace(/\D/g,'');
|
||
const order=orders.find(o=>String(o.id)===id),select=row.querySelector('select');if(!order||!select)return;
|
||
ensureBlankOption(select);select.value=String(order.status||'');
|
||
});
|
||
document.querySelectorAll('#sunOrderCards [data-card-status]').forEach(select=>{
|
||
const order=orders.find(o=>String(o.id)===String(select.dataset.cardStatus));if(!order)return;
|
||
ensureBlankOption(select);select.value=String(order.status||'');
|
||
});
|
||
}
|
||
const priorRenderOrders=window.renderOrders;
|
||
if(typeof priorRenderOrders==='function')window.renderOrders=function(...args){const r=priorRenderOrders.apply(this,args);setTimeout(decorateOrderStatusUI,0);return r;};
|
||
|
||
// Keep cards/list/viewer and printed order blank consistent when status was removed.
|
||
let statusDecorateQueued=false;
|
||
function decorateDynamicStatus(){
|
||
statusDecorateQueued=false;
|
||
decorateOrderStatusUI();
|
||
const viewer=document.getElementById('sun-order-viewer');
|
||
if(viewer?.classList.contains('on')){
|
||
const id=(viewer.querySelector('.sun-order-view-title h2')?.textContent||'').replace(/\D/g,'');
|
||
const order=orders.find(o=>String(o.id)===id),chip=viewer.querySelector('.sun-order-view-status');
|
||
if(chip&&order){
|
||
const next=String(order.status||'');
|
||
if(chip.textContent!==next)chip.textContent=next;
|
||
chip.classList.toggle('sun-status-empty',!next);
|
||
}
|
||
}
|
||
const badge=document.querySelector('#preview .sun-doc-badge');
|
||
if(badge){
|
||
const next=String(draft.status||'');
|
||
if(badge.textContent!==next)badge.textContent=next;
|
||
badge.classList.toggle('sun-status-empty',!next);
|
||
}
|
||
}
|
||
const observer=new MutationObserver(()=>{
|
||
if(statusDecorateQueued)return;
|
||
statusDecorateQueued=true;
|
||
requestAnimationFrame(decorateDynamicStatus);
|
||
});
|
||
observer.observe(document.body,{childList:true,subtree:true});
|
||
|
||
// Existing stored orders keep their current status. New drafts default to "Новый".
|
||
if(!draft.id&&!draft.status)draft.status='Новый';
|
||
syncStatusField(!draft.id);
|
||
setTimeout(()=>{window.render?.();decorateOrderStatusUI();},0);
|
||
})();
|
||
</script>
|
||
|
||
<style id="sun-orders-edit-date-color-fix-v1">
|
||
/* Orders: make the date tile more visible and keep editing as the primary action. */
|
||
#sunOrderCards .sun-order-card-date{
|
||
background:linear-gradient(145deg,#6f5bd0,#8b78df)!important;
|
||
border-color:#6f5bd0!important;
|
||
color:#fff!important;
|
||
box-shadow:0 5px 14px rgba(91,73,184,.22)!important;
|
||
}
|
||
#sunOrderCards .sun-order-card-date strong,
|
||
#sunOrderCards .sun-order-card-date span,
|
||
#sunOrderCards .sun-order-card-date small{color:#fff!important}
|
||
#sunOrderCards .sun-order-card-date small{opacity:.88}
|
||
#ordersList .row{cursor:pointer!important}
|
||
</style>
|
||
|
||
|
||
<!-- ===== ORDER DETAILS: SCHEDULE + PRECISE ADDRESS + MAP PRINT OPTION v1 ===== -->
|
||
<style id="sun-order-details-precise-address-style-v1">
|
||
#orderForm>.form{grid-template-columns:minmax(0,1fr)!important}
|
||
#details .sun-order-schedule{grid-column:1/-1;display:grid;grid-template-columns:minmax(0,1fr) minmax(110px,.45fr);gap:10px 12px;padding:10px 0 12px;margin-bottom:2px;border-bottom:1px solid #eceef2}
|
||
#details .sun-order-schedule label{margin:0}
|
||
#details .sun-address-precision-title{grid-column:1/-1;margin:5px 0 -1px;color:#30394f;font-size:12px;font-weight:800;letter-spacing:.02em}
|
||
#details .sun-delivery-region{grid-column:auto}
|
||
#details .sun-delivery-locality{grid-column:auto}
|
||
#details .sun-delivery-district{grid-column:1/-1}
|
||
#details .sun-print-map-option{grid-column:1/-1;display:flex!important;flex-direction:row!important;align-items:center!important;gap:9px!important;margin-top:3px;padding:9px 10px;border:1px solid #e2e5ea;border-radius:9px;background:#fafbfc;color:#364152!important;font-size:12.5px!important}
|
||
#details .sun-print-map-option input{width:17px!important;height:17px!important;flex:0 0 auto;margin:0}
|
||
#details .sun-print-map-option small{display:block;color:#7b8490;font-size:11px;margin-top:2px}
|
||
#details #openEventCardBtn,#details .event-card-section,.event-card-section,#openEventCardBtn{display:none!important}
|
||
@media(max-width:680px){
|
||
#details .sun-order-schedule{grid-template-columns:1fr}
|
||
#details .sun-delivery-region,#details .sun-delivery-locality,#details .sun-delivery-district{grid-column:1/-1}
|
||
}
|
||
</style>
|
||
<script id="sun-order-details-precise-address-script-v1">
|
||
(()=>{
|
||
'use strict';
|
||
if(window.__sunOrderDetailsPreciseAddressV1)return;window.__sunOrderDetailsPreciseAddressV1=true;
|
||
const q=(s,r=document)=>r.querySelector(s);
|
||
const details=q('#details');
|
||
const grid=q('#details .sun-delivery-details');
|
||
const orderForm=q('#orderForm');
|
||
const dateInput=q('#date');
|
||
const timeInput=q('#time');
|
||
const addressInput=q('#address');
|
||
if(!details||!grid||!orderForm||!dateInput||!timeInput||!addressInput)return;
|
||
|
||
// Move issue date/time out of the Order tab into Details without recreating inputs,
|
||
// so all existing save/open/calendar logic continues using the same elements.
|
||
let schedule=q('#details .sun-order-schedule');
|
||
if(!schedule){
|
||
schedule=document.createElement('div');schedule.className='sun-order-schedule';
|
||
const dateLabel=dateInput.closest('label'),timeLabel=timeInput.closest('label');
|
||
if(dateLabel)schedule.appendChild(dateLabel);
|
||
if(timeLabel)schedule.appendChild(timeLabel);
|
||
grid.insertAdjacentElement('beforebegin',schedule);
|
||
}
|
||
|
||
// Keep the existing guests input, but move it into Details instead of hiding it
|
||
// with the legacy operational event card. This preserves all old save/open hooks.
|
||
const moveGuestsToDetails=()=>{
|
||
const guests=q('#eventGuests');
|
||
if(!guests||!grid)return;
|
||
const label=guests.closest('label');
|
||
if(!label)return;
|
||
label.classList.add('sun-guests-details-field');
|
||
if(!label.querySelector(':scope > span')){
|
||
[...label.childNodes].filter(n=>n.nodeType===3).forEach(n=>n.remove());
|
||
const caption=document.createElement('span');caption.textContent='Количество гостей';label.insertBefore(caption,guests);
|
||
}
|
||
window.SunSafe.insertBefore(grid,label,grid.firstChild);
|
||
};
|
||
const removeEventCard=()=>{
|
||
moveGuestsToDetails();
|
||
q('#openEventCardBtn')?.remove();
|
||
document.querySelectorAll('.event-card-section').forEach(el=>{
|
||
el.hidden=true;
|
||
el.setAttribute('aria-hidden','true');
|
||
el.style.setProperty('display','none','important');
|
||
});
|
||
};
|
||
removeEventCard();setTimeout(removeEventCard,80);setTimeout(removeEventCard,400);
|
||
|
||
// Address disambiguation: region + locality + municipal/district context.
|
||
if(!q('#deliveryRegion')){
|
||
const title=document.createElement('div');title.className='sun-address-precision-title';title.textContent='Точный адрес доставки';
|
||
const region=document.createElement('label');region.className='sun-delivery-region';region.innerHTML=("<span>Регион</span><select id=\"deliveryRegion\"><option value=\"\"></option><option>Санкт-Петербург</option><option>Ленинградская область</option><option>Другой регион</option></select>");
|
||
const locality=document.createElement('label');locality.className='sun-delivery-locality';locality.innerHTML='<span>Город / населённый пункт</span><input id="deliveryLocality" autocomplete="address-level2">';
|
||
const district=document.createElement('label');district.className='sun-delivery-district';district.innerHTML='<span>Район / муниципалитет</span><input id="deliveryDistrict" autocomplete="address-level3">';
|
||
window.SunSafe.insertBefore(grid,title,grid.firstChild);title.insertAdjacentElement('afterend',region);region.insertAdjacentElement('afterend',locality);locality.insertAdjacentElement('afterend',district);
|
||
moveGuestsToDetails();
|
||
}
|
||
|
||
// Order-level print preference. Enabled by default for backward compatibility.
|
||
if(!q('#orderPrintMap')){
|
||
const label=document.createElement('label');label.className='sun-print-map-option';
|
||
label.innerHTML='<input id="orderPrintMap" type="checkbox" checked><span><b>Печатать карту в бланке заказа</b><small>Снимите галочку, если нужен бланк без карты.</small></span>';
|
||
grid.appendChild(label);
|
||
}
|
||
|
||
const region=q('#deliveryRegion'),locality=q('#deliveryLocality'),district=q('#deliveryDistrict'),printMap=q('#orderPrintMap');
|
||
const street=q('#deliveryStreet'),house=q('#deliveryHouse'),entrance=q('#deliveryEntrance'),floor=q('#deliveryFloor'),apartment=q('#deliveryApartment'),office=q('#deliveryOffice');
|
||
const val=el=>String(el?.value||'').trim();
|
||
const currentDraft=()=>{try{return draft}catch(_){return null}};
|
||
const currentOrders=()=>{try{return orders}catch(_){return []}};
|
||
|
||
function composePreciseAddress(){
|
||
const parts=[
|
||
val(region),val(locality),val(district),val(street),
|
||
val(house)?`д. ${val(house)}`:'',
|
||
val(entrance)?`подъезд ${val(entrance)}`:'',
|
||
val(floor)?`этаж ${val(floor)}`:'',
|
||
val(apartment)?`кв. ${val(apartment)}`:'',
|
||
val(office)?`офис ${val(office)}`:''
|
||
].filter(Boolean);
|
||
const next=parts.join(', ');
|
||
if(addressInput.value!==next){addressInput.value=next;addressInput.dispatchEvent(new Event('input',{bubbles:true}));}
|
||
return next;
|
||
}
|
||
window.sunComposeDeliveryAddress=composePreciseAddress;
|
||
[region,locality,district,street,house,entrance,floor,apartment,office].forEach(el=>el?.addEventListener('input',()=>setTimeout(composePreciseAddress,0)));
|
||
region?.addEventListener('change',composePreciseAddress);
|
||
|
||
function writeExtraToDraft(){
|
||
const d=currentDraft();if(!d)return;
|
||
d.deliveryRegion=val(region);d.deliveryLocality=val(locality);d.deliveryDistrict=val(district);
|
||
d.printMap=Boolean(printMap?.checked);
|
||
d.address=composePreciseAddress();
|
||
}
|
||
function finalizeSaved(){
|
||
writeExtraToDraft();
|
||
const d=currentDraft();if(!d?.id)return;
|
||
const saved=currentOrders().find(o=>String(o.id)===String(d.id));
|
||
if(saved){
|
||
saved.deliveryRegion=d.deliveryRegion;saved.deliveryLocality=d.deliveryLocality;saved.deliveryDistrict=d.deliveryDistrict;
|
||
saved.printMap=d.printMap;saved.address=d.address;
|
||
try{persist()}catch(_){try{localStorage.setItem('sunOrders',JSON.stringify(currentOrders()))}catch(__){}}
|
||
}
|
||
}
|
||
function fillExtra(){
|
||
const d=currentDraft()||{};
|
||
region.value=String(d.deliveryRegion||'');
|
||
locality.value=String(d.deliveryLocality||'');
|
||
district.value=String(d.deliveryDistrict||'');
|
||
printMap.checked=d.printMap!==false;
|
||
// For older orders, keep the legacy address untouched unless precise fields exist.
|
||
const hasPrecise=Boolean(val(region)||val(locality)||val(district));
|
||
if(hasPrecise)composePreciseAddress();
|
||
}
|
||
|
||
const prevSave=window.saveOrder;
|
||
if(typeof prevSave==='function')window.saveOrder=function(...args){
|
||
writeExtraToDraft();
|
||
const result=prevSave.apply(this,args);
|
||
if(result&&typeof result.then==='function')return result.then(v=>{finalizeSaved();return v;},e=>{throw e});
|
||
finalizeSaved();return result;
|
||
};
|
||
const prevOpen=window.openOrder;
|
||
if(typeof prevOpen==='function')window.openOrder=function(...args){const r=prevOpen.apply(this,args);setTimeout(fillExtra,0);return r;};
|
||
const prevReset=window.resetDraft;
|
||
if(typeof prevReset==='function')window.resetDraft=function(...args){const r=prevReset.apply(this,args);region.value='';locality.value='';district.value='';printMap.checked=true;const d=currentDraft();if(d){d.deliveryRegion='';d.deliveryLocality='';d.deliveryDistrict='';d.printMap=true;}return r;};
|
||
printMap.addEventListener('change',()=>{const d=currentDraft();if(d)d.printMap=Boolean(printMap.checked);});
|
||
|
||
// Enforce the saved/selected map preference in the order blank even if older map
|
||
// modules try to inject a map asynchronously after the preview opens.
|
||
function stripMapWhenDisabled(){
|
||
const d=currentDraft();if(!d||d.printMap!==false)return;
|
||
const doc=q('#preview .sun-doc');if(!doc)return;
|
||
const title=doc.querySelector('.sun-doc-title h2')?.textContent||'';
|
||
if(!/Бланк заказа/i.test(title))return;
|
||
doc.querySelectorAll('.sun-doc-map,.sun-doc-route').forEach(el=>el.remove());
|
||
}
|
||
const preview=q('#preview');
|
||
if(preview)new MutationObserver(stripMapWhenDisabled).observe(preview,{childList:true,subtree:true});
|
||
document.addEventListener('click',e=>{const b=e.target.closest('button');if(b?.textContent.trim()==='Бланк заказа')setTimeout(stripMapWhenDisabled,0);},true);
|
||
|
||
fillExtra();
|
||
})();
|
||
</script>
|
||
|
||
|
||
<!-- ===== NO-HANG BUTTON SAFETY v1 ===== -->
|
||
<style id="sun-nohang-button-safety-style">
|
||
button.sun-busy-safe{position:relative;pointer-events:none;opacity:.72}
|
||
button.sun-busy-safe:after{content:"";width:12px;height:12px;margin-left:7px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;display:inline-block;vertical-align:-2px;animation:sunSafeSpin .65s linear infinite}
|
||
@keyframes sunSafeSpin{to{transform:rotate(360deg)}}
|
||
</style>
|
||
<script id="sun-nohang-button-safety-script">
|
||
(()=>{
|
||
'use strict';
|
||
if(window.__sunNoHangButtonSafetyV1)return;window.__sunNoHangButtonSafetyV1=true;
|
||
const toast=(text,type='warn')=>{try{window.SunEnterprise?.toast?.(text,type,3500)}catch(_){}};
|
||
// Never allow an accidental double-click to launch the same heavy action twice.
|
||
const heavyText=/^(Построить маршрут|Обновить маршрут|Создать карту|Обновить карту|Отправить в облако|Загрузить из облака)$/i;
|
||
document.addEventListener('click',e=>{
|
||
const b=e.target.closest?.('button');if(!b||!heavyText.test((b.textContent||'').trim()))return;
|
||
if(b.dataset.sunBusySafe==='1'){e.preventDefault();e.stopImmediatePropagation();return;}
|
||
b.dataset.sunBusySafe='1';b.classList.add('sun-busy-safe');
|
||
// Short debounce only. Long artificial disabling used to look like a freeze
|
||
// even when the async operation had already failed or completed quickly.
|
||
setTimeout(()=>{if(!b.isConnected)return;b.dataset.sunBusySafe='0';b.classList.remove('sun-busy-safe');},1800);
|
||
},true);
|
||
// Surface runtime errors instead of leaving the UI looking frozen.
|
||
window.addEventListener('error',e=>{if(e?.message)toast('Ошибка интерфейса: '+String(e.message).slice(0,120),'error')});
|
||
window.addEventListener('unhandledrejection',e=>{const m=e?.reason?.message||String(e?.reason||'');if(m)toast('Операция не завершена: '+m.slice(0,120),'warn')});
|
||
})();
|
||
</script>
|
||
|
||
|
||
<!-- ===== RECEIPT SETTINGS + CLEAN DETAILS + PDF ACTION v1 ===== -->
|
||
<style id="sun-receipt-settings-clean-style-v2">
|
||
#orderEconomyPanel{display:none!important}
|
||
#details #sunReceiptBuyerCard{display:none!important}
|
||
.sun-receipt-settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}
|
||
.sun-receipt-settings-grid .wide{grid-column:1/-1}
|
||
.sun-receipt-settings-checks{display:flex;gap:14px;flex-wrap:wrap;margin:8px 0 12px}
|
||
.sun-receipt-settings-checks label{display:flex;flex-direction:row;align-items:center;gap:7px;color:#394653}
|
||
.sun-receipt-settings-checks input{width:17px;height:17px}
|
||
.sun-receipt-settings-section{border:1px solid #e3e6ea;border-radius:12px;padding:14px;margin-top:12px;background:#fafbfc}
|
||
.sun-receipt-settings-section h3{margin:0 0 10px;color:#15364c;font-size:15px}
|
||
.sun-receipt-party{margin:12px 0;padding:12px;border:1px solid #dde2e6;border-radius:10px;background:#fafbfc}
|
||
.sun-receipt-party h3{margin:0 0 8px;color:#15364c;font-size:14px}
|
||
.sun-receipt-party-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px 18px;font-size:12px}
|
||
.sun-receipt-party-grid div{padding:3px 0;border-bottom:1px solid #edf0f2}
|
||
.sun-receipt-party-grid span{display:block;color:#7b858d;font-size:10px;text-transform:uppercase;letter-spacing:.04em;margin-bottom:2px}
|
||
.sun-receipt-party-grid b{font-weight:600;color:#203040;overflow-wrap:anywhere}
|
||
.sun-catalog-pdf-button{white-space:nowrap}
|
||
|
||
#sunCatalogPdfPrintRoot{display:none}
|
||
@media print{
|
||
body.sun-print-catalog-pdf *{visibility:hidden!important}
|
||
body.sun-print-catalog-pdf #sunCatalogPdfPrintRoot,
|
||
body.sun-print-catalog-pdf #sunCatalogPdfPrintRoot *{visibility:visible!important}
|
||
body.sun-print-catalog-pdf #sunCatalogPdfPrintRoot{display:block!important;position:absolute!important;inset:0!important;background:#fff!important;padding:0!important}
|
||
.sun-catalog-pdf-page{page-break-after:always;break-after:page;width:100%;height:277mm;padding:10mm 12mm;display:flex;flex-direction:column;align-items:center;justify-content:flex-start;overflow:hidden;font-family:Arial,sans-serif;color:#17222d;background:#fff}
|
||
.sun-catalog-pdf-page:last-child{page-break-after:auto;break-after:auto}
|
||
.sun-catalog-pdf-head{width:100%;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #d9dde0;padding-bottom:5mm;margin-bottom:7mm}
|
||
.sun-catalog-pdf-brand{display:flex;align-items:center;gap:4mm;color:#15364c;font-weight:700;font-size:14pt}
|
||
.sun-catalog-pdf-brand img{width:24mm;height:15mm;object-fit:contain}
|
||
.sun-catalog-pdf-category{font-size:10pt;color:#69727c}
|
||
.sun-catalog-pdf-photo{width:100%;height:190mm;object-fit:contain;object-position:center;background:#fff}
|
||
.sun-catalog-pdf-name{width:100%;font-size:20pt;line-height:1.15;color:#15364c;font-weight:800;text-align:center;margin-top:7mm}
|
||
.sun-catalog-pdf-meta{width:100%;display:flex;justify-content:center;gap:8mm;flex-wrap:wrap;margin-top:3mm;font-size:11pt;color:#68717a;text-align:center}
|
||
.sun-catalog-pdf-price{font-size:16pt;font-weight:800;color:#15364c;margin-top:3mm}
|
||
@page{size:A4 portrait;margin:0}
|
||
}
|
||
@media(max-width:700px){.sun-receipt-settings-grid,.sun-receipt-party-grid{grid-template-columns:1fr}.sun-receipt-settings-grid .wide{grid-column:auto}}
|
||
</style>
|
||
<script id="sun-receipt-settings-clean-script-v2">
|
||
(()=>{
|
||
'use strict';
|
||
if(window.__sunReceiptSettingsCleanV2)return;window.__sunReceiptSettingsCleanV2=true;
|
||
const $=id=>document.getElementById(id);
|
||
const qa=(s,r=document)=>[...r.querySelectorAll(s)];
|
||
const esc=window.SunSafe.escapeHTML;
|
||
const money=v=>`${Number(v||0).toLocaleString('ru-RU')} ₽`;
|
||
const SETTINGS_KEY='sunReceiptSettingsV2';
|
||
const LEGACY_KEY='sunReceiptSettingsV1';
|
||
const defaults=()=>({
|
||
includeSeller:true,includeBuyer:true,
|
||
sellerName:(window.CateriumBranding.documentName()),sellerLegalName:'',sellerInn:'',sellerKpp:'',sellerOgrn:'',sellerLegalAddress:'',sellerPhone:'',sellerEmail:'',sellerBank:'',sellerBik:'',sellerAccount:'',sellerCorrAccount:'',
|
||
buyerName:'',buyerInn:'',buyerKpp:'',buyerOgrn:'',buyerLegalAddress:'',buyerPhone:'',buyerEmail:'',buyerBank:'',buyerBik:'',buyerAccount:'',buyerCorrAccount:'',buyerContact:''
|
||
});
|
||
function readSettings(){
|
||
try{
|
||
const current=JSON.parse(localStorage.getItem(SETTINGS_KEY)||'null');
|
||
if(current&&typeof current==='object')return {...defaults(),...current};
|
||
const legacy=JSON.parse(localStorage.getItem(LEGACY_KEY)||'null');
|
||
return legacy&&typeof legacy==='object'?{...defaults(),...legacy}:defaults();
|
||
}catch(_){return defaults()}
|
||
}
|
||
const saveSettings=v=>{try{localStorage.setItem(SETTINGS_KEY,JSON.stringify({...defaults(),...(v||{})}))}catch(_){}};
|
||
|
||
/* Details should contain only order/delivery data. Company requisites live in Settings. */
|
||
const removeBuyerCard=()=>{$('sunReceiptBuyerCard')?.remove()};
|
||
const hideEconomy=()=>{const p=$('orderEconomyPanel');if(p)p.style.setProperty('display','none','important')};
|
||
removeBuyerCard();hideEconomy();
|
||
const cleanupRoot=$('details');if(cleanupRoot){let cleanupQueued=false;const cleanupObserver=new MutationObserver(()=>{if(cleanupQueued)return;cleanupQueued=true;requestAnimationFrame(()=>{cleanupQueued=false;removeBuyerCard();hideEconomy();});});cleanupObserver.observe(cleanupRoot,{childList:true,subtree:true});}
|
||
|
||
function injectReceiptSettings(){
|
||
const view=$('enterprise-settings');const grid=view?.querySelector('.enterprise-grid');if(!grid)return;
|
||
$('sunReceiptSettingsCard')?.remove();
|
||
const s=readSettings();
|
||
const card=document.createElement('section');card.className='enterprise-card wide';card.id='sunReceiptSettingsCard';
|
||
card.innerHTML=`<h2>Настройки реквизитов товарного чека</h2><p class="hint">Все реквизиты настраиваются здесь. В «Деталях» заказа они больше не показываются.</p>
|
||
<div class="sun-receipt-settings-checks"><label><input id="receiptIncludeSeller" type="checkbox" ${s.includeSeller?'checked':''}> Печатать продавца</label><label><input id="receiptIncludeBuyer" type="checkbox" ${s.includeBuyer?'checked':''}> Печатать покупателя</label></div>
|
||
<section class="sun-receipt-settings-section"><h3>Продавец — ваша компания</h3><div class="sun-receipt-settings-grid">
|
||
<label>Название бренда<input id="receiptSellerName" value="${esc(s.sellerName)}"></label><label>Юр. название / ИП<input id="receiptSellerLegalName" value="${esc(s.sellerLegalName)}"></label>
|
||
<label>ИНН<input id="receiptSellerInn" value="${esc(s.sellerInn)}"></label><label>КПП<input id="receiptSellerKpp" value="${esc(s.sellerKpp)}"></label><label>ОГРН / ОГРНИП<input id="receiptSellerOgrn" value="${esc(s.sellerOgrn)}"></label><label>Телефон<input id="receiptSellerPhone" value="${esc(s.sellerPhone)}"></label>
|
||
<label class="wide">Юридический адрес<input id="receiptSellerLegalAddress" value="${esc(s.sellerLegalAddress)}"></label><label class="wide">E-mail<input id="receiptSellerEmail" value="${esc(s.sellerEmail)}"></label><label class="wide">Банк<input id="receiptSellerBank" value="${esc(s.sellerBank)}"></label>
|
||
<label>БИК<input id="receiptSellerBik" value="${esc(s.sellerBik)}"></label><label>Расчётный счёт<input id="receiptSellerAccount" value="${esc(s.sellerAccount)}"></label><label class="wide">Корреспондентский счёт<input id="receiptSellerCorrAccount" value="${esc(s.sellerCorrAccount)}"></label>
|
||
</div></section>
|
||
<section class="sun-receipt-settings-section"><h3>Покупатель — компания заказчика</h3><div class="sun-receipt-settings-grid">
|
||
<label>Компания / ИП<input id="receiptBuyerNameSetting" value="${esc(s.buyerName)}"></label><label>Контактное лицо<input id="receiptBuyerContactSetting" value="${esc(s.buyerContact)}"></label>
|
||
<label>ИНН<input id="receiptBuyerInnSetting" value="${esc(s.buyerInn)}"></label><label>КПП<input id="receiptBuyerKppSetting" value="${esc(s.buyerKpp)}"></label><label>ОГРН / ОГРНИП<input id="receiptBuyerOgrnSetting" value="${esc(s.buyerOgrn)}"></label><label>Телефон<input id="receiptBuyerPhoneSetting" value="${esc(s.buyerPhone)}"></label>
|
||
<label class="wide">Юридический адрес<input id="receiptBuyerLegalAddressSetting" value="${esc(s.buyerLegalAddress)}"></label><label class="wide">E-mail<input id="receiptBuyerEmailSetting" value="${esc(s.buyerEmail)}"></label><label class="wide">Банк<input id="receiptBuyerBankSetting" value="${esc(s.buyerBank)}"></label>
|
||
<label>БИК<input id="receiptBuyerBikSetting" value="${esc(s.buyerBik)}"></label><label>Расчётный счёт<input id="receiptBuyerAccountSetting" value="${esc(s.buyerAccount)}"></label><label class="wide">Корреспондентский счёт<input id="receiptBuyerCorrAccountSetting" value="${esc(s.buyerCorrAccount)}"></label>
|
||
</div></section>
|
||
<div class="actions"><button class="primary" id="receiptSettingsSave" type="button">Сохранить реквизиты</button></div>`;
|
||
window.SunSafe.insertBefore(grid,card,grid.lastElementChild||null);
|
||
$('receiptSettingsSave').onclick=()=>{
|
||
saveSettings({
|
||
includeSeller:$('receiptIncludeSeller').checked,includeBuyer:$('receiptIncludeBuyer').checked,
|
||
sellerName:$('receiptSellerName').value.trim(),sellerLegalName:$('receiptSellerLegalName').value.trim(),sellerInn:$('receiptSellerInn').value.trim(),sellerKpp:$('receiptSellerKpp').value.trim(),sellerOgrn:$('receiptSellerOgrn').value.trim(),sellerLegalAddress:$('receiptSellerLegalAddress').value.trim(),sellerPhone:$('receiptSellerPhone').value.trim(),sellerEmail:$('receiptSellerEmail').value.trim(),sellerBank:$('receiptSellerBank').value.trim(),sellerBik:$('receiptSellerBik').value.trim(),sellerAccount:$('receiptSellerAccount').value.trim(),sellerCorrAccount:$('receiptSellerCorrAccount').value.trim(),
|
||
buyerName:$('receiptBuyerNameSetting').value.trim(),buyerContact:$('receiptBuyerContactSetting').value.trim(),buyerInn:$('receiptBuyerInnSetting').value.trim(),buyerKpp:$('receiptBuyerKppSetting').value.trim(),buyerOgrn:$('receiptBuyerOgrnSetting').value.trim(),buyerLegalAddress:$('receiptBuyerLegalAddressSetting').value.trim(),buyerPhone:$('receiptBuyerPhoneSetting').value.trim(),buyerEmail:$('receiptBuyerEmailSetting').value.trim(),buyerBank:$('receiptBuyerBankSetting').value.trim(),buyerBik:$('receiptBuyerBikSetting').value.trim(),buyerAccount:$('receiptBuyerAccountSetting').value.trim(),buyerCorrAccount:$('receiptBuyerCorrAccountSetting').value.trim()
|
||
});
|
||
window.SunEnterprise?.toast?.('Реквизиты товарного чека сохранены.','success');
|
||
};
|
||
}
|
||
const settingsBtn=qa('header nav button').find(b=>String(b.dataset.navLabel||b.textContent||'').trim()==='Настройки');
|
||
settingsBtn?.addEventListener('click',()=>setTimeout(injectReceiptSettings,20));
|
||
if($('enterprise-settings')?.classList.contains('on'))setTimeout(injectReceiptSettings,0);
|
||
|
||
function fieldsHtml(title,rows){const filtered=rows.filter(([,v])=>String(v||'').trim());if(!filtered.length)return'';return `<section class="sun-receipt-party"><h3>${esc(title)}</h3><div class="sun-receipt-party-grid">${filtered.map(([k,v])=>`<div><span>${esc(k)}</span><b>${esc(v)}</b></div>`).join('')}</div></section>`;}
|
||
function currentDraft(){try{return draft}catch(_){return null}}
|
||
function currentRows(){const d=currentDraft()||{};return(d.lines||[]).map((line,index)=>{const item=(typeof boxes!=='undefined'?boxes:[]).find(x=>String(x.id)===String(line.id));const direct=Number(line?.price),price=Number.isFinite(direct)&&direct>=0?direct:Number(item?.price||0),qty=Math.max(0,Number(line.qty||0));return{index:index+1,name:item?.name||line?.name||`Позиция ${line?.id||''}`,qty,price,sum:price*qty};}).filter(x=>x.qty>0)}
|
||
function docDate(raw){if(!raw)return'—';const d=new Date(`${raw}T00:00:00`);return Number.isNaN(d.getTime())?raw:d.toLocaleDateString('ru-RU')}
|
||
function receiptActions(){return `<div class="sun-doc-actions"><button class="primary" type="button" onclick="window.print()">Печать</button><button class="outline" type="button" onclick="closeModal('preview')">Закрыть</button></div>`;}
|
||
function showConfiguredReceipt(){
|
||
const d=currentDraft()||{},rows=currentRows(),total=rows.reduce((sum,row)=>sum+row.sum,0),paid=Math.max(0,Number($('prepayment')?.value||d.prepayment||0)),balance=Math.max(0,total-paid),s=readSettings();
|
||
const sellerBlock=s.includeSeller?fieldsHtml('Продавец',[["Наименование",s.sellerLegalName||s.sellerName],["ИНН",s.sellerInn],["КПП",s.sellerKpp],["ОГРН / ОГРНИП",s.sellerOgrn],["Юридический адрес",s.sellerLegalAddress],["Телефон",s.sellerPhone],["E-mail",s.sellerEmail],["Банк",s.sellerBank],["БИК",s.sellerBik],["Расчётный счёт",s.sellerAccount],["Корр. счёт",s.sellerCorrAccount]]):'';
|
||
const buyerBlock=s.includeBuyer?fieldsHtml('Покупатель',[["Компания / ИП",s.buyerName],["ИНН",s.buyerInn],["КПП",s.buyerKpp],["ОГРН / ОГРНИП",s.buyerOgrn],["Юридический адрес",s.buyerLegalAddress],["Контактное лицо",s.buyerContact],["Телефон",s.buyerPhone],["E-mail",s.buyerEmail],["Банк",s.buyerBank],["БИК",s.buyerBik],["Расчётный счёт",s.buyerAccount],["Корр. счёт",s.buyerCorrAccount]]):'';
|
||
const dialog=document.querySelector('#preview .dialog');if(!dialog)return;
|
||
dialog.innerHTML=`<div class="sun-doc sun-receipt"><div class="sun-doc-head"><div class="sun-doc-brand"><img src="${window.CateriumBranding.logoHTML()}" alt="${window.CateriumBranding.nameHTML()}"><div><strong>${esc(s.sellerName||(window.CateriumBranding.documentName()))}</strong><small>${esc(s.sellerLegalName||'')}</small></div></div><div class="sun-doc-title"><h2>Товарный чек № ${esc(d.id||'—')}</h2><p>${esc(docDate($('date')?.value||d.date))} · ${esc($('time')?.value||d.time||'')}</p></div></div>${sellerBlock}${buyerBlock}<table><thead><tr><th class="doc-num">№</th><th class="doc-name">Наименование</th><th>Цена</th><th>Кол.</th><th>Сумма</th></tr></thead><tbody>${rows.length?rows.map(r=>`<tr><td class="doc-num">${r.index}</td><td class="doc-name">${esc(r.name)}</td><td>${money(r.price)}</td><td>${r.qty}</td><td>${money(r.sum)}</td></tr>`).join(''):'<tr><td colspan="5">В заказе нет позиций.</td></tr>'}</tbody></table><div class="sun-doc-summary"><p><span>Итого</span><b>${money(total)}</b></p><p><span>Оплачено</span><b>${money(paid)}</b></p><p class="grand"><span>К оплате</span><span>${money(balance)}</span></p></div>${$('note')?.value?`<div class="sun-doc-note"><b>Примечание</b>${esc($('note').value)}</div>`:''}<div class="sun-doc-footer"><div><div class="sun-doc-sign">Продавец / подпись</div></div><div><div class="sun-doc-sign">Покупатель / подпись</div></div></div>${receiptActions()}</div>`;
|
||
window.modal?.('preview');
|
||
}
|
||
window.sunShowConfiguredReceipt=showConfiguredReceipt;
|
||
function patchReceiptButton(){qa('#new .actions button').filter(b=>b.textContent.trim()==='Товарный чек').forEach(b=>{b.onclick=showConfiguredReceipt;b.dataset.sunReceiptConfigured='2';});}
|
||
patchReceiptButton();setTimeout(patchReceiptButton,100);setTimeout(patchReceiptButton,500);
|
||
const actionObserver=new MutationObserver(patchReceiptButton);const actions=[...document.querySelectorAll('#new .actions')].find(a=>[...a.querySelectorAll('button')].some(b=>b.textContent.trim()==='Сохранить'));if(actions)actionObserver.observe(actions,{childList:true,subtree:true});
|
||
|
||
/* If older code adds “Скачать PDF” to a receipt preview, remove it only from the receipt. */
|
||
const preview=$('preview');
|
||
const stripReceiptPdf=()=>{if(preview?.querySelector('.sun-receipt'))qa('.sun-pdf-download',preview).forEach(b=>b.remove())};
|
||
const previewObserver=new MutationObserver(()=>setTimeout(stripReceiptPdf,0));if(preview)previewObserver.observe(preview,{attributes:true,childList:true,subtree:true,attributeFilter:['class']});
|
||
|
||
/* Catalog PDF: current category, one full-photo item per A4 page. */
|
||
function currentCatalogCategory(){
|
||
const active=qa('#new .sun-catalog-top-toolbar .cats button,#new .cats button').find(b=>b.style.outline&&b.style.outline!=='none')||qa('#new .sun-catalog-top-toolbar .cats button,#new .cats button').find(b=>b.getAttribute('aria-current')==='true');
|
||
if(active?.dataset.category!=null)return Number(active.dataset.category);
|
||
const title=document.querySelector('#new .catalog h1')?.textContent?.trim()||'';
|
||
const map={'Боксы':0,'Премиум':5,'Посуда':1,'Дополнения':2,'Напитки':3,'Доставка':4};
|
||
return Object.entries(map).find(([name])=>title.startsWith(name))?.[1]??0;
|
||
}
|
||
function categoryName(cat){return ({0:'Боксы',1:'Посуда',2:'Дополнения',3:'Напитки',4:'Доставка',5:'Премиум'})[cat]||'Каталог'}
|
||
function getCatalogItems(cat){try{return (Array.isArray(boxes)?boxes:[]).filter(x=>Number(x.category||0)===Number(cat))}catch(_){return[]}}
|
||
function ensureCatalogPdfButton(){
|
||
const head=document.querySelector('#new .catalog .catalog-head');const configure=qa('button',head||document).find(b=>b.textContent.trim()==='Настроить');if(!head||!configure)return;
|
||
let btn=$('sunCatalogPdfButton');if(!btn){btn=document.createElement('button');btn.id='sunCatalogPdfButton';btn.type='button';btn.className='outline sun-catalog-pdf-button';btn.textContent='Скачать PDF';btn.title='Текущий раздел: одна позиция на одной странице';configure.insertAdjacentElement('beforebegin',btn);}
|
||
btn.onclick=printCatalogPdf;
|
||
const cat=currentCatalogCategory();btn.hidden=cat===4;btn.style.display=cat===4?'none':'';
|
||
}
|
||
function printCatalogPdf(){
|
||
const cat=currentCatalogCategory(),items=getCatalogItems(cat).filter(item=>item.photo);
|
||
if(!items.length){window.SunEnterprise?.toast?.('В этом разделе нет позиций с фотографиями.','warn');return;}
|
||
let root=$('sunCatalogPdfPrintRoot');if(!root){root=document.createElement('div');root.id='sunCatalogPdfPrintRoot';document.body.appendChild(root);}
|
||
root.innerHTML=items.map(item=>`<article class="sun-catalog-pdf-page"><div class="sun-catalog-pdf-head"><div class="sun-catalog-pdf-brand"><img src="${window.CateriumBranding.logoHTML()}" alt=""><span>${window.CateriumBranding.nameHTML()}</span></div><div class="sun-catalog-pdf-category">${esc(categoryName(cat))}</div></div><img class="sun-catalog-pdf-photo" src="${esc(item.photo)}" alt="${esc(item.name||'')}"><div class="sun-catalog-pdf-name">${esc(item.name||'')}</div><div class="sun-catalog-pdf-meta">${item.catalogSection?`<span>${esc(item.catalogSection)}</span>`:''}${item.weight?`<span>${esc(item.weight)}</span>`:''}</div><div class="sun-catalog-pdf-price">${money(item.price||0)}${Number(item.oldPrice||0)>Number(item.price||0)?` <span style="font-size:.72em;color:#8e949d;text-decoration:line-through;font-weight:500">${money(item.oldPrice)}</span>`:""}</div></article>`).join('');
|
||
document.body.classList.add('sun-print-catalog-pdf');
|
||
const cleanup=()=>document.body.classList.remove('sun-print-catalog-pdf');
|
||
window.addEventListener('afterprint',cleanup,{once:true});
|
||
setTimeout(()=>{window.print();setTimeout(cleanup,800);},40);
|
||
}
|
||
ensureCatalogPdfButton();setTimeout(ensureCatalogPdfButton,100);setTimeout(ensureCatalogPdfButton,500);
|
||
document.addEventListener('click',e=>{if(e.target.closest('#new .cats button,#new .sun-catalog-top-toolbar .cats button'))setTimeout(ensureCatalogPdfButton,0)},true);
|
||
})();
|
||
</script>
|
||
|
||
|
||
|
||
<!-- ===== PRINT / DETAILS / STATUS / CALENDAR POLISH v2 ===== -->
|
||
<style id="sun-print-details-status-polish-v2">
|
||
/* Details: keep legacy compatibility nodes, but remove them from the manager UI. */
|
||
#details .sun-address-precision-title,
|
||
#details .sun-delivery-region,
|
||
#details .sun-delivery-locality,
|
||
#details .sun-delivery-district,
|
||
#details .sun-print-map-option{display:none!important}
|
||
|
||
/* Prepayment should be a plain numeric field without browser spinner arrows. */
|
||
#prepayment{-moz-appearance:textfield!important;appearance:textfield!important}
|
||
#prepayment::-webkit-outer-spin-button,#prepayment::-webkit-inner-spin-button{-webkit-appearance:none!important;margin:0!important}
|
||
|
||
/* Status is an optional global feature. Default: completely hidden. */
|
||
body.sun-status-disabled #details .sun-order-status-wrap,
|
||
body.sun-status-disabled #details #orderStatusOptional,
|
||
body.sun-status-disabled #details #orderStatusClear,
|
||
body.sun-status-disabled #sunOrderCards .sun-order-card-status,
|
||
body.sun-status-disabled .sun-order-view-status,
|
||
body.sun-status-disabled .client-order-status,
|
||
body.sun-status-disabled .geo-order-status,
|
||
body.sun-status-disabled .sun-route-status,
|
||
body.sun-status-disabled .dash3-status,
|
||
body.sun-status-disabled .dash4-next-status,
|
||
body.sun-status-disabled #ordersList select,
|
||
body.sun-status-disabled #calendar .cal-status,
|
||
body.sun-status-disabled #order-map-view #mapStatus,
|
||
body.sun-status-disabled #order-map-view #mapStatus+*,
|
||
body.sun-status-disabled #order-map-view .static-map-badge,
|
||
body.sun-status-disabled #route-map-view #routeStatus,
|
||
body.sun-status-disabled #route-map-view #routeStatus+*,
|
||
body.sun-status-disabled .sun-doc-badge{display:none!important}
|
||
body.sun-status-disabled #order-map-view label:has(#mapStatus),
|
||
body.sun-status-disabled #route-map-view label:has(#routeStatus){display:none!important}
|
||
body.sun-status-disabled #orders .head span:nth-child(6){display:none!important}
|
||
body.sun-status-disabled #orders .head{grid-template-columns:65px 1.15fr 145px 1fr 110px 150px!important}
|
||
body.sun-status-disabled #ordersList .row{grid-template-columns:65px 1.15fr 145px 1fr 110px 150px!important}
|
||
|
||
/* New corpus field follows the existing compact Details grid. */
|
||
#details .sun-delivery-corpus{min-width:0}
|
||
|
||
/* Print center additions. */
|
||
.sun-print-extra-card{margin-top:12px}
|
||
.sun-print-extra-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 12px}
|
||
.sun-print-extra-grid .print-check{padding:8px 9px;border:1px solid #e7e8ed;border-radius:9px;background:#fafbfc}
|
||
@media(max-width:720px){.sun-print-extra-grid{grid-template-columns:1fr}}
|
||
|
||
/* Group preparations by box. */
|
||
.prep-box-groups{display:grid;gap:12px}
|
||
.prep-box-group{border:1px solid #e0e3e8;border-radius:10px;padding:11px;break-inside:avoid;background:#fff}
|
||
.prep-box-group-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:8px}
|
||
.prep-box-group-head h3{margin:0!important;font-size:15px!important}
|
||
.prep-box-group-head b{color:#6e5f36;white-space:nowrap}
|
||
.prep-box-group-body{display:grid;grid-template-columns:minmax(0,1fr) 125px;gap:10px;align-items:start}
|
||
.prep-box-group .prep-list{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||
.prep-box-group-photo img{width:100%;height:105px;object-fit:cover;border-radius:7px;background:#f1f2f4}
|
||
.prep-box-group-photo small{display:block;margin-top:4px;color:#737b86;text-align:center}
|
||
@media(max-width:720px){.prep-box-group-body{grid-template-columns:1fr}.prep-box-group .prep-list{grid-template-columns:1fr}.prep-box-group-photo{max-width:180px}}
|
||
|
||
/* Dedicated calendar print sheet: one landscape A4 page. */
|
||
#sunCalendarPrintRoot{display:none}
|
||
@media print{
|
||
@page{size:A4 landscape;margin:5mm}
|
||
body.sun-print-calendar-v2>*:not(#sunCalendarPrintRoot){display:none!important}
|
||
body.sun-print-calendar-v2{background:#fff!important;margin:0!important;padding:0!important;overflow:visible!important}
|
||
body.sun-print-calendar-v2 #sunCalendarPrintRoot{display:block!important;width:277mm!important;height:190mm!important;overflow:hidden!important;background:#fff!important;color:#20242d!important;zoom:var(--sun-cal-print-scale,1)}
|
||
#sunCalendarPrintRoot .sun-cal-print-head{display:flex;justify-content:space-between;align-items:end;border-bottom:1px solid #bbb;padding-bottom:2mm;margin-bottom:2mm}
|
||
#sunCalendarPrintRoot .sun-cal-print-head h1{font-size:15pt;margin:0}
|
||
#sunCalendarPrintRoot .sun-cal-print-head b{font-size:10pt}
|
||
#sunCalendarPrintRoot .cal-scroll{overflow:visible!important}
|
||
#sunCalendarPrintRoot .cal-month{display:grid!important;grid-template-columns:repeat(7,1fr)!important;grid-template-rows:7mm repeat(6,27mm)!important;gap:0!important;border:1px solid #cfd3d8!important;height:169mm!important}
|
||
#sunCalendarPrintRoot .cal-weekday{font-size:8pt!important;padding:1.5mm!important;background:#f2f3f5!important;border:0!important;border-right:1px solid #d9dde1!important}
|
||
#sunCalendarPrintRoot .cal-day{min-height:0!important;height:27mm!important;padding:1.2mm!important;border-radius:0!important;border:0!important;border-right:1px solid #d9dde1!important;border-top:1px solid #d9dde1!important;overflow:hidden!important}
|
||
#sunCalendarPrintRoot .cal-date{margin-bottom:.7mm!important}.cal-date span{font-size:8pt!important}
|
||
#sunCalendarPrintRoot .cal-event{padding:1mm!important;margin:.5mm 0!important;border-radius:1mm!important;min-height:0!important}
|
||
#sunCalendarPrintRoot .cal-event b{font-size:6.5pt!important;line-height:1.05!important}
|
||
#sunCalendarPrintRoot .cal-event small{font-size:5.8pt!important;line-height:1.05!important;white-space:nowrap!important;overflow:hidden!important;text-overflow:ellipsis!important}
|
||
#sunCalendarPrintRoot .cal-count,#sunCalendarPrintRoot .cal-more{font-size:5.5pt!important}
|
||
#sunCalendarPrintRoot .cal-agenda{display:grid!important;grid-template-columns:repeat(2,minmax(0,1fr))!important;gap:2mm!important}
|
||
#sunCalendarPrintRoot .cal-agenda-day{break-inside:avoid!important;margin:0!important}
|
||
#sunCalendarPrintRoot .cal-agenda-date{font-size:8pt!important;padding:1mm 0!important}
|
||
#sunCalendarPrintRoot .cal-agenda-order{grid-template-columns:15mm minmax(0,1fr)!important;gap:1mm!important;padding:1mm!important;font-size:6.4pt!important}
|
||
#sunCalendarPrintRoot .cal-agenda-order button,#sunCalendarPrintRoot .cal-status{display:none!important}
|
||
#sunCalendarPrintRoot .cal-agenda-order small{font-size:5.8pt!important}
|
||
}
|
||
</style>
|
||
<script id="sun-print-details-status-polish-script-v2">
|
||
(()=>{
|
||
'use strict';
|
||
if(window.__sunPrintDetailsStatusPolishV2)return;window.__sunPrintDetailsStatusPolishV2=true;
|
||
const $=id=>document.getElementById(id),qa=(s,r=document)=>[...r.querySelectorAll(s)];
|
||
const getDraft=()=>{try{return draft}catch(_){return null}};
|
||
const getOrders=()=>{try{return orders}catch(_){return[]}};
|
||
const getBoxes=()=>{try{return boxes}catch(_){return[]}};
|
||
const esc=window.SunSafe.escapeHTML;
|
||
const fmtQty=n=>{const x=Number(n||0);return Number.isInteger(x)?String(x):x.toLocaleString('ru-RU',{maximumFractionDigits:2})};
|
||
|
||
/* 1) Details address: hide region/locality/district and add Corpus beside House. */
|
||
const grid=document.querySelector('#details .sun-delivery-details');
|
||
let corpus=$('deliveryCorpus');
|
||
if(grid&&!corpus){
|
||
const house=$('deliveryHouse')?.closest('label');
|
||
const label=document.createElement('label');label.className='sun-delivery-corpus';label.innerHTML='<span>Корпус</span><input id="deliveryCorpus" autocomplete="address-line2">';
|
||
if(house)house.insertAdjacentElement('afterend',label);else grid.appendChild(label);corpus=$('deliveryCorpus');
|
||
}
|
||
const street=$('deliveryStreet'),house=$('deliveryHouse'),entrance=$('deliveryEntrance'),floor=$('deliveryFloor'),apartment=$('deliveryApartment'),office=$('deliveryOffice'),address=$('address');
|
||
const val=el=>String(el?.value||'').trim();
|
||
function composeSimpleAddress(){
|
||
if(!address)return'';
|
||
const parts=[val(street),val(house)?`д. ${val(house)}`:'',val(corpus)?`корп. ${val(corpus)}`:'',val(entrance)?`подъезд ${val(entrance)}`:'',val(floor)?`этаж ${val(floor)}`:'',val(apartment)?`кв. ${val(apartment)}`:'',val(office)?`офис ${val(office)}`:''].filter(Boolean);
|
||
const next=parts.join(', ');
|
||
if(address.value!==next){address.value=next;address.dispatchEvent(new Event('input',{bubbles:true}));}
|
||
const d=getDraft();if(d){d.deliveryCorpus=val(corpus);d.address=next;}
|
||
return next;
|
||
}
|
||
[street,house,corpus,entrance,floor,apartment,office].forEach(el=>el?.addEventListener('input',()=>setTimeout(composeSimpleAddress,0)));
|
||
if(corpus)corpus.addEventListener('change',composeSimpleAddress);
|
||
window.sunComposeDeliveryAddress=composeSimpleAddress;
|
||
|
||
function fillCorpus(){const d=getDraft()||{};if(corpus)corpus.value=String(d.deliveryCorpus||'');setTimeout(composeSimpleAddress,0)}
|
||
const prevOpen=window.openOrder;if(typeof prevOpen==='function')window.openOrder=function(...args){const r=prevOpen.apply(this,args);setTimeout(fillCorpus,0);return r};
|
||
const prevReset=window.resetDraft;if(typeof prevReset==='function')window.resetDraft=function(...args){const r=prevReset.apply(this,args);if(corpus)corpus.value='';const d=getDraft();if(d)d.deliveryCorpus='';setTimeout(composeSimpleAddress,0);return r};
|
||
const prevSave=window.saveOrder;if(typeof prevSave==='function')window.saveOrder=function(...args){composeSimpleAddress();const d=getDraft();if(d)d.deliveryCorpus=val(corpus);const r=prevSave.apply(this,args);const finish=()=>{const cur=getDraft();if(cur?.id){const saved=getOrders().find(o=>String(o.id)===String(cur.id));if(saved){saved.deliveryCorpus=val(corpus);saved.address=composeSimpleAddress();try{persist()}catch(_){}}}};if(r&&typeof r.then==='function')return r.then(v=>{finish();return v});finish();return r};
|
||
|
||
/* 2) Status becomes an optional application-wide feature, disabled by default. */
|
||
const STATUS_KEY='sunOrderStatusFeatureEnabledV1';
|
||
const statusEnabled=()=>localStorage.getItem(STATUS_KEY)==='1';
|
||
function patchCalendarStatus(enabled){
|
||
const cal=$('calendar');if(!cal)return;
|
||
const p=cal.querySelector('.cal-head p');if(p){if(!p.dataset.fullText)p.dataset.fullText=p.textContent||'';{const next=enabled?p.dataset.fullText:'Все мероприятия, суммы и загрузка по датам.';if(p.textContent!==next)p.textContent=next;}}
|
||
qa('.cal-event',cal).forEach(ev=>{const smalls=qa('small',ev),el=smalls[1];if(!el)return;if(!el.dataset.statusFull)el.dataset.statusFull=el.textContent||'';if(enabled){if(el.textContent!==el.dataset.statusFull)el.textContent=el.dataset.statusFull;return}const next=el.dataset.statusFull.replace(/\s·\s(?:Новый|В работе|Собран|Передан курьеру|Отдан заказчику|Отменён)\s*$/,'');if(el.textContent!==next)el.textContent=next;});
|
||
}
|
||
function applyStatusFeature(){
|
||
const enabled=statusEnabled();document.body.classList.toggle('sun-status-disabled',!enabled);
|
||
for(const id of ['mapStatus','routeStatus']){const el=$(id);if(!enabled&&el&&el.value!=='all'){el.value='all';el.dispatchEvent(new Event('change',{bubbles:true}));}}
|
||
patchCalendarStatus(enabled);
|
||
qa('#stats .stats-panel').forEach(panel=>{const isStatus=/Статусы заказов/i.test(panel.querySelector('h2')?.textContent||'');if(isStatus)panel.style.setProperty('display',enabled?'':'none',enabled?'':'important');});
|
||
qa('.route-row').forEach(row=>{const cols=[...row.children],p=cols[3]?.querySelector('p');if(p){if(!p.dataset.statusText)p.dataset.statusText=p.textContent||'';p.style.display=enabled?'':'none';if(enabled&&p.dataset.statusText)p.textContent=p.dataset.statusText;}});
|
||
const preview=$('preview');if(!enabled)qa('.sun-doc-badge,.static-map-badge',preview||document).forEach(x=>x.style.setProperty('display','none','important'));
|
||
}
|
||
window.sunApplyOrderStatusFeature=applyStatusFeature;
|
||
applyStatusFeature();
|
||
let statusApplyQueued=false;const statusObserver=new MutationObserver(()=>{if(statusApplyQueued)return;statusApplyQueued=true;requestAnimationFrame(()=>{statusApplyQueued=false;applyStatusFeature();});});['calendar','stats','routeList','preview'].map($).filter(Boolean).forEach(root=>statusObserver.observe(root,{childList:true,subtree:true}));
|
||
|
||
/* 3) Settings card for enabling Status globally. */
|
||
function injectStatusSettings(){
|
||
const view=$('enterprise-settings'),grid=view?.querySelector('.enterprise-grid');if(!grid||$('sunOrderStatusFeatureCard'))return;
|
||
const card=document.createElement('section');card.className='enterprise-card wide';card.id='sunOrderStatusFeatureCard';
|
||
card.innerHTML=`<h2>Статусы заказов</h2><p class="hint">Функция выключена по умолчанию. Пока она выключена, статус нигде в приложении не показывается. После включения статусы появятся у всех заказов.</p><label class="print-check"><input id="sunOrderStatusFeatureToggle" type="checkbox" ${statusEnabled()?'checked':''}> Включить статусы заказов</label>`;
|
||
grid.appendChild(card);
|
||
$('sunOrderStatusFeatureToggle').onchange=e=>{localStorage.setItem(STATUS_KEY,e.target.checked?'1':'0');applyStatusFeature();try{window.renderOrders?.()}catch(_){};window.SunEnterprise?.toast?.(e.target.checked?'Статусы заказов включены.':'Статусы заказов скрыты.','success');};
|
||
}
|
||
document.addEventListener('click',e=>{const b=e.target.closest('header nav button');if(b&&String(b.dataset.navLabel||b.textContent||'').trim()==='Настройки')setTimeout(injectStatusSettings,20)},true);
|
||
setTimeout(injectStatusSettings,80);
|
||
|
||
/* 4) Print center: map and document options; remove QR-label document. */
|
||
const PRINT_KEY='sunPrintSettingsV1';
|
||
const readPrint=()=>Object.assign({showMapInBlank:true,showBlankPhone:true,showBlankNote:true,showBlankTotals:true},(()=>{try{return JSON.parse(localStorage.getItem(PRINT_KEY)||'{}')}catch(_){return{}}})());
|
||
const savePrint=patch=>{const next=Object.assign({},readPrint(),patch);localStorage.setItem(PRINT_KEY,JSON.stringify(next));return next};
|
||
function syncHiddenOrderMapSetting(){const s=readPrint(),cb=$('orderPrintMap'),d=getDraft();if(cb){cb.checked=s.showMapInBlank;cb.dispatchEvent(new Event('change',{bubbles:true}));}if(d)d.printMap=s.showMapInBlank;}
|
||
function injectPrintOptions(){
|
||
const view=$('sun-print-center');if(!view)return;
|
||
const type=$('printType');if(type){qa('option',type).filter(o=>o.value==='labels'||/Этикетки/i.test(o.textContent)).forEach(o=>o.remove());}
|
||
const intro=view.querySelector('.print-center-head .hint');if(intro&&intro.textContent!=='Бланки, чеки, заготовки, фотографии и лист курьеру.')intro.textContent='Бланки, чеки, заготовки, фотографии и лист курьеру.';
|
||
$('sunPrintExtraOptions')?.remove();
|
||
}
|
||
function applyBlankOptions(){
|
||
const doc=document.querySelector('#preview .sun-doc:not(.sun-receipt)');if(!doc)return;
|
||
const s=readPrint();syncHiddenOrderMapSetting();
|
||
qa('.sun-doc-field',doc).forEach(field=>{const label=field.querySelector('span')?.textContent?.trim()||'';if(label==='Телефон'&&!s.showBlankPhone)field.remove();});
|
||
if(!s.showBlankNote)qa('.sun-doc-note',doc).forEach(x=>x.remove());
|
||
if(!s.showBlankTotals)qa('.sun-doc-summary',doc).forEach(x=>x.remove());
|
||
if(!s.showMapInBlank)qa('.sun-doc-map,.sun-doc-route',doc).forEach(x=>x.remove());
|
||
if(!statusEnabled())qa('.sun-doc-badge',doc).forEach(x=>x.remove());
|
||
}
|
||
document.addEventListener('click',e=>{const b=e.target.closest('button');if(b?.textContent.trim()==='Бланк заказа'){syncHiddenOrderMapSetting();setTimeout(applyBlankOptions,0);setTimeout(applyBlankOptions,150)}},true);
|
||
const preview=$('preview');if(preview)new MutationObserver(()=>setTimeout(applyBlankOptions,0)).observe(preview,{childList:true,subtree:true,attributes:true,attributeFilter:['class']});
|
||
document.addEventListener('click',e=>{const b=e.target.closest('header nav button');if(b&&String(b.dataset.navLabel||b.textContent||'').trim()==='Печать')setTimeout(injectPrintOptions,30)},true);
|
||
setTimeout(injectPrintOptions,120);
|
||
const pc=$('sun-print-center');if(pc)new MutationObserver(()=>setTimeout(injectPrintOptions,0)).observe(pc,{childList:true,subtree:true});
|
||
|
||
/* 5) Preparations: group ingredients under each box, preserving photo options. */
|
||
window.openPreview=()=>{
|
||
const d=getDraft()||{lines:[]},items=getBoxes(),settings=readPrint();
|
||
const lines=(d.lines||[]).map(line=>({line,item:items.find(x=>String(x.id)===String(line.id)),qty:Math.max(0,Number(line.qty||0))})).filter(x=>x.item&&x.qty>0&&[0,5].includes(Number(x.item.category||0)));
|
||
const dialog=document.querySelector('#preview .dialog');if(!dialog)return;
|
||
const eventName=$('event')?.value||d.event||'',orderDate=$('date')?.value||d.date||'',orderTime=$('time')?.value||d.time||'';
|
||
const photoOnly=Boolean(settings.photosOnly);
|
||
const groups=lines.map(({item,qty})=>{
|
||
const ingredients=(item.ingredients||[]).filter(part=>Array.isArray(part)&&part[0]).map(part=>`<p><b>${esc(part[0])}</b><span>${esc(fmtQty(Number(part[1]||0)*qty))} ${esc(part[2]||'')}</span></p>`).join('')||'<div class="prep-empty">Состав для заготовок не указан.</div>';
|
||
const photo=item.photo?`<div class="prep-box-group-photo"><img src="${esc(item.photo)}" alt="${esc(item.name)}"><small>${esc(item.weight||'')}</small></div>`:'';
|
||
return `<section class="prep-box-group"><div class="prep-box-group-head"><h3>${esc(item.name)}</h3><b>× ${esc(qty)}</b></div><div class="prep-box-group-body">${photoOnly?'':`<div class="prep-list">${ingredients}</div>`}${photo||'<div></div>'}</div></section>`;
|
||
}).join('')||'<div class="prep-empty">В заказе нет боксов для заготовок.</div>';
|
||
dialog.innerHTML=`<div class="prep-doc"><div class="prep-doc-head"><div class="prep-doc-brand"><img src="${window.CateriumBranding.logoHTML()}" alt="${window.CateriumBranding.nameHTML()}"><div><b>${window.CateriumBranding.nameHTML()}</b><small>Производственная заготовка заказа</small></div></div><div class="prep-doc-title"><h2>Заготовки${d.id?` · заказ № ${esc(d.id)}`:''}</h2><p>${esc(eventName)}${orderDate?` · ${esc(orderDate)}`:''}${orderTime?` · ${esc(orderTime)}`:''}</p></div></div><section class="prep-doc-section"><h3>${photoOnly?'Боксы в заказе':'Заготовки по боксам'}</h3><div class="prep-box-groups">${groups}</div></section><div class="prep-doc-actions"><button class="primary" type="button" onclick="window.print()">Печать / PDF</button><button class="outline" type="button" onclick="closeModal('preview')">Закрыть</button></div></div>`;
|
||
window.modal?.('preview');
|
||
};
|
||
|
||
/* 6) Calendar print: clone only the calendar sheet and force one landscape A4 page. */
|
||
function calendarPrint(){
|
||
const cal=$('calendar');if(!cal)return;
|
||
const period=cal.querySelector('.cal-period-title')?.textContent?.trim()||'';
|
||
const countMetric=cal.querySelector('.cal-metric:first-child')?.outerHTML||'';
|
||
const metrics=countMetric?`<div class="cal-metrics cal-metrics-kitchen">${countMetric}</div>`:'';
|
||
const source=cal.querySelector('.cal-scroll')||cal.querySelector('.cal-agenda')||cal.querySelector('.cal-empty');
|
||
const clone=source?source.cloneNode(true):null;
|
||
if(clone){
|
||
clone.querySelectorAll('.amount').forEach(el=>el.remove());
|
||
clone.querySelectorAll('.cal-event').forEach(ev=>{
|
||
const smalls=[...ev.querySelectorAll('small')],finance=smalls[1];if(!finance)return;
|
||
const text=String(finance.textContent||'').trim();
|
||
const status=['Новый','В работе','Собран','Передан курьеру','Отдан заказчику','Отменён'].find(x=>text.endsWith(x));
|
||
if(status&&!document.body.classList.contains('sun-status-disabled'))finance.textContent=status;else finance.remove();
|
||
});
|
||
}
|
||
const content=clone?.outerHTML||'<div class="cal-empty">Нет данных</div>';
|
||
const w=window.open('','_blank','width=1200,height=850');if(!w){try{window.SunEnterprise?.toast?.('Браузер заблокировал окно печати. Разрешите всплывающие окна для приложения.','warn')}catch(_){}return}
|
||
const html=`<!doctype html><html lang="ru"><head><meta charset="utf-8"><title>Календарь заказов · ${esc(period)}</title><style>
|
||
*{box-sizing:border-box}html,body{margin:0;padding:0;background:#fff;color:#203040;font-family:Arial,sans-serif;-webkit-print-color-adjust:exact;print-color-adjust:exact}body{padding:6mm}.print-head{display:flex;align-items:flex-end;justify-content:space-between;gap:10mm;border-bottom:1px solid #bfc5ca;padding-bottom:2mm;margin-bottom:2.5mm}.print-head h1{margin:0;font-size:16pt;color:#15364c}.print-head b{font-size:10pt}.cal-metrics{display:grid;grid-template-columns:1fr;max-width:48mm;gap:2mm;margin:0 0 2.5mm}.cal-metric{border:1px solid #d9dde0;border-radius:2mm;padding:2mm;background:#fafafa}.cal-metric small{display:block;color:#68717a;font-size:7pt;margin-bottom:.6mm}.cal-metric b{font-size:10pt;color:#15364c}.cal-scroll{overflow:visible}.cal-month{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));grid-template-rows:7mm repeat(6,25mm);border:1px solid #cfd3d8;width:100%;overflow:hidden}.cal-weekday{padding:1.5mm;text-align:center;font-weight:700;color:#68717a;background:#f2f3f5;border-right:1px solid #d9dde1;border-bottom:1px solid #d9dde1;font-size:8pt}.cal-day{min-width:0;height:25mm;padding:1.2mm;border-right:1px solid #d9dde1;border-bottom:1px solid #d9dde1;overflow:hidden;background:#fff}.cal-day.outside{background:#fafafa;color:#999}.cal-day.today{box-shadow:inset 0 0 0 .6mm #c99a32}.cal-date{display:flex;justify-content:space-between;font-weight:700;margin-bottom:.5mm;font-size:7pt}.cal-count{font-size:5.5pt;background:#eef1f2;border-radius:9px;padding:.2mm 1mm}.cal-event{display:block;width:100%;border:0;border-left:1mm solid #c9d0d8;border-radius:1mm;background:#f7f8f8;padding:.7mm 1mm;margin:.5mm 0;text-align:left;color:#203040;overflow:hidden}.cal-event b{display:block;font-size:6.2pt;line-height:1.05;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.cal-event small{display:block;color:#68717a;font-size:5.4pt;line-height:1.05;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.cal-more{font-size:5.5pt;color:#68717a}.cal-agenda{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:2mm}.cal-agenda-day{border:1px solid #d9dde0;border-radius:2mm;overflow:hidden;break-inside:avoid}.cal-agenda-date{padding:1.5mm 2mm;background:#fafafa;border-bottom:1px solid #d9dde0;font-weight:700;color:#15364c;font-size:8pt}.cal-agenda-order{display:grid;grid-template-columns:16mm minmax(0,1fr);gap:1.5mm;align-items:center;padding:1.5mm 2mm;border-bottom:1px solid #e7e9ea;font-size:7pt;break-inside:avoid}.cal-agenda-order:last-child{border-bottom:0}.cal-agenda-order .time{font-weight:700;color:#15364c}.cal-agenda-order small{display:block;color:#68717a;font-size:6pt;margin-top:.5mm}.cal-agenda-order .amount{text-align:right;font-weight:700}.cal-agenda-order>.outline{display:none}.cal-status{display:none}.cal-empty{padding:10mm;text-align:center;color:#879198;border:1px dashed #d9dde0}.cal-print-btn,#calPrev,#calToday,#calNext,.cal-mode{display:none!important}@page{size:A4 landscape;margin:6mm}@media print{body{padding:0}.print-head{margin-top:0}}
|
||
</style></head><body><div class="print-head"><h1>Календарь заказов</h1><b>${esc(period)}</b></div>${metrics}${content}<script>window.addEventListener('load',()=>setTimeout(()=>{window.focus();window.print()},120));window.addEventListener('afterprint',()=>setTimeout(()=>window.close(),80));<\/script></body></html>`;
|
||
w.document.open();w.document.write(html);w.document.close();
|
||
}
|
||
document.addEventListener('click',e=>{const b=e.target.closest('#calPrint');if(!b)return;e.preventDefault();e.stopImmediatePropagation();calendarPrint();},true);
|
||
|
||
/* Initial cleanup/sync. */
|
||
syncHiddenOrderMapSetting();fillCorpus();injectPrintOptions();injectStatusSettings();applyStatusFeature();
|
||
})();
|
||
</script>
|
||
|
||
</body></html>
|
||
|
||
<!-- SUN CATALOG PDF OPEN/SAVE/PRINT FIX v1 -->
|
||
<style id="sun-catalog-pdf-open-style-v1">
|
||
#sunCatalogPdfButton.sun-catalog-pdf-open-btn{display:inline-flex;align-items:center;gap:6px}
|
||
</style>
|
||
<script id="sun-catalog-pdf-open-script-v1">
|
||
(()=>{
|
||
'use strict';
|
||
if(window.__sunCatalogPdfOpenV1)return;
|
||
window.__sunCatalogPdfOpenV1=true;
|
||
const PDF_PATH='catalog/solnce-catalog-one-item-per-page.pdf';
|
||
|
||
function currentCatalogCategory(){
|
||
const buttons=[...document.querySelectorAll('#new .sun-catalog-top-toolbar .cats button,#new .cats button')];
|
||
const active=buttons.find(b=>b.style.outline&&b.style.outline!=='none')||buttons.find(b=>b.getAttribute('aria-current')==='true');
|
||
if(active?.dataset?.category!=null)return Number(active.dataset.category);
|
||
const title=document.querySelector('#new .catalog h1')?.textContent?.trim()||'';
|
||
const map={'Боксы':0,'Премиум':5,'Посуда':1,'Дополнения':2,'Напитки':3,'Доставка':4};
|
||
const found=Object.entries(map).find(([name])=>title.startsWith(name));
|
||
return found?Number(found[1]):0;
|
||
}
|
||
|
||
function openCatalogPdf(event){
|
||
event?.preventDefault?.();
|
||
event?.stopPropagation?.();
|
||
const win=window.open(PDF_PATH,'_blank');
|
||
if(!win){
|
||
const a=document.createElement('a');
|
||
a.href=PDF_PATH;
|
||
a.target='_blank';
|
||
a.rel='noopener';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
}
|
||
}
|
||
|
||
function install(){
|
||
const head=document.querySelector('#new .catalog .catalog-head');
|
||
const configure=[...document.querySelectorAll('#new .catalog .catalog-head button')].find(b=>b.textContent.trim()==='Настроить');
|
||
if(!head||!configure)return;
|
||
let old=document.getElementById('sunCatalogPdfButton');
|
||
if(old && old.dataset.sunPdfOpenBound!=='1'){
|
||
old.onclick=null;
|
||
const replacement=document.createElement('button');
|
||
replacement.id='sunCatalogPdfButton';
|
||
replacement.type='button';
|
||
replacement.className=(old.className||'outline')+' sun-catalog-pdf-open-btn';
|
||
replacement.textContent='Скачать PDF';
|
||
replacement.title='Открыть готовый PDF-каталог. В просмотрщике PDF можно сохранить файл или распечатать его.';
|
||
replacement.dataset.sunPdfOpenBound='1';
|
||
replacement.addEventListener('click',openCatalogPdf);
|
||
old.replaceWith(replacement);
|
||
old=replacement;
|
||
}else if(!old){
|
||
old=document.createElement('button');
|
||
old.id='sunCatalogPdfButton';
|
||
old.type='button';
|
||
old.className='outline sun-catalog-pdf-open-btn';
|
||
old.textContent='Скачать PDF';
|
||
old.title='Открыть готовый PDF-каталог. В просмотрщике PDF можно сохранить файл или распечатать его.';
|
||
old.dataset.sunPdfOpenBound='1';
|
||
old.addEventListener('click',openCatalogPdf);
|
||
configure.insertAdjacentElement('beforebegin',old);
|
||
}
|
||
const cat=currentCatalogCategory();
|
||
old.hidden=cat===4;
|
||
old.style.display=cat===4?'none':'';
|
||
}
|
||
|
||
install();
|
||
setTimeout(install,100);
|
||
setTimeout(install,600);
|
||
document.addEventListener('click',event=>{
|
||
if(event.target.closest('#new .cats button,#new .sun-catalog-top-toolbar .cats button'))setTimeout(install,0);
|
||
},true);
|
||
const target=document.querySelector('#new .catalog');
|
||
if(target&&'MutationObserver' in window)new MutationObserver(()=>install()).observe(target,{childList:true,subtree:true});
|
||
})();
|
||
</script>
|
||
|
||
<!-- SUN MAILINGS + EVENT TYPES + PDF VIEWER STABILITY v1 -->
|
||
<style id="sun-mailings-eventtypes-pdf-style-v1">
|
||
/* Event selector */
|
||
#event.sun-event-select{width:100%;min-width:0}
|
||
.sun-event-types-list{display:grid;gap:7px;margin-top:10px}
|
||
.sun-event-type-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px;align-items:center}
|
||
.sun-event-type-row input{min-width:0}
|
||
|
||
/* Order communication channel */
|
||
.sun-order-contact-channel{min-width:0}
|
||
|
||
/* Mailings */
|
||
.sun-enterprise-sidebar header nav .nav-mailings{--sun-icon:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.9%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Crect%20x%3D%223%22%20y%3D%225%22%20width%3D%2218%22%20height%3D%2214%22%20rx%3D%223%22%2F%3E%3Cpath%20d%3D%22m4.5%207%207.5%206%207.5-6%22%2F%3E%3C%2Fsvg%3E")}
|
||
#mailings{max-width:1500px;margin:0 auto}
|
||
.sun-mailing-head{display:flex;justify-content:space-between;gap:16px;align-items:flex-start;margin-bottom:16px}
|
||
.sun-mailing-head h1{margin-bottom:4px}
|
||
.sun-mailing-note{padding:11px 13px;border:1px solid #e7ddbd;background:#fffaf0;border-radius:12px;color:#75633e;font-size:12.5px;max-width:560px}
|
||
.sun-mailing-grid{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(330px,.75fr);gap:14px;align-items:start}
|
||
.sun-mailing-card{background:#fff;border:1px solid #e0e2e8;border-radius:14px;padding:16px;box-shadow:0 2px 10px rgba(35,29,73,.05)}
|
||
.sun-mailing-card.wide{grid-column:1/-1}
|
||
.sun-mailing-card h2{margin:0 0 12px;font-size:17px}
|
||
.sun-mailing-form{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||
.sun-mailing-form .wide{grid-column:1/-1}
|
||
.sun-mailing-form textarea{min-height:140px;resize:vertical}
|
||
.sun-mailing-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
|
||
.sun-mailing-vars{display:flex;gap:6px;flex-wrap:wrap;margin-top:7px}
|
||
.sun-mailing-vars button{min-height:28px;padding:4px 8px;font-size:11px}
|
||
.sun-mailing-audience{display:grid;grid-template-columns:repeat(3,minmax(90px,1fr));gap:7px;margin-top:12px}
|
||
.sun-mailing-metric{padding:10px;background:#f7f7fb;border:1px solid #e7e7ee;border-radius:10px}
|
||
.sun-mailing-metric small{display:block;color:#7b7f8c;font-size:10px;margin-bottom:4px}
|
||
.sun-mailing-metric b{font-size:17px;color:#30284f}
|
||
.sun-mailing-template-list,.sun-mailing-history{display:grid;gap:7px}
|
||
.sun-mailing-template-row,.sun-mailing-history-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;align-items:center;padding:9px 10px;border:1px solid #e4e5eb;border-radius:10px;background:#fafafe}
|
||
.sun-mailing-template-row b,.sun-mailing-history-row b{display:block;font-size:12.5px}
|
||
.sun-mailing-template-row small,.sun-mailing-history-row small{display:block;color:#7e8390;margin-top:2px}
|
||
.sun-mailing-row-actions{display:flex;gap:5px;flex-wrap:wrap;justify-content:flex-end}
|
||
.sun-mailing-row-actions button{min-height:30px;padding:4px 8px;font-size:11px}
|
||
.sun-client-comms-panel{margin:14px 0;padding:14px;border:1px solid #dedfea;border-radius:12px;background:#fbfbfe}
|
||
.sun-client-comms-panel h3{margin:0 0 4px;font-size:15px}
|
||
.sun-client-comms-panel>.hint{margin:0 0 11px}
|
||
.sun-client-comms-grid{display:grid;grid-template-columns:1fr 1fr;gap:9px}
|
||
.sun-client-comms-grid .wide{grid-column:1/-1}
|
||
.sun-client-comms-consent{display:flex!important;align-items:center;gap:8px!important;background:#f4fbf6;border:1px solid #dceee1;border-radius:9px;padding:9px 10px!important}
|
||
.sun-client-comms-consent input{width:auto!important;margin:0!important}
|
||
.sun-mail-queue{max-height:62vh;overflow:auto;display:grid;gap:7px}
|
||
.sun-mail-queue-row{display:grid;grid-template-columns:1.1fr 100px minmax(180px,1fr) auto;gap:8px;align-items:center;padding:9px;border:1px solid #e2e4ea;border-radius:10px}
|
||
.sun-mail-queue-row small{color:#7b808b}
|
||
.sun-mail-channel{display:inline-flex;align-items:center;justify-content:center;min-height:26px;padding:3px 7px;border-radius:999px;background:#f1effa;color:#55458a;font-size:10.5px;font-weight:800}
|
||
.sun-mail-channel.whatsapp{background:#eef9f1;color:#29713d}.sun-mail-channel.telegram{background:#eef6fc;color:#27709f}.sun-mail-channel.sms{background:#fff6e8;color:#8c621d}.sun-mail-channel.email{background:#f2f0ff;color:#5d4aa8}.sun-mail-channel.max{background:#eef5ff;color:#245ea6}.sun-mail-channel.phone{background:#f5f5f5;color:#666}.sun-mail-channel.none{background:#fff0f0;color:#9a4040}
|
||
.sun-mailing-channel-settings{grid-column:1/-1;border:1px solid #e2e4ea;border-radius:10px;padding:10px;background:#fafafe}.sun-mailing-channel-settings>span{display:block;font-size:11px;font-weight:800;margin-bottom:7px;color:#555b68}.sun-mailing-channel-options{display:flex;gap:7px;flex-wrap:wrap}.sun-mailing-channel-options label{display:inline-flex;flex-direction:row;align-items:center;gap:6px;padding:7px 9px;border:1px solid #dddfea;border-radius:9px;background:#fff;font-size:11.5px}.sun-mailing-channel-options input{width:auto!important;margin:0!important}
|
||
|
||
/* Stable PDF catalog link. Keep the legacy button only as an invisible sink for old handlers. */
|
||
#sunCatalogPdfButton.sun-pdf-legacy-dummy{display:none!important;visibility:hidden!important;pointer-events:none!important;position:absolute!important;left:-99999px!important;width:1px!important;height:1px!important;overflow:hidden!important}
|
||
#sunCatalogPdfViewerLink{display:inline-flex;align-items:center;justify-content:center;gap:6px;text-decoration:none}
|
||
|
||
@media(max-width:900px){
|
||
.sun-mailing-head{display:block}.sun-mailing-note{margin-top:10px;max-width:none}
|
||
.sun-mailing-grid{grid-template-columns:1fr}.sun-mailing-card.wide{grid-column:auto}
|
||
.sun-mailing-form,.sun-client-comms-grid{grid-template-columns:1fr}.sun-mailing-form .wide,.sun-client-comms-grid .wide{grid-column:auto}
|
||
.sun-mailing-audience{grid-template-columns:1fr 1fr}
|
||
.sun-mail-queue-row{grid-template-columns:1fr auto}.sun-mail-queue-row>div:nth-child(3){grid-column:1/-1}
|
||
}
|
||
</style>
|
||
<script id="sun-mailings-eventtypes-pdf-script-v1">
|
||
(()=>{
|
||
'use strict';
|
||
if(window.__sunMailingsEventTypesPdfV1)return;
|
||
window.__sunMailingsEventTypesPdfV1=true;
|
||
const $=id=>document.getElementById(id), qa=(sel,root=document)=>[...root.querySelectorAll(sel)];
|
||
const esc=window.SunSafe.escapeHTML;
|
||
const readJson=(key,fallback)=>{try{const v=JSON.parse(localStorage.getItem(key)||'null');return v??fallback}catch(_){return fallback}};
|
||
const writeJson=(key,value)=>{try{localStorage.setItem(key,JSON.stringify(value));return true}catch(_){return false}};
|
||
const getOrders=()=>{try{return Array.isArray(orders)?orders:[]}catch(_){return[]}};
|
||
const getDraft=()=>{try{return draft}catch(_){return null}};
|
||
const toast=(msg,type='success')=>{try{window.SunEnterprise?.toast?.(msg,type)}catch(_){}};
|
||
const today=()=>new Date().toISOString().slice(0,10);
|
||
|
||
/* ---------- Event types ---------- */
|
||
const EVENT_TYPES_KEY='sunEventTypesV1';
|
||
const DEFAULT_EVENT_TYPES=['Фуршет','Доставка боксов','Кейтеринг','Банкет'];
|
||
const eventTypes=()=>{
|
||
const list=readJson(EVENT_TYPES_KEY,DEFAULT_EVENT_TYPES);
|
||
const clean=(Array.isArray(list)?list:DEFAULT_EVENT_TYPES).map(x=>String(x||'').trim()).filter(Boolean);
|
||
return [...new Set(clean.length?clean:DEFAULT_EVENT_TYPES)];
|
||
};
|
||
function installEventSelect(preferred){
|
||
let control=$('event');if(!control)return null;
|
||
const value=preferred!==undefined?String(preferred||''):String(control.value||'');
|
||
if(control.tagName!=='SELECT'){
|
||
const select=document.createElement('select');select.id='event';select.className=(control.className||'')+' sun-event-select';select.setAttribute('aria-label','Мероприятие');
|
||
control.replaceWith(select);control=select;
|
||
}
|
||
const options=['',...eventTypes()];
|
||
if(value && !options.includes(value))options.push(value);
|
||
control.innerHTML=options.map(v=>`<option value="${esc(v)}">${v?esc(v):'— Не указывать —'}</option>`).join('');
|
||
control.value=value&&options.includes(value)?value:'';
|
||
return control;
|
||
}
|
||
installEventSelect($('event')?.value||'');
|
||
|
||
function injectEventTypeSettings(){
|
||
const view=$('enterprise-settings'),grid=view?.querySelector('.enterprise-grid');if(!grid)return;
|
||
let card=$('sunEventTypesSettingsCard');
|
||
if(!card){
|
||
card=document.createElement('section');card.id='sunEventTypesSettingsCard';card.className='enterprise-card wide';
|
||
card.innerHTML=`<h2>Типы мероприятий</h2><p class="hint">Этот список появляется стрелкой в поле «Мероприятие» при оформлении заказа.</p><div class="sun-event-types-list" id="sunEventTypesRows"></div><div class="actions"><button class="outline" id="sunEventTypeAdd" type="button">Добавить тип</button><button class="primary" id="sunEventTypesSave" type="button">Сохранить список</button></div>`;
|
||
window.SunSafe.insertBefore(grid,card,grid.lastElementChild||null);
|
||
$('sunEventTypeAdd').onclick=()=>{const rows=$('sunEventTypesRows');const row=document.createElement('div');row.className='sun-event-type-row';row.innerHTML=`<input class="sun-event-type-input" value=""><button class="outline" type="button">Убрать</button>`;row.querySelector('button').onclick=()=>row.remove();rows.appendChild(row);row.querySelector('input').focus();};
|
||
$('sunEventTypesSave').onclick=()=>{
|
||
const list=qa('.sun-event-type-input',card).map(i=>i.value.trim()).filter(Boolean);const clean=[...new Set(list)];
|
||
if(!clean.length)return toast('Добавьте хотя бы один тип мероприятия.','warn');
|
||
writeJson(EVENT_TYPES_KEY,clean);const cur=$('event')?.value||'';installEventSelect(cur);renderEventRows();toast('Список мероприятий сохранён.');
|
||
};
|
||
}
|
||
renderEventRows();
|
||
function renderEventRows(){
|
||
const rows=$('sunEventTypesRows');if(!rows)return;rows.innerHTML=eventTypes().map(v=>`<div class="sun-event-type-row"><input class="sun-event-type-input" value="${esc(v)}"><button class="outline" type="button">Убрать</button></div>`).join('');qa('button',rows).forEach(b=>b.onclick=()=>b.closest('.sun-event-type-row')?.remove());
|
||
}
|
||
}
|
||
document.addEventListener('click',e=>{const b=e.target.closest('header nav button');if(b&&String(b.dataset.navLabel||b.textContent||'').trim()==='Настройки')setTimeout(injectEventTypeSettings,20)},true);
|
||
if($('enterprise-settings')?.classList.contains('on'))injectEventTypeSettings();
|
||
|
||
const priorOpen=window.openOrder;if(typeof priorOpen==='function')window.openOrder=function(...args){const r=priorOpen.apply(this,args);setTimeout(()=>installEventSelect(getDraft()?.event||$('event')?.value||''),0);return r};
|
||
const priorReset=window.resetDraft;if(typeof priorReset==='function')window.resetDraft=function(...args){const r=priorReset.apply(this,args);setTimeout(()=>installEventSelect(''),0);return r};
|
||
const priorSave=window.saveOrder;if(typeof priorSave==='function')window.saveOrder=function(...args){const d=getDraft(),ev=$('event');if(d&&ev)d.event=ev.value;const r=priorSave.apply(this,args);return r};
|
||
|
||
/* ---------- Communication channel in order + client card ---------- */
|
||
const CLIENT_COMM_KEY='sunClientCommunicationV1';
|
||
function phoneDigits(value){return String(value||'').replace(/\D/g,'')}
|
||
function clientKeyFromOrder(order){const p=phoneDigits(order?.phone);if(p)return`p:${p}`;const n=String(order?.contact||'').trim().toLowerCase();return n?`n:${n}`:''}
|
||
const readCommStore=()=>readJson(CLIENT_COMM_KEY,{});
|
||
const saveCommStore=store=>writeJson(CLIENT_COMM_KEY,store||{});
|
||
function clientSnapshot(key){
|
||
const list=getOrders().filter(o=>clientKeyFromOrder(o)===key).sort((a,b)=>`${b.date||''}T${b.time||''}`.localeCompare(`${a.date||''}T${a.time||''}`));
|
||
const latest=list[0]||{};return{orders:list,latest,name:String(latest.contact||'Без имени'),phone:String(latest.phone||''),lastChannel:String(latest.contactChannel||'')};
|
||
}
|
||
function installOrderContactChannel(){
|
||
const details=$('details'),grid=details?.querySelector('.sun-delivery-details');if(!grid||$('orderContactChannel'))return;
|
||
const phone=$('phone')?.closest('label');const label=document.createElement('label');label.className='sun-order-contact-channel';label.innerHTML=`Канал связи<select id="orderContactChannel"><option value="">Не указан</option><option value="whatsapp">WhatsApp</option><option value="max">MAX</option><option value="telegram">Telegram</option><option value="email">E-mail</option><option value="sms">SMS</option><option value="phone">Телефон</option><option value="other">Другое</option></select>`;
|
||
if(phone)phone.insertAdjacentElement('afterend',label);else window.SunSafe.insertBefore(grid,label,grid.firstChild);
|
||
label.querySelector('select').onchange=()=>{const d=getDraft();if(d)d.contactChannel=label.querySelector('select').value};
|
||
}
|
||
installOrderContactChannel();
|
||
function fillOrderContactChannel(){installOrderContactChannel();const d=getDraft();if($('orderContactChannel'))$('orderContactChannel').value=String(d?.contactChannel||'')}
|
||
const openAfterChannel=window.openOrder;if(typeof openAfterChannel==='function')window.openOrder=function(...args){const r=openAfterChannel.apply(this,args);setTimeout(fillOrderContactChannel,0);return r};
|
||
const resetAfterChannel=window.resetDraft;if(typeof resetAfterChannel==='function')window.resetDraft=function(...args){const r=resetAfterChannel.apply(this,args);setTimeout(()=>{if($('orderContactChannel'))$('orderContactChannel').value='';const d=getDraft();if(d)d.contactChannel=''},0);return r};
|
||
const saveAfterChannel=window.saveOrder;if(typeof saveAfterChannel==='function')window.saveOrder=function(...args){const d=getDraft();if(d)d.contactChannel=$('orderContactChannel')?.value||'';const r=saveAfterChannel.apply(this,args);const sync=()=>{const cur=getDraft();if(!cur)return;const key=clientKeyFromOrder(cur);if(!key)return;const store=readCommStore(),old=store[key]||{};store[key]={...old,lastChannel:String(cur.contactChannel||old.lastChannel||''),updatedAt:new Date().toISOString()};saveCommStore(store)};if(r&&typeof r.then==='function')return r.then(v=>{sync();return v});sync();return r};
|
||
|
||
const priorClientCard=window.openClientCard;
|
||
function injectClientCommunications(encodedKey){
|
||
const key=decodeURIComponent(encodedKey),host=$('client-card-content');if(!host)return;host.querySelector('#sunClientCommsPanel')?.remove();
|
||
const snap=clientSnapshot(key),store=readCommStore(),saved=store[key]||{};
|
||
const preferred=String(saved.preferred||'auto'),wa=String(saved.whatsapp||snap.phone||''),sms=String(saved.sms||snap.phone||''),telegram=String(saved.telegram||''),email=String(saved.email||''),max=String(saved.max||''),consent=Boolean(saved.marketingConsent);
|
||
const panel=document.createElement('section');panel.id='sunClientCommsPanel';panel.className='sun-client-comms-panel';
|
||
panel.innerHTML=`<h3>Связь и рассылки</h3><p class="hint">Укажите контакты и предпочтительный канал. Для одной кампании клиент получает одно сообщение: сначала по предпочтительному доступному каналу, затем по следующему разрешённому.</p><div class="sun-client-comms-grid"><label>Предпочтительный канал<select id="clientPreferredChannel"><option value="auto" ${preferred==='auto'?'selected':''}>Авто · по последнему заказу</option><option value="whatsapp" ${preferred==='whatsapp'?'selected':''}>WhatsApp</option><option value="max" ${preferred==='max'?'selected':''}>MAX</option><option value="telegram" ${preferred==='telegram'?'selected':''}>Telegram</option><option value="email" ${preferred==='email'?'selected':''}>E-mail</option><option value="sms" ${preferred==='sms'?'selected':''}>SMS</option><option value="none" ${preferred==='none'?'selected':''}>Не отправлять</option></select></label><label>WhatsApp<input id="clientWhatsApp" value="${esc(wa)}" placeholder="+7 ..."></label><label>SMS<input id="clientSms" value="${esc(sms)}" placeholder="+7 ..."></label><label>E-mail<input id="clientEmail" type="email" value="${esc(email)}" placeholder="name@example.com"></label><label>MAX<input id="clientMax" value="${esc(max)}" placeholder="username, ID или телефон"></label><label class="wide">Telegram username / chat ID<input id="clientTelegram" value="${esc(telegram)}" placeholder="@username или chat ID"></label><label class="wide sun-client-comms-consent"><input id="clientMarketingConsent" type="checkbox" ${consent?'checked':''}> Клиент согласен получать акции и предложения</label></div><div class="actions"><button class="primary" id="clientCommsSave" type="button">Сохранить связь</button></div>`;
|
||
const detail=host.querySelector('.client-detail-grid');if(detail)detail.insertAdjacentElement('beforebegin',panel);else host.appendChild(panel);
|
||
$('clientCommsSave').onclick=()=>{const all=readCommStore();all[key]={...(all[key]||{}),preferred:$('clientPreferredChannel').value,whatsapp:$('clientWhatsApp').value.trim(),sms:$('clientSms').value.trim(),telegram:$('clientTelegram').value.trim(),email:$('clientEmail').value.trim(),max:$('clientMax').value.trim(),marketingConsent:$('clientMarketingConsent').checked,lastChannel:String(all[key]?.lastChannel||snap.lastChannel||''),updatedAt:new Date().toISOString()};saveCommStore(all);toast('Каналы связи клиента сохранены.');};
|
||
}
|
||
if(typeof priorClientCard==='function')window.openClientCard=function(encodedKey,...args){const r=priorClientCard.call(this,encodedKey,...args);setTimeout(()=>injectClientCommunications(encodedKey),0);return r};
|
||
|
||
/* ---------- Mailings center ---------- */
|
||
const TEMPLATE_KEY='sunMarketingTemplatesV1', CAMPAIGN_KEY='sunMarketingCampaignsV1', PROMO_KEY='sunPromoCodesV1';
|
||
const defaultTemplates=[{id:'sep1',name:'1 сентября · скидка 10%',text:("{{Имя}}, к 1 сентября для вас скидка {{Скидка}} на заказ в «"+window.CateriumBranding.documentName()+"». Промокод: {{Промокод}}. Будем рады приготовить ваш праздник!")},{id:'return',name:'Вернуть клиента',text:("{{Имя}}, давно вас не видели! Для вас действует персональное предложение от «"+window.CateriumBranding.documentName()+"». Напишите нам, и подберём формат под ваш праздник.")}];
|
||
function templates(){const list=readJson(TEMPLATE_KEY,defaultTemplates),src=Array.isArray(list)&&list.length?list:defaultTemplates;let changed=false;const migrated=src.map(t=>{const text=String(t.text||'').replaceAll('{{Скидка}}%','{{Скидка}}');if(text!==t.text)changed=true;return {...t,text}});if(changed)saveTemplates(migrated);return migrated}
|
||
function saveTemplates(list){writeJson(TEMPLATE_KEY,list)}
|
||
function campaigns(){const list=readJson(CAMPAIGN_KEY,[]);return Array.isArray(list)?list:[]}
|
||
function saveCampaigns(list){writeJson(CAMPAIGN_KEY,list)}
|
||
function customerList(){
|
||
const map={};
|
||
getOrders().forEach(o=>{const key=clientKeyFromOrder(o);if(!key)return;if(!map[key])map[key]={key,name:String(o.contact||'Без имени')||'Без имени',phone:String(o.phone||''),orders:[],total:0,lastDate:'',lastChannel:''};const p=map[key];p.orders.push(o);p.name=String(o.contact||p.name);p.phone=String(o.phone||p.phone);if(String(o.status||'')!=='Отменён')p.total+=Number(typeof orderTotalValue==='function'?orderTotalValue(o):o.total||0);const stamp=`${o.date||''}T${o.time||''}`;if(stamp>=p.lastDate){p.lastDate=stamp;p.lastChannel=String(o.contactChannel||p.lastChannel||'')}});
|
||
return Object.values(map);
|
||
}
|
||
function daysSince(iso){if(!iso)return 99999;const d=new Date(String(iso).slice(0,10)+'T12:00:00');return Number.isNaN(d.getTime())?99999:Math.floor((Date.now()-d.getTime())/86400000)}
|
||
function segmentClients(segment){return customerList().filter(p=>{if(segment==='repeat')return p.orders.length>=2;if(segment==='inactive60')return daysSince(p.lastDate)>=60;if(segment==='inactive90')return daysSince(p.lastDate)>=90;if(segment==='vip100')return p.total>=100000;return true})}
|
||
const MAIL_CHANNELS=['whatsapp','max','telegram','email','sms'];
|
||
const MAIL_CHANNEL_DEFAULTS_KEY='sunMarketingChannelDefaultsV1';
|
||
function contactFor(channel,person,pref){if(channel==='whatsapp')return pref.whatsapp||person.phone||'';if(channel==='sms')return pref.sms||person.phone||'';if(channel==='telegram')return pref.telegram||'';if(channel==='email')return pref.email||'';if(channel==='max')return pref.max||'';return''}
|
||
function channelFor(person,pref,allowedChannels=MAIL_CHANNELS){const allowed=new Set((allowedChannels||[]).filter(ch=>MAIL_CHANNELS.includes(ch))),raw=String(pref.preferred||'auto');if(raw==='none'||!allowed.size)return'none';const candidates=[];if(raw!=='auto')candidates.push(raw);else candidates.push(String(pref.lastChannel||person.lastChannel||''));candidates.push('whatsapp','max','telegram','email','sms');for(const ch of candidates){if(allowed.has(ch)&&contactFor(ch,person,pref))return ch}return'none'}
|
||
function messageFor(person,text,discount,promo){return String(text||'').replaceAll('{{Имя}}',person.name||'').replaceAll('{{Скидка}}',String(discount||'')).replaceAll('{{Промокод}}',String(promo||''))}
|
||
function makeQueue(segment,text,discount,promo,allowedChannels=MAIL_CHANNELS){
|
||
const store=readCommStore();return segmentClients(segment).filter(p=>Boolean(store[p.key]?.marketingConsent)).map(p=>{const pref=store[p.key]||{},channel=channelFor(p,pref,allowedChannels),contact=contactFor(channel,p,pref);return{key:p.key,name:p.name,channel,contact,message:messageFor(p,text,discount,promo)}});
|
||
}
|
||
function queueStats(queue){const c={whatsapp:0,max:0,telegram:0,email:0,sms:0,none:0,total:queue.length};queue.forEach(r=>{c[r.channel]!==undefined?c[r.channel]++:c.none++});return c}
|
||
function channelLabel(ch){return({whatsapp:'WhatsApp',max:'MAX',telegram:'Telegram',email:'E-mail',sms:'SMS',none:'Без канала'})[ch]||'Без канала'}
|
||
function quickLink(row){const digits=phoneDigits(row.contact);if(row.channel==='whatsapp'&&digits)return`https://wa.me/${digits}?text=${encodeURIComponent(row.message)}`;if(row.channel==='sms'&&row.contact)return`sms:${row.contact}?body=${encodeURIComponent(row.message)}`;if(row.channel==='email'&&row.contact)return`mailto:${encodeURIComponent(row.contact)}?subject=${encodeURIComponent((window.CateriumBranding.documentName()))}&body=${encodeURIComponent(row.message)}`;if(row.channel==='telegram'){const u=String(row.contact||'').trim().replace(/^@/,'');if(u&&!/^\d+$/.test(u))return`https://t.me/${encodeURIComponent(u)}`;}return''}
|
||
|
||
const nav=document.querySelector('header nav');
|
||
let mailingButton=$('sunMailingsNav');
|
||
if(!mailingButton){mailingButton=document.createElement('button');mailingButton.id='sunMailingsNav';mailingButton.type='button';mailingButton.className='sun-nav-button nav-mailings';mailingButton.dataset.navLabel='Рассылки';mailingButton.textContent='Рассылки';const group=nav?.querySelector('.sun-nav-management')||nav;const clients=qa('button',group||document).find(b=>String(b.dataset.navLabel||b.textContent||'').trim()==='Клиенты');if(clients)clients.insertAdjacentElement('afterend',mailingButton);else group?.appendChild(mailingButton)}
|
||
let mailingView=$('mailings');
|
||
if(!mailingView){mailingView=document.createElement('section');mailingView.id='mailings';mailingView.className='view page';mailingView.innerHTML=`<div class="sun-mailing-head"><div><h1>Рассылки</h1><p class="hint" style="margin:0">Акции по клиентской базе с выбором разрешённых каналов: WhatsApp, MAX, Telegram, E-mail и SMS. Каждый клиент получает сообщение только по одному доступному каналу.</p></div><div class="sun-mailing-note">Выберите каналы кампании. Приложение подготовит по одному сообщению на клиента; полностью автоматическая отправка потребует подключения API соответствующих каналов.</div></div><div class="sun-mailing-grid"><section class="sun-mailing-card"><h2>Новая кампания</h2><div class="sun-mailing-form"><label class="wide">Название кампании<input id="mailCampaignName" value="1 сентября · скидка 10%"></label><label>Скидка, %<input id="mailDiscount" type="number" min="0" max="100" step="1" value="10"></label><label>Промокод<input id="mailPromo" value="SEPT10"></label><label>Сегмент<select id="mailSegment"><option value="all">Все клиенты с согласием</option><option value="repeat">Постоянные · 2+ заказа</option><option value="inactive60">Не заказывали 60+ дней</option><option value="inactive90">Не заказывали 90+ дней</option><option value="vip100">Оборот от 100 000 ₽</option></select></label><label>Шаблон<select id="mailTemplateSelect"></select></label><label class="wide">Текст сообщения<textarea id="mailMessage"></textarea></label></div><div class="sun-mailing-vars"><button class="outline" type="button" data-mail-var="{{Имя}}">+ Имя</button><button class="outline" type="button" data-mail-var="{{Скидка}}">+ Скидка</button><button class="outline" type="button" data-mail-var="{{Промокод}}">+ Промокод</button></div><div id="mailAudience" class="sun-mailing-audience"></div><div class="sun-mailing-actions"><button class="outline" id="mailPreviewAudience" type="button">Рассчитать аудиторию</button><button class="primary" id="mailLaunch" type="button">Запустить рассылку</button></div></section><section class="sun-mailing-card"><h2>Шаблоны сообщений</h2><div class="sun-mailing-form"><label class="wide">Название шаблона<input id="mailTemplateName" placeholder="Например, 1 сентября"></label><label class="wide">Текст<textarea id="mailTemplateText" rows="5" placeholder="Текст с {{Имя}}, {{Скидка}}, {{Промокод}}"></textarea></label></div><div class="sun-mailing-actions"><button class="primary" id="mailTemplateSave" type="button">Сохранить шаблон</button></div><div id="mailTemplateList" class="sun-mailing-template-list" style="margin-top:12px"></div></section><section class="sun-mailing-card wide"><h2>История кампаний</h2><div id="mailCampaignHistory" class="sun-mailing-history"></div></section></div>`;document.body.appendChild(mailingView)}
|
||
let queueModal=$('sunMailQueueModal');
|
||
if(!queueModal){queueModal=document.createElement('div');queueModal.id='sunMailQueueModal';queueModal.className='modal';queueModal.innerHTML=`<div class="dialog" style="max-width:1050px"><div class="dialog-head"><h2 id="sunMailQueueTitle">Очередь рассылки</h2><button type="button" onclick="closeModal('sunMailQueueModal')">×</button></div><div id="sunMailQueueContent"></div></div>`;document.body.appendChild(queueModal)}
|
||
|
||
function promoCodes(){const list=readJson(PROMO_KEY,[]);return Array.isArray(list)?list:[]}
|
||
function savePromoCodes(list){writeJson(PROMO_KEY,Array.isArray(list)?list:[]);window.sunRefreshOrderPromos?.()}
|
||
const localToday=()=>{const d=new Date(),p=n=>String(n).padStart(2,'0');return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}`};
|
||
function promoActive(p,date=localToday()){if(!p||p.enabled===false)return false;const start=String(p.startDate||''),end=String(p.endDate||'');return (!start||start<=date)&&(!end||end>=date)}
|
||
function promoDiscountText(p){const n=Math.max(0,Number(p?.discountValue||0));return p?.discountType==='amount'?`${n.toLocaleString('ru-RU',{maximumFractionDigits:2})} ₽`:`${n.toLocaleString('ru-RU',{maximumFractionDigits:2})}%`}
|
||
function promoState(p){if(p.enabled===false)return'Отключён';const now=localToday();if(p.startDate&&p.startDate>now)return`С ${p.startDate}`;if(p.endDate&&p.endDate<now)return'Истёк';return'Активен'}
|
||
function selectedMailPromo(){const id=$('mailPromoSelect')?.value||'';return promoCodes().find(p=>String(p.id)===String(id))||null}
|
||
function readMailChannelDefaults(){const saved=readJson(MAIL_CHANNEL_DEFAULTS_KEY,MAIL_CHANNELS);return Array.isArray(saved)?saved.filter(ch=>MAIL_CHANNELS.includes(ch)):MAIL_CHANNELS.slice()}
|
||
function selectedMailChannels(){return qa('[data-mail-channel]',mailingView).filter(cb=>cb.checked).map(cb=>cb.dataset.mailChannel).filter(ch=>MAIL_CHANNELS.includes(ch))}
|
||
function ensureMailChannelUi(){const form=$('mailCampaignName')?.closest('.sun-mailing-form');if(!form||$('mailChannelSettings'))return;const selected=new Set(readMailChannelDefaults()),box=document.createElement('div');box.id='mailChannelSettings';box.className='sun-mailing-channel-settings wide';box.innerHTML=`<span>Каналы этой рассылки</span><div class="sun-mailing-channel-options">${MAIL_CHANNELS.map(ch=>`<label><input type="checkbox" data-mail-channel="${ch}" ${selected.has(ch)?'checked':''}> ${channelLabel(ch)}</label>`).join('')}</div>`;const segment=$('mailSegment')?.closest('label');if(segment)segment.insertAdjacentElement('beforebegin',box);else form.appendChild(box);qa('[data-mail-channel]',box).forEach(cb=>cb.addEventListener('change',()=>{const list=selectedMailChannels();writeJson(MAIL_CHANNEL_DEFAULTS_KEY,list);renderAudience()}))}
|
||
function ensurePromoUi(){
|
||
const form=$('mailCampaignName')?.closest('.sun-mailing-form');if(!form)return;
|
||
ensureMailChannelUi();
|
||
const oldDiscount=$('mailDiscount')?.closest('label'),oldPromo=$('mailPromo')?.closest('label');
|
||
if(!$('mailPromoSelect')){
|
||
const label=document.createElement('label');label.innerHTML='Промокод<select id="mailPromoSelect"><option value="">Без промокода</option></select>';
|
||
(oldDiscount||form.firstElementChild)?.insertAdjacentElement('afterend',label);
|
||
}
|
||
oldDiscount?.remove();oldPromo?.remove();
|
||
let card=$('sunPromoManagerCard');
|
||
if(!card){
|
||
card=document.createElement('section');card.id='sunPromoManagerCard';card.className='sun-mailing-card wide';
|
||
card.innerHTML=`<h2>Промокоды</h2><p class="hint">Создайте код один раз. В заказе можно выбрать только действующий промокод, а его скидка подставится автоматически.</p><div class="sun-mailing-form"><input id="promoEditId" type="hidden"><label>Промокод<input id="promoManagerCode" placeholder="Например, SEPT10"></label><label>Скидка<div style="display:grid;grid-template-columns:minmax(0,1fr) 80px;gap:6px"><input id="promoManagerValue" type="number" min="0" step="1"><select id="promoManagerType"><option value="percent">%</option><option value="amount">₽</option></select></div></label><label>Начало действия<input id="promoManagerStart" type="date"></label><label>Окончание действия<input id="promoManagerEnd" type="date"></label><label class="wide" style="flex-direction:row;align-items:center;gap:8px"><input id="promoManagerEnabled" type="checkbox" checked style="width:auto"> Промокод включён</label></div><div class="sun-mailing-actions"><button class="primary" id="promoManagerSave" type="button">Создать промокод</button><button class="outline" id="promoManagerCancel" type="button" style="display:none">Отмена</button></div><div id="promoManagerList" class="sun-mailing-template-list" style="margin-top:12px"></div>`;
|
||
const history=$('mailCampaignHistory')?.closest('.sun-mailing-card');if(history)history.insertAdjacentElement('beforebegin',card);else mailingView.querySelector('.sun-mailing-grid')?.appendChild(card);
|
||
$('promoManagerStart').value=localToday();
|
||
$('promoManagerSave').onclick=savePromoFromForm;$('promoManagerCancel').onclick=clearPromoForm;
|
||
card.addEventListener('click',e=>{const edit=e.target.closest('[data-promo-edit]'),toggle=e.target.closest('[data-promo-toggle]'),del=e.target.closest('[data-promo-delete]');if(edit){editPromo(edit.dataset.promoEdit);return}if(toggle){togglePromo(toggle.dataset.promoToggle);return}if(del){deletePromo(del.dataset.promoDelete);return}});
|
||
}
|
||
renderPromoControls();
|
||
}
|
||
function clearPromoForm(){if(!$('promoEditId'))return;$('promoEditId').value='';$('promoManagerCode').value='';$('promoManagerValue').value='';$('promoManagerType').value='percent';$('promoManagerStart').value=localToday();$('promoManagerEnd').value='';$('promoManagerEnabled').checked=true;$('promoManagerSave').textContent='Создать промокод';$('promoManagerCancel').style.display='none'}
|
||
function savePromoFromForm(){
|
||
const id=$('promoEditId').value.trim(),code=$('promoManagerCode').value.trim().toUpperCase(),value=Math.max(0,Number($('promoManagerValue').value||0)),type=$('promoManagerType').value==='amount'?'amount':'percent',start=$('promoManagerStart').value,end=$('promoManagerEnd').value,enabled=$('promoManagerEnabled').checked;
|
||
if(!code)return toast('Введите промокод.','warn');if(!value)return toast('Укажите размер скидки.','warn');if(type==='percent'&&value>100)return toast('Процент скидки не может быть больше 100%.','warn');if(!start||!end)return toast('Укажите начало и окончание действия промокода.','warn');if(end<start)return toast('Дата окончания не может быть раньше даты начала.','warn');
|
||
const list=promoCodes(),duplicate=list.find(p=>String(p.code||'').trim().toLowerCase()===code.toLowerCase()&&String(p.id)!==String(id));if(duplicate)return toast('Такой промокод уже существует.','warn');
|
||
if(id){const p=list.find(x=>String(x.id)===String(id));if(p)Object.assign(p,{code,discountValue:value,discountType:type,startDate:start,endDate:end,enabled,updatedAt:new Date().toISOString()})}
|
||
else list.push({id:`promo-${Date.now()}`,code,discountValue:value,discountType:type,startDate:start,endDate:end,enabled,createdAt:new Date().toISOString(),updatedAt:new Date().toISOString()});
|
||
savePromoCodes(list);clearPromoForm();renderPromoControls();toast(id?'Промокод обновлён.':'Промокод создан.');
|
||
}
|
||
function editPromo(id){const p=promoCodes().find(x=>String(x.id)===String(id));if(!p)return;$('promoEditId').value=p.id;$('promoManagerCode').value=p.code||'';$('promoManagerValue').value=Number(p.discountValue||0);$('promoManagerType').value=p.discountType==='amount'?'amount':'percent';$('promoManagerStart').value=p.startDate||localToday();$('promoManagerEnd').value=p.endDate||'';$('promoManagerEnabled').checked=p.enabled!==false;$('promoManagerSave').textContent='Сохранить изменения';$('promoManagerCancel').style.display='inline-flex';$('promoManagerCode').focus()}
|
||
function togglePromo(id){const list=promoCodes(),p=list.find(x=>String(x.id)===String(id));if(!p)return;p.enabled=p.enabled===false; p.updatedAt=new Date().toISOString();savePromoCodes(list);renderPromoControls()}
|
||
function deletePromo(id){const p=promoCodes().find(x=>String(x.id)===String(id));if(!p)return;if(!confirm(`Удалить промокод ${p.code}? Старые заказы сохранят уже применённую скидку.`))return;savePromoCodes(promoCodes().filter(x=>String(x.id)!==String(id)));renderPromoControls();clearPromoForm()}
|
||
function renderPromoControls(){
|
||
const list=promoCodes().slice().sort((a,b)=>String(b.createdAt||'').localeCompare(String(a.createdAt||''))),select=$('mailPromoSelect');
|
||
if(select){const current=select.value,active=list.filter(p=>promoActive(p));select.innerHTML='<option value="">Без промокода</option>'+active.map(p=>`<option value="${esc(p.id)}">${esc(p.code)} · ${esc(promoDiscountText(p))} · до ${esc(p.endDate||'—')}</option>`).join('');if(current&&active.some(p=>String(p.id)===String(current)))select.value=current;}
|
||
const host=$('promoManagerList');if(host)host.innerHTML=list.length?list.map(p=>`<div class="sun-mailing-template-row"><div><b>${esc(p.code)}</b><small>${esc(promoDiscountText(p))} · ${esc(p.startDate||'—')} — ${esc(p.endDate||'—')} · ${esc(promoState(p))}</small></div><div class="sun-mailing-row-actions"><button class="outline" type="button" data-promo-edit="${esc(p.id)}">Изменить</button><button class="outline" type="button" data-promo-toggle="${esc(p.id)}">${p.enabled===false?'Включить':'Отключить'}</button><button class="danger" type="button" data-promo-delete="${esc(p.id)}">Удалить</button></div></div>`).join(''):'<p class="hint">Промокодов пока нет.</p>';
|
||
}
|
||
|
||
function renderTemplateControls(){
|
||
const list=templates(),select=$('mailTemplateSelect');if(select){const current=select.value;select.innerHTML='<option value="">Без шаблона</option>'+list.map(t=>`<option value="${esc(t.id)}">${esc(t.name)}</option>`).join('');if(current&&list.some(t=>t.id===current))select.value=current;}
|
||
const host=$('mailTemplateList');if(host)host.innerHTML=list.map(t=>`<div class="sun-mailing-template-row"><div><b>${esc(t.name)}</b><small>${esc(String(t.text||'').slice(0,95))}${String(t.text||'').length>95?'…':''}</small></div><div class="sun-mailing-row-actions"><button class="outline" type="button" data-mail-template-use="${esc(t.id)}">Использовать</button><button class="outline" type="button" data-mail-template-edit="${esc(t.id)}">Изменить</button><button class="danger" type="button" data-mail-template-delete="${esc(t.id)}">Удалить</button></div></div>`).join('')||'<p class="hint">Шаблонов пока нет.</p>';
|
||
}
|
||
function currentQueue(){const p=selectedMailPromo(),channels=selectedMailChannels();return makeQueue($('mailSegment')?.value||'all',$('mailMessage')?.value||'',p?promoDiscountText(p):'',p?.code||'',channels)}
|
||
function renderAudience(){const q=currentQueue(),s=queueStats(q),host=$('mailAudience');if(host)host.innerHTML=`<div class="sun-mailing-metric"><small>WhatsApp</small><b>${s.whatsapp}</b></div><div class="sun-mailing-metric"><small>MAX</small><b>${s.max}</b></div><div class="sun-mailing-metric"><small>Telegram</small><b>${s.telegram}</b></div><div class="sun-mailing-metric"><small>E-mail</small><b>${s.email}</b></div><div class="sun-mailing-metric"><small>SMS</small><b>${s.sms}</b></div><div class="sun-mailing-metric"><small>Без доступного канала</small><b>${s.none}</b></div>`;return q}
|
||
function renderHistory(){const host=$('mailCampaignHistory');if(!host)return;const list=campaigns().slice().reverse();host.innerHTML=list.length?list.map(c=>`<div class="sun-mailing-history-row"><div><b>${esc(c.name||'Кампания')}</b><small>${esc(c.createdAt?.slice(0,16).replace('T',' ')||'')} · ${Number(c.counts?.total||0)} получателей · WhatsApp ${Number(c.counts?.whatsapp||0)} · MAX ${Number(c.counts?.max||0)} · Telegram ${Number(c.counts?.telegram||0)} · E-mail ${Number(c.counts?.email||0)} · SMS ${Number(c.counts?.sms||0)}</small></div><div class="sun-mailing-row-actions"><button class="outline" type="button" data-mail-history-open="${esc(c.id)}">Очередь</button></div></div>`).join(''):'<p class="hint">Запущенных кампаний пока нет.</p>'}
|
||
function openQueue(campaign){
|
||
if(!campaign)return;const rows=campaign.queue||[];$('sunMailQueueTitle').textContent=`Очередь · ${campaign.name||'Кампания'}`;
|
||
$('sunMailQueueContent').innerHTML=`<p class="hint">Для WhatsApp, SMS и E-mail можно открыть подготовленное сообщение. Для Telegram и MAX текст можно скопировать; автоматическая отправка появится после подключения API каналов.</p><div class="sun-mail-queue">${rows.map((r,i)=>{const link=quickLink(r);return`<div class="sun-mail-queue-row"><div><b>${esc(r.name)}</b><small>${esc(r.contact||'Контакт не указан')}</small></div><span class="sun-mail-channel ${esc(r.channel)}">${esc(channelLabel(r.channel))}</span><div><small>${esc(String(r.message||'').slice(0,150))}${String(r.message||'').length>150?'…':''}</small></div><div class="sun-mailing-row-actions"><button class="outline" type="button" data-mail-copy="${i}">Копировать</button>${link?`<a class="outline" style="text-decoration:none;display:inline-flex;align-items:center" href="${esc(link)}" target="_blank" rel="noopener">Открыть</a>`:''}</div></div>`}).join('')||'<p class="hint">Получателей нет.</p>'}</div>`;
|
||
qa('[data-mail-copy]',$('sunMailQueueContent')).forEach(btn=>btn.onclick=async()=>{const row=rows[Number(btn.dataset.mailCopy)];try{await navigator.clipboard.writeText(row.message);toast('Текст скопирован.')}catch(_){const ta=document.createElement('textarea');ta.value=row.message;document.body.appendChild(ta);ta.select();document.execCommand('copy');ta.remove();toast('Текст скопирован.')}});window.modal?.('sunMailQueueModal');
|
||
}
|
||
function bindMailings(){
|
||
ensurePromoUi();renderTemplateControls();renderPromoControls();renderHistory();const first=templates()[0];if(!$('mailMessage').value&&first)$('mailMessage').value=first.text;renderAudience();
|
||
$('mailTemplateSelect').onchange=()=>{const t=templates().find(x=>x.id===$('mailTemplateSelect').value);if(t)$('mailMessage').value=t.text;renderAudience()};
|
||
qa('[data-mail-var]',mailingView).forEach(b=>b.onclick=()=>{const ta=$('mailMessage'),v=b.dataset.mailVar,start=ta.selectionStart??ta.value.length,end=ta.selectionEnd??start;ta.value=ta.value.slice(0,start)+v+ta.value.slice(end);ta.focus();ta.setSelectionRange(start+v.length,start+v.length);renderAudience()});
|
||
$('mailPreviewAudience').onclick=()=>{const q=renderAudience();toast(`Аудитория рассчитана: ${q.length} получателей.`)};
|
||
$('mailLaunch').onclick=()=>{const all=renderAudience(),channels=selectedMailChannels();if(!channels.length)return toast('Выберите хотя бы один канал рассылки.','warn');const q=all.filter(r=>r.channel!=='none');if(!q.length)return toast('Нет клиентов с согласием и доступным контактом в выбранных каналах.','warn');const p=selectedMailPromo();const c={id:`mail-${Date.now()}`,name:$('mailCampaignName').value.trim()||'Кампания',createdAt:new Date().toISOString(),segment:$('mailSegment').value,channels,promoId:p?.id||'',promo:p?.code||'',discountValue:Number(p?.discountValue||0),discountType:p?.discountType||'percent',discount:p?promoDiscountText(p):'',promoStartDate:p?.startDate||'',promoEndDate:p?.endDate||'',text:$('mailMessage').value,counts:queueStats(q),queue:q,status:'queue'};const list=campaigns();list.push(c);saveCampaigns(list);renderHistory();openQueue(c);toast('Рассылка подготовлена. Каждый клиент добавлен только в один канал.');};
|
||
$('mailTemplateSave').onclick=()=>{const name=$('mailTemplateName').value.trim(),text=$('mailTemplateText').value.trim();if(!name||!text)return toast('Введите название и текст шаблона.','warn');const list=templates(),existing=list.find(t=>t.name.toLowerCase()===name.toLowerCase());if(existing)existing.text=text;else list.push({id:`tpl-${Date.now()}`,name,text});saveTemplates(list);$('mailTemplateName').value='';$('mailTemplateText').value='';renderTemplateControls();toast('Шаблон сохранён.');};
|
||
mailingView.onclick=e=>{const use=e.target.closest('[data-mail-template-use]'),edit=e.target.closest('[data-mail-template-edit]'),del=e.target.closest('[data-mail-template-delete]'),hist=e.target.closest('[data-mail-history-open]');if(use){const t=templates().find(x=>x.id===use.dataset.mailTemplateUse);if(t){$('mailTemplateSelect').value=t.id;$('mailMessage').value=t.text;renderAudience()}return}if(edit){const t=templates().find(x=>x.id===edit.dataset.mailTemplateEdit);if(t){$('mailTemplateName').value=t.name;$('mailTemplateText').value=t.text}return}if(del){const list=templates().filter(x=>x.id!==del.dataset.mailTemplateDelete);saveTemplates(list.length?list:defaultTemplates);renderTemplateControls();return}if(hist){openQueue(campaigns().find(x=>x.id===hist.dataset.mailHistoryOpen));}};
|
||
['mailSegment','mailPromoSelect','mailMessage'].forEach(id=>$(id)?.addEventListener(id==='mailMessage'?'input':'change',renderAudience));
|
||
}
|
||
ensurePromoUi();
|
||
let mailBound=false;
|
||
mailingButton.onclick=()=>{qa('.view').forEach(v=>v.classList.remove('on'));mailingView.classList.add('on');qa('header nav button').forEach(b=>b.classList.remove('on'));mailingButton.classList.add('on');ensurePromoUi();ensureMailChannelUi();if(!mailBound){bindMailings();mailBound=true}else{renderTemplateControls();renderPromoControls();renderHistory();renderAudience()}};
|
||
|
||
/* ---------- Final PDF catalog fix ----------
|
||
Old historical code repeatedly rebinds #sunCatalogPdfButton to window.print().
|
||
Leave that ID as a hidden sink and use a plain link for the real PDF viewer.
|
||
*/
|
||
const PDF_PATH='catalog/solnce-catalog-one-item-per-page.pdf';
|
||
function currentCatalogCategory(){const buttons=qa('#new .sun-catalog-top-toolbar .cats button,#new .cats button'),active=buttons.find(b=>b.style.outline&&b.style.outline!=='none')||buttons.find(b=>b.getAttribute('aria-current')==='true');if(active?.dataset.category!=null)return Number(active.dataset.category);const title=document.querySelector('#new .catalog h1')?.textContent?.trim()||'';const map={'Боксы':0,'Премиум':5,'Посуда':1,'Дополнения':2,'Напитки':3,'Доставка':4};const f=Object.entries(map).find(([n])=>title.startsWith(n));return f?Number(f[1]):0}
|
||
function installStablePdfLink(){
|
||
const head=document.querySelector('#new .catalog .catalog-head'),configure=qa('button',head||document).find(b=>b.textContent.trim()==='Настроить');if(!head||!configure)return;
|
||
let legacy=$('sunCatalogPdfButton');if(legacy){legacy.classList.add('sun-pdf-legacy-dummy');legacy.setAttribute('aria-hidden','true');legacy.tabIndex=-1;legacy.title='';}
|
||
else{legacy=document.createElement('button');legacy.id='sunCatalogPdfButton';legacy.type='button';legacy.className='sun-pdf-legacy-dummy';legacy.setAttribute('aria-hidden','true');legacy.tabIndex=-1;head.appendChild(legacy)}
|
||
let link=$('sunCatalogPdfViewerLink');if(!link){link=document.createElement('a');link.id='sunCatalogPdfViewerLink';link.className='outline sun-catalog-pdf-button';link.textContent='Скачать PDF';link.href=PDF_PATH;link.target='_blank';link.rel='noopener';link.title='Открыть готовый PDF-каталог. В просмотрщике можно сохранить файл или распечатать.';configure.insertAdjacentElement('beforebegin',link)}
|
||
const hide=currentCatalogCategory()===4;link.hidden=hide;link.style.display=hide?'none':'';
|
||
}
|
||
installStablePdfLink();setTimeout(installStablePdfLink,50);setTimeout(installStablePdfLink,650);
|
||
document.addEventListener('click',e=>{if(e.target.closest('#new .cats button,#new .sun-catalog-top-toolbar .cats button'))setTimeout(installStablePdfLink,10)},true);
|
||
const catalog=document.querySelector('#new .catalog');if(catalog&&'MutationObserver'in window)new MutationObserver(()=>installStablePdfLink()).observe(catalog,{childList:true,subtree:true});
|
||
|
||
/* Initial sync */
|
||
fillOrderContactChannel();
|
||
setTimeout(()=>{injectEventTypeSettings();installStablePdfLink()},150);
|
||
})();
|
||
</script>
|
||
|
||
<!-- ===== USER REQUEST FINAL PATCH v3 ===== -->
|
||
<style id="sun-user-final-patch-style-v3">
|
||
/* Blank-form settings live only in Settings. Keep the legacy print-page card inert and invisible. */
|
||
#sun-print-center #sunPrintExtraOptions{display:none!important}
|
||
#enterprise-settings:not(.on){display:none!important}
|
||
#enterprise-settings.on{display:block!important}
|
||
body #sunBlankSettingsCardV3{display:none!important}
|
||
#enterprise-settings.on #sunBlankSettingsCardV3{display:block!important}
|
||
/* Older PDF controls remain as compatibility sinks; only the live catalog button is visible. */
|
||
#sunCatalogPdfViewerLink,#sunCatalogPdfButton{display:none!important}
|
||
#sunLiveCatalogPdfButton{display:inline-flex;align-items:center;justify-content:center;gap:6px;white-space:nowrap}
|
||
#sunLiveCatalogPrintRoot{display:none}
|
||
.sun-settings-blank-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:10px}
|
||
.sun-settings-blank-grid label{display:flex;flex-direction:row;align-items:center;gap:8px;color:#394653}
|
||
.sun-settings-blank-grid input{width:17px;height:17px}
|
||
@media(max-width:700px){.sun-settings-blank-grid{grid-template-columns:1fr}}
|
||
|
||
@media print{
|
||
body.sun-live-catalog-print{-webkit-print-color-adjust:exact!important;print-color-adjust:exact!important;background:#f8f4eb!important}
|
||
body.sun-live-catalog-print *{visibility:hidden!important}
|
||
body.sun-live-catalog-print #sunLiveCatalogPrintRoot,
|
||
body.sun-live-catalog-print #sunLiveCatalogPrintRoot *{visibility:visible!important}
|
||
body.sun-live-catalog-print #sunLiveCatalogPrintRoot{display:block!important;position:absolute!important;left:0!important;top:0!important;width:210mm!important;background:#f8f4eb!important;padding:0!important;margin:0!important}
|
||
.sun-live-catalog-page{page-break-after:always;break-after:page;width:210mm;height:297mm;padding:11.5mm 17mm 10mm;display:flex;flex-direction:column;overflow:hidden;position:relative;font-family:Arial,sans-serif;color:#1f1f1d;background:#f8f4eb}
|
||
.sun-live-catalog-page:last-child{page-break-after:auto;break-after:auto}
|
||
.sun-live-catalog-head{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:5mm;min-height:17mm;padding-bottom:5.5mm;border-bottom:.45mm solid #c99a32}
|
||
.sun-live-catalog-logo{width:14mm;height:14mm;object-fit:contain}
|
||
.sun-live-catalog-brand strong{display:block;font-size:15.3pt;line-height:1.05;font-weight:850;letter-spacing:.01em;color:#1e1e1c}
|
||
.sun-live-catalog-brand small{display:block;margin-top:1.5mm;font-size:7.8pt;color:#87827a;letter-spacing:.035em;text-transform:uppercase}
|
||
.sun-live-catalog-counter{font-size:7.5pt;color:#7e7971;align-self:start;padding-top:2.4mm}
|
||
.sun-live-catalog-visual{position:relative;margin-top:6.8mm;height:96mm;border-radius:4mm;background:#f0ebdf;display:flex;align-items:center;justify-content:center;overflow:hidden;padding:3mm}
|
||
.sun-live-catalog-photo{width:100%;height:100%;object-fit:contain;object-position:center}
|
||
.sun-live-catalog-photo.is-placeholder{max-width:55mm;max-height:55mm;opacity:.55}
|
||
.sun-live-catalog-section{position:absolute;left:6mm;top:4.8mm;z-index:2;max-width:74%;border-radius:999px;background:#191919;color:#fff;padding:3mm 5.5mm 2.7mm;font-size:7.7pt;line-height:1;font-weight:800;text-transform:uppercase;letter-spacing:.015em}
|
||
.sun-live-catalog-title-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8mm;align-items:start;margin-top:5mm}
|
||
.sun-live-catalog-name{font-size:18pt;line-height:1.03;font-weight:850;letter-spacing:.005em;color:#1f1f1d}
|
||
.sun-live-catalog-price{font-size:18pt;line-height:1;font-weight:850;color:#c99a32;white-space:nowrap;padding-top:1.5mm}
|
||
.sun-live-catalog-facts{display:grid;grid-template-columns:repeat(2,1fr);gap:7mm;margin-top:6.2mm;width:58%}
|
||
.sun-live-catalog-fact small{display:block;color:#77736d;font-size:6.6pt;font-weight:800;text-transform:uppercase;margin-bottom:1mm}
|
||
.sun-live-catalog-fact b{display:block;font-size:9pt;color:#232321;font-weight:800;text-transform:uppercase}
|
||
.sun-live-catalog-divider{height:.25mm;background:#d9d0c2;margin-top:5.4mm}
|
||
.sun-live-catalog-composition{margin-top:5.2mm;min-height:45mm}
|
||
.sun-live-catalog-composition h3{margin:0 0 3.4mm;font-size:10.5pt;line-height:1;font-weight:850;text-transform:uppercase;color:#1f1f1d}
|
||
.sun-live-catalog-composition ul{list-style:none;margin:0;padding:0;display:grid;gap:1.6mm}
|
||
.sun-live-catalog-composition li{position:relative;padding-left:6mm;font-size:8.3pt;line-height:1.28;color:#33312e}
|
||
.sun-live-catalog-composition li:before{content:'';position:absolute;left:.3mm;top:.43em;width:2mm;height:2mm;border-radius:50%;background:#c99a32}
|
||
.sun-live-catalog-empty{font-size:8pt;color:#77736d;margin:0}
|
||
.sun-live-catalog-footer{position:absolute;left:17mm;right:17mm;bottom:10mm;padding-top:3.3mm;border-top:.25mm solid #d9d0c2;display:flex;justify-content:space-between;gap:8mm;color:#88827a;font-size:6.6pt}
|
||
@page{size:A4 portrait;margin:0}
|
||
}
|
||
</style>
|
||
<script id="sun-user-final-patch-script-v3">
|
||
(()=>{
|
||
'use strict';
|
||
if(window.__sunUserFinalPatchV3)return;window.__sunUserFinalPatchV3=true;
|
||
const $=id=>document.getElementById(id),qa=(sel,root=document)=>[...root.querySelectorAll(sel)];
|
||
const esc=window.SunSafe.escapeHTML;
|
||
const PRINT_KEY='sunPrintSettingsV1';
|
||
const printDefaults={showMapInBlank:true,showBlankPhone:true,showBlankNote:true,showBlankTotals:true};
|
||
function readPrint(){try{return Object.assign({},printDefaults,JSON.parse(localStorage.getItem(PRINT_KEY)||'{}'))}catch(_){return {...printDefaults}}}
|
||
function writePrint(patch){const next=Object.assign({},readPrint(),patch);try{localStorage.setItem(PRINT_KEY,JSON.stringify(next))}catch(_){}return next}
|
||
function auditCard(grid){return qa(':scope > .enterprise-card',grid).find(card=>(card.querySelector('h2')?.textContent||'').trim()==='\u0418\u0441\u0442\u043e\u0440\u0438\u044f \u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0439')||null}
|
||
function pinAuditLast(){const grid=document.querySelector('#enterprise-settings .enterprise-grid');if(!grid)return;const audit=auditCard(grid);if(audit&&grid.lastElementChild!==audit)grid.appendChild(audit)}
|
||
function syncMapSetting(value){const cb=$('orderPrintMap');if(cb){cb.checked=Boolean(value);cb.dispatchEvent(new Event('change',{bubbles:true}))}try{if(typeof draft!=='undefined'&&draft)draft.printMap=Boolean(value)}catch(_){}}
|
||
function ensureBlankSettings(){
|
||
const grid=document.querySelector('#enterprise-settings .enterprise-grid');if(!grid)return;
|
||
let card=$('sunBlankSettingsCardV3');const s=readPrint();
|
||
if(!card){
|
||
card=document.createElement('section');card.id='sunBlankSettingsCardV3';card.className='enterprise-card wide';
|
||
card.innerHTML=`<h2>\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0431\u043b\u0430\u043d\u043a\u0430</h2><p class="hint">\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0431\u043b\u0430\u043d\u043a\u0430 \u0437\u0430\u043a\u0430\u0437\u0430 \u0434\u043b\u044f \u0432\u0441\u0435\u0445 \u0437\u0430\u043a\u0430\u0437\u043e\u0432.</p><div class="sun-settings-blank-grid"><label><input id="sunSettingsBlankMap" type="checkbox"> \u041f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u0442\u044c \u043a\u0430\u0440\u0442\u0443</label><label><input id="sunSettingsBlankPhone" type="checkbox"> \u041f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u0442\u044c \u0442\u0435\u043b\u0435\u0444\u043e\u043d \u043a\u043b\u0438\u0435\u043d\u0442\u0430</label><label><input id="sunSettingsBlankNote" type="checkbox"> \u041f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u0442\u044c \u043f\u0440\u0438\u043c\u0435\u0447\u0430\u043d\u0438\u0435</label><label><input id="sunSettingsBlankTotals" type="checkbox"> \u041f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u0442\u044c \u0440\u0430\u0441\u0447\u0451\u0442 \u043e\u043f\u043b\u0430\u0442\u044b</label></div>`;
|
||
const audit=auditCard(grid);window.SunSafe.insertBefore(grid,card,audit);
|
||
const bind=(id,key)=>{$(id).addEventListener('change',e=>{const next=writePrint({[key]:e.target.checked});if(key==='showMapInBlank')syncMapSetting(next.showMapInBlank)})};
|
||
bind('sunSettingsBlankMap','showMapInBlank');bind('sunSettingsBlankPhone','showBlankPhone');bind('sunSettingsBlankNote','showBlankNote');bind('sunSettingsBlankTotals','showBlankTotals');
|
||
}
|
||
const now=readPrint();
|
||
if($('sunSettingsBlankMap'))$('sunSettingsBlankMap').checked=now.showMapInBlank;
|
||
if($('sunSettingsBlankPhone'))$('sunSettingsBlankPhone').checked=now.showBlankPhone;
|
||
if($('sunSettingsBlankNote'))$('sunSettingsBlankNote').checked=now.showBlankNote;
|
||
if($('sunSettingsBlankTotals'))$('sunSettingsBlankTotals').checked=now.showBlankTotals;
|
||
pinAuditLast();
|
||
}
|
||
function enforceBlankSettingsPlacement(){
|
||
const settings=$('enterprise-settings');
|
||
qa('section,div').filter(el=>(el.querySelector(':scope > h2')?.textContent||'').trim()==='Настройки бланка').forEach(el=>{
|
||
if(el.id!=='sunBlankSettingsCardV3'||!settings?.contains(el))el.remove();
|
||
});
|
||
}
|
||
function settingsRefresh(){setTimeout(()=>{ensureBlankSettings();enforceBlankSettingsPlacement();pinAuditLast()},90)}
|
||
document.addEventListener('click',e=>{const b=e.target.closest('header nav button');if(!b)return;const name=String(b.dataset.navLabel||b.textContent||'').trim();if(name==='\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438')settingsRefresh()},false);
|
||
const settingsView=$('enterprise-settings');if(settingsView)new MutationObserver(()=>queueMicrotask(()=>{enforceBlankSettingsPlacement();pinAuditLast()})).observe(settingsView,{childList:true,subtree:true});
|
||
document.addEventListener('click',e=>{const b=e.target.closest('header nav button');if(!b)return;const name=String(b.dataset.navLabel||b.textContent||'').trim();if(name!=='Настройки')settingsView?.classList.remove('on');setTimeout(enforceBlankSettingsPlacement,0)},true);
|
||
// enterprise-settings is already observed above; no body-wide observer is needed here.
|
||
|
||
function stripClientManagerChannel(){
|
||
const panel=$('sunClientCommsPanel');if(!panel)return;
|
||
qa('label',panel).filter(label=>(label.textContent||'').trim().startsWith('\u041f\u043e\u0441\u043b\u0435\u0434\u043d\u0438\u0439 \u043a\u0430\u043d\u0430\u043b \u043c\u0435\u043d\u0435\u0434\u0436\u0435\u0440\u0430')).forEach(label=>label.remove());
|
||
}
|
||
const previousOpenClient=window.openClientCard;
|
||
if(typeof previousOpenClient==='function')window.openClientCard=function(...args){const result=previousOpenClient.apply(this,args);setTimeout(stripClientManagerChannel,0);setTimeout(stripClientManagerChannel,80);return result};
|
||
|
||
function currentCategory(){
|
||
const buttons=qa('#new .sun-catalog-top-toolbar .cats button,#new .cats button');
|
||
const active=buttons.find(b=>b.style.outline&&b.style.outline!=='none')||buttons.find(b=>b.getAttribute('aria-current')==='true');
|
||
if(active?.dataset?.category!=null)return Number(active.dataset.category);
|
||
const title=document.querySelector('#new .catalog h1')?.textContent?.trim()||'';
|
||
const names=[['\u0411\u043e\u043a\u0441\u044b',0],['\u041f\u0440\u0435\u043c\u0438\u0443\u043c',5],['\u041f\u043e\u0441\u0443\u0434\u0430',1],['\u0414\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f',2],['\u041d\u0430\u043f\u0438\u0442\u043a\u0438',3],['\u0414\u043e\u0441\u0442\u0430\u0432\u043a\u0430',4]];
|
||
return names.find(([name])=>title.startsWith(name))?.[1]??0;
|
||
}
|
||
const categoryName=cat=>({0:'\u0411\u043e\u043a\u0441\u044b',1:'\u041f\u043e\u0441\u0443\u0434\u0430',2:'\u0414\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f',3:'\u041d\u0430\u043f\u0438\u0442\u043a\u0438',4:'\u0414\u043e\u0441\u0442\u0430\u0432\u043a\u0430',5:'\u041f\u0440\u0435\u043c\u0438\u0443\u043c'})[cat]||'\u041a\u0430\u0442\u0430\u043b\u043e\u0433';
|
||
function allBoxes(){try{if(Array.isArray(boxes))return boxes}catch(_){}try{const saved=JSON.parse(localStorage.getItem('sunBoxes')||'[]');return Array.isArray(saved)?saved:[]}catch(_){return[]}}
|
||
function ingredientWeight(row){if(!Array.isArray(row))return 0;const direct=Math.max(0,Number(row[3]||0));if(direct)return direct;const m=String(row[0]||'').match(/[—–-]\s*(\d+(?:[.,]\d+)?)\s*г(?:\s*\/\s*шт\.?)?\s*$/i);return m?Math.max(0,Number(String(m[1]).replace(',','.'))):0}
|
||
function cleanIngredientName(name){return String(name||'').replace(/\s*[—–-]\s*\d+(?:[.,]\d+)?\s*г(?:\s*\/\s*шт\.?)?\s*$/i,'').trim()}
|
||
function composition(item){
|
||
if(Array.isArray(item?.ingredients)&&item.ingredients.some(x=>Array.isArray(x)&&x[0]))return item.ingredients.filter(x=>Array.isArray(x)&&x[0]).map(x=>{const q=Math.max(0,Number(x[1]||0)),u=String(x[2]||'').trim(),name=cleanIngredientName(x[0]),w=ingredientWeight(x),shown=Number.isInteger(q)?String(q):q.toLocaleString('ru-RU',{maximumFractionDigits:2});if(/^поз\.?$/i.test(u)&&q<=1)return `${name}${w?` — ${w.toLocaleString('ru-RU')} г/шт.`:''}`;return `${shown} ${u} ${name}${w?` — ${w.toLocaleString('ru-RU')} г/шт.`:''}`.replace(/\s+/g,' ').trim()});
|
||
if(Array.isArray(item?.composition)&&item.composition.length)return item.composition.map(x=>String(x||'').trim()).filter(Boolean);
|
||
return [];
|
||
}
|
||
const money=value=>`${Number(value||0).toLocaleString('ru-RU')} \u20bd`;
|
||
function ensureLivePdfButton(){
|
||
const head=document.querySelector('#new .catalog .catalog-head');if(!head)return;
|
||
const configure=qa('button',head).find(b=>(b.textContent||'').trim()==='\u041d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c');if(!configure)return;
|
||
let btn=$('sunLiveCatalogPdfButton');
|
||
if(!btn){btn=document.createElement('button');btn.id='sunLiveCatalogPdfButton';btn.type='button';btn.className='outline';btn.textContent='\u0421\u043a\u0430\u0447\u0430\u0442\u044c PDF';btn.title='PDF \u0444\u043e\u0440\u043c\u0438\u0440\u0443\u0435\u0442\u0441\u044f \u0438\u0437 \u0442\u0435\u043a\u0443\u0449\u0438\u0445 \u043f\u043e\u0437\u0438\u0446\u0438\u0439 \u0438 \u0446\u0435\u043d \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f.';btn.addEventListener('click',printLiveCatalog);configure.insertAdjacentElement('beforebegin',btn)}
|
||
const hide=currentCategory()===4;btn.hidden=hide;btn.style.display=hide?'none':'';
|
||
}
|
||
async function printLiveCatalog(){
|
||
const cat=currentCategory(),items=allBoxes().filter(item=>Number(item.category||0)===Number(cat));
|
||
if(!items.length){window.SunEnterprise?.toast?.('\u0412 \u044d\u0442\u043e\u043c \u0440\u0430\u0437\u0434\u0435\u043b\u0435 \u043d\u0435\u0442 \u043f\u043e\u0437\u0438\u0446\u0438\u0439.','warn');return}
|
||
let root=$('sunLiveCatalogPrintRoot');if(!root){root=document.createElement('div');root.id='sunLiveCatalogPrintRoot';document.body.appendChild(root)}
|
||
const total=items.length,dateText=new Date().toLocaleDateString('ru-RU');
|
||
root.innerHTML=items.map((item,index)=>{
|
||
const lines=composition(item),section=String(item.catalogSection||categoryName(cat)).toUpperCase(),photo=item.photo||(window.CateriumBranding.documentImage()),placeholder=item.photo?'':' is-placeholder';
|
||
const subtitle=cat===0?'\u041f\u041e\u041b\u041d\u042b\u0419 \u041a\u0410\u0422\u0410\u041b\u041e\u0413 \u0424\u0423\u0420\u0428\u0415\u0422\u041d\u042b\u0425 \u0411\u041e\u041a\u0421\u041e\u0412':`\u041a\u0410\u0422\u0410\u041b\u041e\u0413 ${String(categoryName(cat)).toUpperCase()}`;
|
||
const page=`${String(index+1).padStart(2,'0')} / ${String(total).padStart(2,'0')}`;
|
||
return `<article class="sun-live-catalog-page"><div class="sun-live-catalog-head"><img class="sun-live-catalog-logo" src="${window.CateriumBranding.logoHTML()}" alt=""><div class="sun-live-catalog-brand"><strong>${window.CateriumBranding.nameHTML().toUpperCase()}</strong><small>${esc(subtitle)}</small></div><div class="sun-live-catalog-counter">${esc(page)}</div></div><div class="sun-live-catalog-visual"><div class="sun-live-catalog-section">${esc(section)}</div><img class="sun-live-catalog-photo${placeholder}" src="${esc(photo)}" alt="${esc(item.name||'')}"></div><div class="sun-live-catalog-title-row"><div class="sun-live-catalog-name">${esc(item.name||'')}</div><div class="sun-live-catalog-price">${money(item.price||0)}</div></div><div class="sun-live-catalog-facts"><div class="sun-live-catalog-fact"><small>\u0412\u0435\u0441</small><b>${esc(String(item.weight||'\u2014').toUpperCase())}</b></div><div class="sun-live-catalog-fact"><small>\u041a\u043e\u043b-\u0432\u043e</small><b>${Number(item.pieces||0)>0?`${Math.round(Number(item.pieces))} \u0448\u0442.`:'\u2014'}</b></div></div><div class="sun-live-catalog-divider"></div><div class="sun-live-catalog-composition"><h3>\u0421\u043e\u0441\u0442\u0430\u0432</h3>${lines.length?`<ul>${lines.map(line=>`<li>${esc(line)}</li>`).join('')}</ul>`:'<p class="sun-live-catalog-empty">\u0421\u043e\u0441\u0442\u0430\u0432 \u043d\u0435 \u0443\u043a\u0430\u0437\u0430\u043d.</p>'}</div><div class="sun-live-catalog-footer"><span>${window.CateriumBranding.contactsHTML()}</span><span>Цены актуальны на ${esc(dateText)}</span></div></article>`
|
||
}).join('');
|
||
document.body.classList.add('sun-live-catalog-print');
|
||
const waitImages=qa('img',root).map(img=>img.complete?Promise.resolve():new Promise(resolve=>{img.addEventListener('load',resolve,{once:true});img.addEventListener('error',resolve,{once:true})}));
|
||
await Promise.race([Promise.all(waitImages),new Promise(resolve=>setTimeout(resolve,900))]);
|
||
const cleanup=()=>document.body.classList.remove('sun-live-catalog-print');
|
||
window.addEventListener('afterprint',cleanup,{once:true});window.print();setTimeout(cleanup,1200);
|
||
}
|
||
window.sunPrintLiveCatalog=printLiveCatalog;
|
||
ensureLivePdfButton();setTimeout(ensureLivePdfButton,120);setTimeout(ensureLivePdfButton,700);
|
||
document.addEventListener('click',e=>{if(e.target.closest('#new .cats button,#new .sun-catalog-top-toolbar .cats button'))setTimeout(ensureLivePdfButton,20)},true);
|
||
const catalog=$('new')?.querySelector('.catalog');if(catalog)new MutationObserver(()=>ensureLivePdfButton()).observe(catalog,{childList:true,subtree:true});
|
||
setTimeout(()=>{if($('enterprise-settings')?.classList.contains('on'))ensureBlankSettings();enforceBlankSettingsPlacement();pinAuditLast();stripClientManagerChannel()},180);
|
||
})();
|
||
</script>
|
||
|
||
|
||
<script src="app-runtime.js?v=20260917-banquet-menu"></script>
|