/* ===== MODULE: legacy-catalog-v175.js ===== */ window.SUN_LEGACY_CATALOG_V175=[]; ; /* ===== MODULE: pdf-viewer-patch.js ===== */ (function(){ 'use strict'; const $=id=>document.getElementById(id); const qa=(sel,root=document)=>[...root.querySelectorAll(sel)]; const IMAGE_DATA=window.SUN_CATALOG_IMAGE_DATA||{}; let lastPdfUrl=''; function toast(message,type){ try{ if(window.SunEnterprise?.toast) return window.SunEnterprise.toast(message,type); }catch(_){} console[type==='warn'?'warn':'log'](message); } 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=[['Боксы',0],['Премиум',5],['Посуда',1],['Дополнения',2],['Напитки',3],['Доставка',4]]; return names.find(([name])=>title.startsWith(name))?.[1]??0; } const categoryName=cat=>{try{if(typeof window.sunCatalogCategoryName==='function')return window.sunCatalogCategoryName(cat)}catch(_){}return ({0:'Боксы',1:'Посуда',2:'Дополнения',3:'Напитки',4:'Доставка',5:'Премиум'})[cat]||'Каталог'}; 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?.composition)&&item.composition.length)return item.composition.map(x=>String(x||'').trim()).filter(Boolean); 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()}); return []; } const money=value=>`${Number(value||0).toLocaleString('ru-RU')} ₽`; const mm=v=>v*5; function roundedRect(ctx,x,y,w,h,r){ const rr=Math.min(r,w/2,h/2); ctx.beginPath();ctx.moveTo(x+rr,y);ctx.arcTo(x+w,y,x+w,y+h,rr);ctx.arcTo(x+w,y+h,x,y+h,rr);ctx.arcTo(x,y+h,x,y,rr);ctx.arcTo(x,y,x+w,y,rr);ctx.closePath(); } function wrapText(ctx,text,maxWidth,maxLines=20){ const words=String(text||'').replace(/\s+/g,' ').trim().split(' ').filter(Boolean), lines=[]; let line=''; for(const word of words){ const test=line?line+' '+word:word; if(ctx.measureText(test).width<=maxWidth||!line) line=test; else {lines.push(line);line=word;if(lines.length>=maxLines-1)break;} } if(line&&lines.length2&&ctx.measureText(last+'…').width>maxWidth)last=last.slice(0,-1); lines[maxLines-1]=last.replace(/[\s,.;:-]+$/,'')+'…'; } return lines; } function drawTextLines(ctx,lines,x,y,lineHeight){lines.forEach((line,i)=>ctx.fillText(line,x,y+i*lineHeight));} function imageSource(src){ src=String(src||'').trim(); if(!src)return ''; if(/^data:image\//i.test(src))return src; return IMAGE_DATA[src]||IMAGE_DATA[src.replace(/^\.\//,'')]||(/^sun-logo\.png$/i.test(src)?window.CateriumBranding.documentLogo():src); } function loadImage(src){ return new Promise(resolve=>{ const raw=window.SunSafe.imageAssetSrc(imageSource(src)),img=new Image();let done=false; const finish=value=>{if(done)return;done=true;clearTimeout(timer);resolve(value)}; try{const url=new URL(raw,document.baseURI||location.href);if(/^https?:$/i.test(url.protocol)&&url.origin!==location.origin)img.crossOrigin='anonymous'}catch(_){} img.onload=()=>finish(img); img.onerror=()=>finish(null); const timer=setTimeout(()=>finish(null),15000); img.src=raw; }); } function drawContain(ctx,img,x,y,w,h,pad=0){ if(!img)return; const iw=img.naturalWidth||img.width, ih=img.naturalHeight||img.height; if(!iw||!ih)return; const aw=Math.max(1,w-pad*2),ah=Math.max(1,h-pad*2),scale=Math.min(aw/iw,ah/ih); const dw=iw*scale,dh=ih*scale; ctx.drawImage(img,x+(w-dw)/2,y+(h-dh)/2,dw,dh); } async function renderPageJpeg(item,cat,index,total,dateText,brand=window.CateriumBranding.identity()){ const W=1050,H=1485,canvas=document.createElement('canvas');canvas.width=W;canvas.height=H; const ctx=canvas.getContext('2d',{alpha:false}); ctx.fillStyle='#f8f4eb';ctx.fillRect(0,0,W,H); ctx.textBaseline='alphabetic';ctx.textAlign='left'; const logo=await loadImage((brand.logo||'')); if(logo)drawContain(ctx,logo,85,50,72,72,1); ctx.fillStyle='#1f1f1d';ctx.font='800 36px Arial, sans-serif';ctx.fillText((brand.name.toUpperCase()),177,83); const subtitle=cat===0?'ПОЛНЫЙ КАТАЛОГ ФУРШЕТНЫХ БОКСОВ':`КАТАЛОГ ${categoryName(cat).toUpperCase()}`; ctx.fillStyle='#88827a';ctx.font='600 16px Arial, sans-serif';ctx.fillText(subtitle,178,112); ctx.textAlign='right';ctx.font='500 15px Arial, sans-serif';ctx.fillText(`${String(index+1).padStart(2,'0')} / ${String(total).padStart(2,'0')}`,965,69);ctx.textAlign='left'; ctx.fillStyle='#c99a32';ctx.fillRect(85,143,880,2); const vx=85,vy=178,vw=880,vh=480; ctx.fillStyle='#f0ebdf';roundedRect(ctx,vx,vy,vw,vh,20);ctx.fill(); const photo=await loadImage(item.photo||(brand.logo||'')); if(photo)drawContain(ctx,photo,vx,vy,vw,vh,item.photo?14:165); const section=String(item.catalogSection||categoryName(cat)).toUpperCase(); ctx.font='800 17px Arial, sans-serif'; const sectionW=Math.min(720,Math.max(175,ctx.measureText(section).width+54)); ctx.fillStyle='#191919';roundedRect(ctx,115,201,sectionW,43,22);ctx.fill(); ctx.fillStyle='#fff';ctx.fillText(section,142,229); const titleY=717; ctx.fillStyle='#1f1f1d';ctx.font='800 36px Arial, sans-serif'; const nameLines=wrapText(ctx,item.name||'',650,2);drawTextLines(ctx,nameLines,85,titleY,41); ctx.textAlign='right';ctx.fillStyle='#c99a32';ctx.font='800 37px Arial, sans-serif';ctx.fillText(money(item.price||0),965,titleY);ctx.textAlign='left'; const factsY=820; ctx.fillStyle='#77736d';ctx.font='800 14px Arial, sans-serif';ctx.fillText('ВЕС',85,factsY);ctx.fillText('КОЛ-ВО',525,factsY); ctx.fillStyle='#232321';ctx.font='800 19px Arial, sans-serif';ctx.fillText(String(item.weight||'—').toUpperCase(),85,factsY+31);ctx.fillText(Number(item.pieces||0)>0?`${Math.round(Number(item.pieces))} ШТ.`:'—',525,factsY+31); ctx.fillStyle='#d9d0c2';ctx.fillRect(85,889,880,2); ctx.fillStyle='#1f1f1d';ctx.font='800 23px Arial, sans-serif';ctx.fillText('СОСТАВ',85,939); let lines=composition(item),rendered=[]; ctx.font='400 19px Arial, sans-serif'; for(const raw of lines){ const wrapped=wrapText(ctx,raw,795,2); for(const line of wrapped){if(rendered.length<9)rendered.push(line)} if(rendered.length>=9)break; } if(!rendered.length){ctx.fillStyle='#77736d';ctx.font='400 18px Arial, sans-serif';ctx.fillText('Состав не указан.',85,985)} else{ let y=985;ctx.font='400 19px Arial, sans-serif'; for(const line of rendered){ ctx.fillStyle='#c99a32';ctx.beginPath();ctx.arc(93,y-6,5,0,Math.PI*2);ctx.fill(); ctx.fillStyle='#33312e';ctx.fillText(line,113,y);y+=32; } } ctx.fillStyle='#d9d0c2';ctx.fillRect(85,1400,880,2); ctx.fillStyle='#88827a';ctx.font='400 14px Arial, sans-serif';ctx.fillText((brand.contacts),85,1435); ctx.textAlign='right';ctx.fillText(`Цены актуальны на ${dateText}`,965,1435);ctx.textAlign='left'; let dataUrl; try{dataUrl=canvas.toDataURL('image/jpeg',0.88)}catch(_){dataUrl=''} if(!dataUrl)throw new Error('Не удалось сформировать страницу PDF.'); const binary=atob(dataUrl.split(',')[1]),bytes=new Uint8Array(binary.length); for(let i=0;iwindow.SunPdfEngine.fromJpegs(pages); async function createCatalogPdfBlob(cat,progress){ const items=allBoxes().filter(item=>Number(item.category||0)===Number(cat)); if(!items.length)throw new Error('В этом разделе нет позиций.'); const pages=[],dateText=new Date().toLocaleDateString('ru-RU'),brand=window.CateriumBranding.identity(); for(let i=0;isetTimeout(resolve,0)); } return buildPdf(pages); } function loadingPage(win,title){ try{ win.document.open(); win.document.write(`${window.CateriumBranding.escapeHTML(title)}
Формируем актуальный PDF-каталог
Подготовка…
`); win.document.close(); }catch(_){} } function updateProgress(win,current,total){try{const p=win.document.getElementById('p');if(p)p.textContent=`Страница ${current} из ${total}`;}catch(_){} } async function openCatalogPdfViewer(){ const cat=currentCategory(); if(cat===4)return; const viewer=window.open('about:blank','_blank'); if(!viewer){toast('Браузер заблокировал новую вкладку. Разрешите всплывающие окна для приложения.','warn');return} loadingPage(viewer,("Каталог — "+window.CateriumBranding.documentName())); try{ const blob=await createCatalogPdfBlob(cat,(n,t)=>updateProgress(viewer,n,t)); if(lastPdfUrl)URL.revokeObjectURL(lastPdfUrl); lastPdfUrl=URL.createObjectURL(blob); viewer.location.replace(lastPdfUrl); }catch(err){ try{viewer.document.body.innerHTML=`
Не удалось открыть PDF.

${String(err?.message||err)}

`}catch(_){} toast(err?.message||'Не удалось сформировать PDF.','warn'); } } function installButton(){ const head=document.querySelector('#new .catalog .catalog-head');if(!head)return; const configure=qa('button',head).find(b=>(b.textContent||'').trim()==='Настроить');if(!configure)return; let old=$('sunLiveCatalogPdfButton'),btn; if(old){btn=old.cloneNode(true);old.replaceWith(btn)} else{btn=document.createElement('button');btn.id='sunLiveCatalogPdfButton';btn.type='button';btn.className='outline';configure.insertAdjacentElement('beforebegin',btn)} btn.textContent='PDF'; btn.title='Открыть актуальный PDF-каталог. В просмотрщике браузера можно скачать или распечатать.'; btn.onclick=openCatalogPdfViewer; const hide=currentCategory()===4;btn.hidden=hide;btn.style.display=hide?'none':'inline-flex'; } window.sunOpenCatalogPdfViewer=openCatalogPdfViewer; window.sunBuildCatalogPdfBlob=()=>createCatalogPdfBlob(currentCategory()); window.sunCatalogDebugPdfPages=async(cat=currentCategory(),limit=3)=>{const items=allBoxes().filter(item=>Number(item.category||0)===Number(cat)).slice(0,Math.max(1,Number(limit||3)));const dateText=new Date().toLocaleDateString('ru-RU'),pages=[];for(let i=0;i{if(e.target.closest('#new .cats button,#new .sun-catalog-top-toolbar .cats button'))setTimeout(installButton,30)},true); const catalog=document.querySelector('#new .catalog');if(catalog&&'MutationObserver'in window)new MutationObserver(()=>{const b=$('sunLiveCatalogPdfButton');if(!b||b.textContent!=='PDF'||b.onclick!==openCatalogPdfViewer)queueMicrotask(installButton)}).observe(catalog,{childList:true,subtree:true}); })(); ; /* ===== MODULE: feature-pack-v1.js ===== */ (function(){ 'use strict'; if(window.__sunFeaturePackV1)return; window.__sunFeaturePackV1=true; const $=id=>document.getElementById(id); const qa=(sel,root=document)=>[...root.querySelectorAll(sel)]; const esc=window.SunSafe.escapeHTML; const money=v=>`${Math.round(Number(v||0)).toLocaleString('ru-RU')} ₽`; const toast=(m,t='success')=>{try{return window.SunEnterprise?.toast?.(m,t)}catch(_){}; if(t==='warn'||t==='error')console.warn(m);}; const style=document.createElement('style'); style.id='sun-feature-pack-v1-style'; style.textContent=` #new .catalog-head{gap:8px;flex-wrap:wrap} #new .sun-catalog-search{height:35px;min-width:180px;width:min(235px,28vw);padding:6px 10px;border:1px solid #d9dde0;border-radius:8px;background:#fff;color:#203040} #new .sun-catalog-tabs-button{white-space:nowrap} #manager .sun-manager-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 12px} #manager .sun-manager-toolbar .primary{margin:0} #manager .sun-manager-search{flex:1 1 220px;min-width:180px} .sun-category-list{display:grid;gap:8px;margin-top:12px} .sun-category-row{display:grid;grid-template-columns:minmax(180px,1fr) 58px auto auto;gap:8px;align-items:center;padding:9px;border:1px solid #e0e4e8;border-radius:10px;background:#fff} .sun-category-row.is-hidden{opacity:.6;background:#f6f7f8} .sun-category-row input{width:100%}.sun-category-color{display:grid;place-items:center;margin:0}.sun-category-color input{width:42px;height:34px;padding:2px;border:1px solid #d9dde0;border-radius:8px;background:#fff;cursor:pointer} .sun-category-row small{color:#7b858d;white-space:nowrap} .sun-category-row-actions{display:flex;gap:5px;flex-wrap:wrap;justify-content:flex-end} .sun-category-add{display:grid;grid-template-columns:1fr auto;gap:8px;margin-top:14px;padding-top:12px;border-top:1px solid #e6e8eb} .sun-order-requisites{display:none;margin-top:4px} .sun-order-requisites-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px} .sun-order-requisites-grid .wide{grid-column:1/-1} .sun-order-requisites h3{margin:6px 0 12px;color:#15364c} .sun-order-qr-box{display:grid;grid-template-columns:110px minmax(0,1fr);gap:14px;align-items:center;margin-top:16px;padding:12px;border:1px solid #e1e4e7;border-radius:12px;background:#fafbfc} .sun-order-qr-preview{width:100px;height:100px;border:1px dashed #c7cdd2;border-radius:8px;background:#fff;display:grid;place-items:center;overflow:hidden;color:#8a949b;font-size:11px;text-align:center;padding:5px} .sun-order-qr-preview img{width:100%;height:100%;object-fit:contain} .sun-order-qr-actions{display:flex;gap:7px;flex-wrap:wrap;margin-top:8px} .sun-discount-panel{display:grid;grid-template-columns:1fr;gap:10px;margin-top:10px;padding:11px;border:1px solid #e3e6e9;border-radius:10px;background:#fafbfc} .sun-discount-combo{display:grid;grid-template-columns:minmax(0,1fr) 78px;gap:6px} .sun-promo-select-wrap{display:grid;gap:5px} .sun-promo-select-wrap select{width:100%} .sun-promo-meta{font-size:12px;color:#68717a;line-height:1.35;min-height:16px} .sun-discount-panel label>span{display:block;color:#68717a;font-size:12px;margin-bottom:5px} .sun-order-summary-discount{color:#8a5b00} .sun-order-summary-net{font-weight:800;color:#15364c} .sun-order-summary-promo small{color:#7b858d;margin-left:5px} .sun-doc-qr{width:92px;height:92px;object-fit:contain;border:1px solid #e0e3e5;border-radius:7px;padding:4px;background:#fff} .sun-doc-title-with-qr{display:flex;align-items:flex-start;gap:14px;justify-content:flex-end} .sun-doc-title-with-qr .sun-doc-title{text-align:right} .sun-doc-qr-wrap{display:flex;align-items:center;gap:9px;max-width:295px} .sun-doc-qr-note{min-width:115px;max-width:180px;padding:7px 8px;border:1px dashed #c8cdd2;border-radius:8px;background:#fff;color:#4f5962;font-size:11px;line-height:1.35;text-align:left;white-space:pre-wrap;overflow-wrap:anywhere} .sun-doc-qr-note:empty:before{content:attr(data-placeholder);color:#9aa1a8} .sun-doc-qr-note:focus{outline:2px solid #d8b766;outline-offset:1px;border-color:transparent} @media print{.sun-doc-qr-note{border:0!important;padding:0!important;background:transparent!important;outline:0!important}.sun-doc-qr-note:empty{display:none!important}} .sun-receipt .sun-receipt-party{margin:4px 0;padding:6px 8px;border-radius:6px} .sun-receipt .sun-receipt-party h3{font-size:11px;margin:0 0 4px} .sun-receipt .sun-receipt-party-grid{grid-template-columns:repeat(4,minmax(0,1fr));gap:2px 10px;font-size:9.5px} .sun-receipt .sun-receipt-party-grid div{padding:1px 0;border-bottom:0;line-height:1.15} .sun-receipt .sun-receipt-party-grid span{font-size:7.5px;margin-bottom:1px} .sun-receipt .sun-receipt-party-grid b{font-size:9.5px;line-height:1.15} #sunReceiptSettingsCard .sun-buyer-settings-hidden{display:none!important} .sun-default-qr-setting{display:grid;grid-template-columns:100px minmax(0,1fr);gap:14px;align-items:center} .sun-default-qr-setting .preview{width:92px;height:92px;border:1px dashed #cbd1d6;border-radius:8px;background:#fff;display:grid;place-items:center;overflow:hidden;color:#8a949b;font-size:11px;text-align:center;padding:4px} .sun-default-qr-setting .preview img{width:100%;height:100%;object-fit:contain} .sun-default-qr-setting .actions{justify-content:flex-start;margin-top:8px} .sun-promo-stats{margin-top:18px;border:1px solid #e0e4e8;border-radius:12px;background:#fff;padding:14px} .sun-promo-stats h2{margin:0 0 4px;color:#15364c} .sun-promo-stats table{width:100%;border-collapse:collapse;margin-top:12px} .sun-promo-stats th,.sun-promo-stats td{padding:8px;border-bottom:1px solid #e8eaec;text-align:left} .sun-promo-stats th{font-size:12px;color:#68717a;background:#fafbfc}.sun-promo-stats td.num,.sun-promo-stats th.num{text-align:right} .sun-box-weight-field,.sun-box-pieces-field{margin-top:8px}.sun-box-weight-field input,.sun-box-pieces-field input{max-width:220px}.sun-box-pieces-field small{display:block;margin-top:3px;color:#7b858d;line-height:1.3}.sun-ingredient-head,.ingredient.sun-ingredient-v175{display:grid;grid-template-columns:minmax(220px,1fr) 88px 78px 112px 34px;gap:6px;align-items:center}.sun-ingredient-head{margin:8px 0 4px;color:#68717a;font-size:11px;font-weight:700}.sun-ingredient-head span:nth-child(n+2){text-align:center}.ingredient.sun-ingredient-v175 input{text-align:left;min-width:0}.ingredient.sun-ingredient-v175 input[type=number]{text-align:center}.sun-order-line-name small{display:block;margin-top:3px;color:#7b858d;font-size:10px;font-weight:600;line-height:1.25}.sun-order-line-name small b{color:#53616d}.sun-doc .doc-sub.sun-doc-weight{margin-top:2px;color:#66727c;font-weight:600} @media(max-width:850px){.sun-receipt .sun-receipt-party-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} @media(max-width:650px){#new .sun-catalog-search{width:100%;flex:1 1 100%}.sun-order-requisites-grid{grid-template-columns:1fr}.sun-order-qr-box,.sun-default-qr-setting{grid-template-columns:1fr}.sun-category-row{grid-template-columns:1fr}.sun-category-row-actions{justify-content:flex-start}.sun-ingredient-head{display:none}.ingredient.sun-ingredient-v175{grid-template-columns:minmax(0,1fr) 64px 58px 84px 32px}.ingredient.sun-ingredient-v175 input{padding:7px 5px;font-size:12px}} @media print{.sun-doc-title-with-qr{gap:8mm}.sun-doc-qr{width:25mm;height:25mm}.sun-receipt .sun-receipt-party{padding:1.5mm 2mm;margin:1mm 0}.sun-receipt .sun-receipt-party-grid{grid-template-columns:repeat(4,minmax(0,1fr));gap:.5mm 2mm}} `; document.head.appendChild(style); const banquetStyle=document.createElement('style'); banquetStyle.id='sun-banquet-constructor-style'; banquetStyle.textContent=` #tiles .sun-banquet-constructor{grid-column:1/-1;display:flex;flex-direction:column;gap:16px} .sun-banquet-list{min-width:0} .sun-banquet-group{margin-bottom:6px} .sun-banquet-group h4{margin:16px 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;color:#a3806f;border-bottom:1px solid #efe1d8;padding-bottom:5px} .sun-banquet-group:first-child h4{margin-top:0} .sun-banquet-row{display:flex;align-items:center;gap:10px;padding:8px 6px;border-radius:9px;cursor:pointer} .sun-banquet-row:hover{background:#faf5f0} .sun-banquet-row input[type=checkbox]{width:17px;height:17px;flex:0 0 auto;accent-color:#c0554f} .sun-banquet-row-name{flex:1 1 auto;font-size:13px;font-weight:600;color:#2c2620;display:flex;flex-direction:column;gap:2px;min-width:0} .sun-banquet-row-name small{font-weight:500;color:#8a7f74;font-size:11px} .sun-banquet-row-price{font-weight:700;color:#c0554f;font-size:13px;white-space:nowrap} .sun-banquet-edit{border:none;background:none;color:#b1a599;cursor:pointer;font-size:13px;padding:2px 6px;flex:0 0 auto} .sun-banquet-edit:hover{color:#5b4f45} .sun-banquet-summary{background:#fff;border:1px solid #efe1d8;border-radius:16px;padding:16px 18px} .sun-banquet-summary h3{margin:0 0 10px;font-size:14px} .sun-banquet-summary-row{display:flex;justify-content:space-between;gap:12px;padding:6px 0;font-size:12.5px;border-bottom:1px dashed #f1e7de} .sun-banquet-summary-total{display:flex;justify-content:space-between;align-items:center;margin-top:10px;padding-top:10px;border-top:2px solid #2c2620;font-size:16px;font-weight:800} @media(min-width:820px){#tiles .sun-banquet-constructor{flex-direction:row;align-items:flex-start}.sun-banquet-list{flex:1 1 auto}.sun-banquet-summary{flex:0 0 260px;position:sticky;top:12px}} `; document.head.appendChild(banquetStyle); // ---------------- Catalog categories + search ---------------- const CAT_KEY='sunCatalogCategoriesV2'; const defaultCats=[ {id:0,name:'Боксы',accent:'#d49a2a',prep:true,hidden:false,order:0}, {id:5,name:'Премиум',accent:'#7a5aa6',prep:true,hidden:false,order:1}, {id:3,name:'Напитки',accent:'#4f9bd8',prep:false,hidden:false,order:2}, {id:2,name:'Дополнения',accent:'#56a889',prep:false,hidden:false,order:3}, {id:1,name:'Посуда',accent:'#a78bfa',prep:false,hidden:false,order:4}, {id:4,name:'Доставка',accent:'#cf9f83',prep:false,hidden:false,order:5}, {id:6,name:'Банкетное меню',accent:'#c0554f',prep:false,hidden:false,order:6} ]; function readCats(){ let saved=[];try{saved=JSON.parse(localStorage.getItem(CAT_KEY)||'[]');if(!Array.isArray(saved))saved=[]}catch(_){saved=[]} const map=new Map(saved.filter(x=>x&&Number.isFinite(Number(x.id))).map(x=>[Number(x.id),x])); defaultCats.forEach(d=>{if(!map.has(d.id))map.set(d.id,d);else map.set(d.id,{...d,...map.get(d.id),id:d.id})}); try{(Array.isArray(boxes)?boxes:[]).forEach(item=>{const id=Number(item?.category||0);if(Number.isFinite(id)&&!map.has(id))map.set(id,{id,name:`Вкладка ${id}`,accent:'#708090',prep:true,hidden:false,order:map.size})})}catch(_){} const arr=[...map.values()].map((c,i)=>({id:Number(c.id),name:String(c.name||`Вкладка ${i+1}`).trim()||`Вкладка ${i+1}`,accent:c.accent||'#708090',prep:c.prep!==false,hidden:Boolean(c.hidden),order:Number.isFinite(Number(c.order))?Number(c.order):i})); arr.sort((a,b)=>a.order-b.order||a.id-b.id);arr.forEach((c,i)=>c.order=i);return arr; } let catalogCats=readCats(); const CAT_LAYOUT_VERSION_KEY='sunCatalogCategoryLayoutVersion'; const CAT_LAYOUT_VERSION='2026-09-02-premium-between-boxes-and-drinks'; if(localStorage.getItem(CAT_LAYOUT_VERSION_KEY)!==CAT_LAYOUT_VERSION){const desired=[0,5,3,2,1,4];const rank=new Map(desired.map((id,i)=>[id,i]));catalogCats.sort((a,b)=>(rank.has(a.id)?rank.get(a.id):100+a.order)-(rank.has(b.id)?rank.get(b.id):100+b.order));catalogCats.forEach((c,i)=>c.order=i);try{localStorage.setItem(CAT_KEY,JSON.stringify(catalogCats));localStorage.setItem(CAT_LAYOUT_VERSION_KEY,CAT_LAYOUT_VERSION)}catch(_){}} function saveCats(){catalogCats.sort((a,b)=>a.order-b.order||a.id-b.id).forEach((c,i)=>c.order=i);try{localStorage.setItem(CAT_KEY,JSON.stringify(catalogCats))}catch(_){}window.dispatchEvent(new CustomEvent('sun:catalog-categories-changed'));} function tabTextColor(value){const hex=String(value||'').trim();const m=/^#([0-9a-f]{6})$/i.exec(hex);if(!m)return '#fff';const n=parseInt(m[1],16),r=(n>>16)&255,g=(n>>8)&255,b=n&255;return (r*299+g*587+b*114)>160000?'#15364c':'#fff'} const catById=id=>catalogCats.find(c=>Number(c.id)===Number(id)); const catName=id=>catById(id)?.name||'Каталог'; const visibleCats=()=>catalogCats.filter(c=>!c.hidden).sort((a,b)=>a.order-b.order); let activeCat=visibleCats()[0]?.id??0; let catalogQuery=''; let managerQuery=''; window.sunCatalogCategoryName=catName; window.sunCatalogCategories=()=>catalogCats.map(c=>({...c})); window.sunCurrentCatalogCategory=()=>activeCat; function ensureCategoryModal(){ if($('sunCategoryManager'))return; const modal=document.createElement('div');modal.id='sunCategoryManager';modal.className='modal'; modal.innerHTML=`

Вкладки каталога

Скрытая вкладка и её товары не удаляются — её можно вернуть.

`; document.body.appendChild(modal); $('sunAddCategoryBtn').onclick=()=>{ const name=$('sunNewCategoryName').value.trim();if(!name)return; const nextId=Math.max(4,...catalogCats.map(c=>Number(c.id)||0))+1; catalogCats.push({id:nextId,name,accent:'#708090',prep:true,hidden:false,order:catalogCats.length});saveCats();$('sunNewCategoryName').value='';renderCategoryManager();renderCategoryTabs();syncEditorCategoryOptions(); }; } function renderCategoryManager(){ ensureCategoryModal();const root=$('sunCategoryRows');if(!root)return; root.innerHTML=catalogCats.slice().sort((a,b)=>a.order-b.order).map(c=>{ const count=(typeof boxes!=='undefined'?boxes:[]).filter(b=>Number(b.category||0)===Number(c.id)).length; return `
${count} поз.
`; }).join(''); qa('[data-cat-name]',root).forEach(input=>input.onchange=()=>{const c=catById(input.dataset.catName);if(!c)return;c.name=input.value.trim()||c.name;saveCats();renderCategoryTabs();syncEditorCategoryOptions();renderCatalogV5();renderCategoryManager();}); qa('[data-cat-color]',root).forEach(input=>{input.oninput=()=>{const c=catById(input.dataset.catColor);if(!c)return;c.accent=input.value||c.accent;renderCategoryTabs();};input.onchange=()=>{const c=catById(input.dataset.catColor);if(!c)return;c.accent=input.value||c.accent;saveCats();renderCategoryTabs();};}); qa('[data-cat-toggle]',root).forEach(btn=>btn.onclick=()=>{const c=catById(btn.dataset.catToggle);if(!c)return;c.hidden=!c.hidden;if(c.hidden&&Number(activeCat)===Number(c.id))activeCat=visibleCats().find(x=>Number(x.id)!==Number(c.id))?.id??0;saveCats();renderCategoryTabs();syncEditorCategoryOptions();renderCatalogV5();renderCategoryManager();}); const move=(id,dir)=>{const arr=catalogCats.slice().sort((a,b)=>a.order-b.order),i=arr.findIndex(c=>Number(c.id)===Number(id)),j=i+dir;if(i<0||j<0||j>=arr.length)return;[arr[i],arr[j]]=[arr[j],arr[i]];arr.forEach((c,k)=>c.order=k);catalogCats=arr;saveCats();renderCategoryTabs();renderCategoryManager();}; qa('[data-cat-up]',root).forEach(btn=>btn.onclick=()=>move(btn.dataset.catUp,-1));qa('[data-cat-down]',root).forEach(btn=>btn.onclick=()=>move(btn.dataset.catDown,1)); } window.sunOpenCategoryManager=()=>{renderCategoryManager();window.modal?.('sunCategoryManager');}; function renderCategoryTabs(){ const panel=document.querySelector('#new .cats');if(!panel)return; const visible=visibleCats();if(!visible.some(c=>Number(c.id)===Number(activeCat)))activeCat=visible[0]?.id??0; panel.innerHTML=visible.map(c=>``).join('')+'

Выберите раздел. Любую позицию можно добавить, изменить или удалить.

'; qa('button',panel).forEach(btn=>{const c=catById(btn.dataset.category),on=Number(btn.dataset.category)===Number(activeCat);btn.style.background=c?.accent||'#708090';btn.style.color=tabTextColor(c?.accent||'#708090');btn.style.opacity=on?'1':'.72';btn.style.outline=on?'2px solid #15364c':'none';btn.style.outlineOffset=on?'2px':'0';btn.onclick=()=>{activeCat=Number(btn.dataset.category);catalogQuery='';const search=$('sunCatalogSearch');if(search)search.value='';renderCategoryTabs();renderCatalogV5();};}); } function syncEditorCategoryOptions(){ const select=$('itemCategory');if(!select)return;const current=select.value; select.innerHTML=catalogCats.slice().sort((a,b)=>a.order-b.order).map(c=>``).join(''); if([...select.options].some(o=>o.value===current))select.value=current; } function ensureCatalogTools(){ const head=document.querySelector('#new .catalog .catalog-head');if(!head)return; const configure=qa('button',head).find(b=>(b.textContent||'').trim()==='Настроить');if(!configure)return; let search=$('sunCatalogSearch');if(!search){search=document.createElement('input');search.id='sunCatalogSearch';search.className='sun-catalog-search';search.type='search';search.placeholder='Поиск по № бокса';search.inputMode='numeric';search.oninput=()=>{catalogQuery=search.value;renderCatalogV5();};configure.insertAdjacentElement('beforebegin',search)} let tabs=$('sunCatalogTabsButton');if(!tabs){tabs=document.createElement('button');tabs.id='sunCatalogTabsButton';tabs.className='outline sun-catalog-tabs-button';tabs.type='button';tabs.textContent='Вкладки';tabs.onclick=window.sunOpenCategoryManager;configure.insertAdjacentElement('beforebegin',tabs)} } function lineUnitPrice(line){const direct=Number(line?.price);if(Number.isFinite(direct)&&direct>=0)return direct;const item=boxes.find(x=>String(x.id)===String(line?.id));return Math.max(0,Number(item?.price||0));} function baseTotal(order){return (order?.lines||[]).reduce((s,l)=>s+lineUnitPrice(l)*Math.max(0,Number(l.qty||0)),0)} window.sunBaseOrderTotal=baseTotal; function parseWeightGrams(value){const raw=String(value??'').trim().toLowerCase().replace(',','.');if(!raw)return 0;const m=raw.match(/(\d+(?:\.\d+)?)/);if(!m)return 0;let n=Math.max(0,Number(m[1]||0));if(/кг/.test(raw))n*=1000;return Math.round(n)} const pieceUnitRe=/^(?:шт\.?|пор\.?|кус\.?|шарик(?:а|ов|и)?|креманк(?:а|и|е)?)$/i; function ingredientUnitWeight(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 inferBoxPieces(item){if(item?.demo&&item.pieces===0)return 0;const explicit=Math.max(0,Number(item?.pieces||0));if(explicit)return Math.round(explicit);let sum=0;for(const row of item?.ingredients||[]){if(!Array.isArray(row))continue;const unit=String(row[2]||'').trim().toLowerCase();if(pieceUnitRe.test(unit))sum+=Math.max(0,Number(row[1]||0));}return Math.round(sum)} function ingredientDisplayLine(row){if(!Array.isArray(row)||!String(row[0]||'').trim())return'';const name=cleanIngredientName(row[0]),qty=Math.max(0,Number(row[1]||0)),unit=String(row[2]||'').trim(),w=ingredientUnitWeight(row);if(/^поз\.?$/i.test(unit)&&qty<=1)return `${name}${w?` — ${w.toLocaleString('ru-RU')} г/шт.`:''}`;const q=Number.isInteger(qty)?String(qty):qty.toLocaleString('ru-RU',{maximumFractionDigits:2});return `${q} ${unit} ${name}${w?` — ${w.toLocaleString('ru-RU')} г/шт.`:''}`.replace(/\s+/g,' ').trim()} function normalizeBoxItem(item,{cleanup=false}={}){if(!item||![0,5].includes(Number(item.category||0)))return item;const legacyComposition=Array.isArray(item.composition)?item.composition.slice():[];item.ingredients=Array.isArray(item.ingredients)?item.ingredients.filter(r=>Array.isArray(r)&&String(r[0]||'').trim()).map((r,index)=>{const row=[String(r[0]||'').trim(),Math.max(0,Number(r[1]||0)),String(r[2]||'').trim()||'шт.',Math.max(0,Number(r[3]||0))];if(!row[3])row[3]=ingredientUnitWeight(r)||ingredientUnitWeight([legacyComposition[index]||'',0,'',0]);return row;}):[]; // Box №18 had a total row "25 шт. тостов ассорти" mixed into two real varieties. The total belongs in pieces, not in composition. if(String(item.id)==='catalog-013'||/№\s*18\b.*тосты\s+ассорти/i.test(String(item.name||''))){const idx=item.ingredients.findIndex(r=>/тостов?\s+ассорти/i.test(String(r[0]||''))&&pieceUnitRe.test(String(r[2]||''))&&Number(r[1]||0)>1);if(idx>=0){item.pieces=Math.max(Number(item.pieces||0),Number(item.ingredients[idx][1]||0));item.ingredients.splice(idx,1);}} if(!Number(item.pieces||0))item.pieces=inferBoxPieces(item); if((!Array.isArray(item.composition)||!item.composition.filter(Boolean).length)&&item.ingredients.length)item.composition=item.ingredients.map(ingredientDisplayLine).filter(Boolean); return item;} function weightText(item,prefix=true){const raw=String(item?.weight||'').trim();if(!raw)return'';return prefix?`Вес бокса: ${raw}`:raw} function piecesText(item,prefix=true){const n=inferBoxPieces(item);if(!n)return'';return prefix?`Количество: ${n.toLocaleString('ru-RU')} шт.`:`${n.toLocaleString('ru-RU')} шт.`} function ensureBoxWeightEditor(){ const price=$('boxPrice'),dialog=document.querySelector('#editor .dialog');if(!price||!dialog)return null; let label=$('sunBoxWeightLabel'); if(!label){label=document.createElement('label');label.id='sunBoxWeightLabel';label.className='sun-box-weight-field';label.innerHTML='Вес бокса, г';price.closest('label')?.insertAdjacentElement('afterend',label)} return label; } function ensureBoxPiecesEditor(){const weight=ensureBoxWeightEditor(),dialog=document.querySelector('#editor .dialog');if(!dialog)return null;let label=$('sunBoxPiecesLabel');if(!label){label=document.createElement('label');label.id='sunBoxPiecesLabel';label.className='sun-box-pieces-field';label.innerHTML='Количество канапе / шт. в боксеЭто общее количество готовых единиц в одном боксе. Не добавляйте его отдельной строкой в «Состав / ТТК».';weight?.insertAdjacentElement('afterend',label)}return label} function syncBoxWeightEditor(category,item){const label=ensureBoxWeightEditor(),input=$('boxWeightGrams'),piecesLabel=ensureBoxPiecesEditor(),piecesInput=$('boxPiecesCount');if(!label||!input)return;const isBox=[0,5].includes(Number(category));label.style.display=isBox?'flex':'none';if(piecesLabel)piecesLabel.style.display=isBox?'flex':'none';if(isBox){input.value=parseWeightGrams(item?.weight)||'';if(piecesInput)piecesInput.value=inferBoxPieces(item)||''}} function ensureBanquetEditor(){ const price=$('boxPrice'),dialog=document.querySelector('#editor .dialog');if(!price||!dialog)return null; let section=$('sunBanquetSectionLabel'); if(!section){section=document.createElement('label');section.id='sunBanquetSectionLabel';section.className='sun-box-weight-field';section.innerHTML='Раздел меню';price.closest('label')?.insertAdjacentElement('afterend',section)} let weight=$('sunBanquetWeightLabel'); if(!weight){weight=document.createElement('label');weight.id='sunBanquetWeightLabel';weight.className='sun-box-weight-field';weight.innerHTML='Вес на 1 гостя, г';section.insertAdjacentElement('afterend',weight)} return {section,weight}; } function syncBanquetEditor(category,item){ const fields=ensureBanquetEditor();if(!fields)return;const isBanquet=Number(category)===BANQUET_CATEGORY; fields.section.style.display=isBanquet?'flex':'none';fields.weight.style.display=isBanquet?'flex':'none'; const caption=$('boxPriceCaption');if(caption)caption.textContent=isBanquet?'Цена на 1 гостя, ₽':'Цена, ₽'; window.CateriumBanquet?.editor(category,item,boxes.filter(i=>Number(i.category)===6)); if(isBanquet){if($('banquetSection'))$('banquetSection').value=item?.catalogSection||'';if($('banquetWeightGrams'))$('banquetWeightGrams').value=parseWeightGrams(item?.weight)||''} } function installIngredientEditorV175(){window.renderIngredients=()=>{if(!edited)return;normalizeBoxItem(edited);const root=$('ingredients');if(!root)return;const head='
Вид / названиеКол-воЕд.Вес 1 шт., г
';root.innerHTML=head+edited.ingredients.map((row,i)=>{const w=ingredientUnitWeight(row);return `
`}).join('')};window.addIngredient=()=>{if(!edited)return;if(!Array.isArray(edited.ingredients))edited.ingredients=[];edited.ingredients.push(['Новая позиция',1,'шт.',0]);window.renderIngredients();};} installIngredientEditorV175(); function renderOrderLines(){ if(!$('lines'))return; if(!(draft.lines||[]).length){$('lines').innerHTML='

Выберите позиции из каталога.

';return} const rows=draft.lines.map((line,index)=>{const item=boxes.find(x=>String(x.id)===String(line.id));if(!item)return'';const qty=Math.max(1,Number(line.qty||1)),price=lineUnitPrice(line),isBox=[0,5,6].includes(Number(item.category||0));return `
${esc(String(item.name||'').replace(/^(?:Фуршетный\s+)?бокс\s*№\s*(\d+)\s*(?:[—–-]\s*)?/i,'№ $1 '))}${isBox&&(item.weight||inferBoxPieces(item))?`${item.weight?`Вес: ${esc(item.weight)}`:''}${item.weight&&inferBoxPieces(item)?' · ':''}${inferBoxPieces(item)?`Количество: ${inferBoxPieces(item)} шт.`:''}`:''}
${money(price*qty)}
`}).join(''); $('lines').innerHTML=`
НаименованиеКол.ЦенаСтоимость
${rows}
Стоимость позиций${money(baseTotal(draft))}
`; } window.sunOrderLinePriceChanged=(index,input)=>{const line=draft.lines?.[index];if(!line)return;line.price=Math.max(0,Math.round(Number(input.value||0)));const row=input.closest('.sun-order-line-row'),sum=row?.querySelector('.sun-order-line-sum b');if(sum)sum.textContent=money(line.price*Math.max(0,Number(line.qty||0)));const total=$('lines')?.querySelector('.sun-order-line-total b');if(total)total.textContent=money(baseTotal(draft));updateOrderSummary();}; function boxNumber(item){const explicit=Number(item?.boxNumber);if(Number.isInteger(explicit)&&explicit>=0)return explicit;const name=String(item?.name||'');const match=name.match(/(?:бокс\s*)?№\s*0*(\d+)/i)||name.match(/^\s*№\s*0*(\d+)/i);return match?Number(match[1]):null} function wantedBoxNumber(query){const raw=String(query||'').trim();if(!raw)return null;const match=raw.match(/^(?:бокс\s*)?(?:№\s*)?0*(\d+)\s*$/i);return match?Number(match[1]):null} function itemMatches(item,query){if(!query)return true;if(Number(activeCat)===0){const wanted=wantedBoxNumber(query);return wanted!==null&&boxNumber(item)===wanted}const q=query.trim().toLowerCase();const parts=[item.name,item.catalogSection,item.weight,...(Array.isArray(item.composition)?item.composition:[]),...(Array.isArray(item.ingredients)?item.ingredients.map(x=>x?.[0]):[])];return parts.join(' ').toLowerCase().includes(q)} const BANQUET_CATEGORY=6; function banquetCatalog(){return boxes.filter(item=>Number(item.category)===BANQUET_CATEGORY)} window.toggleBanquetItem=id=>{window.CateriumBanquet.toggle(draft,banquetCatalog(),String(id));renderCatalogV5();}; function renderBanquetView(items){return window.CateriumBanquet.render({items,catalog:banquetCatalog(),draft})} function renderCatalogV5(){ $('new')?.classList.toggle('sun-banquet-active',Number(activeCat)===BANQUET_CATEGORY); renderCategoryTabs();ensureCatalogTools();const catalogSearch=$('sunCatalogSearch');if(catalogSearch){const boxMode=[0,5].includes(Number(activeCat));catalogSearch.placeholder=boxMode?'Поиск по № бокса':'Поиск по позиции';catalogSearch.inputMode=boxMode?'numeric':'search';}const title=document.querySelector('#new .catalog h1');if(title)title.textContent=catName(activeCat); const all=boxes.filter(item=>Number(item.category||0)===Number(activeCat)),items=all.filter(item=>itemMatches(item,catalogQuery));const cat=catById(activeCat)||{name:'Каталог',prep:true}; if(Number(activeCat)===BANQUET_CATEGORY){$('tiles').innerHTML=renderBanquetView(items);window.CateriumBanquet.bind($('tiles'),{catalog:banquetCatalog(),draft,rerender:renderCatalogV5});renderOrderLines();updateOrderSummary();return;} const cards=items.map(item=>``).join(''); const addText=Number(activeCat)===0?'Добавить бокс':(Number(activeCat)===5?'Добавить премиум':'Добавить позицию'); const empty=items.length?'':`
${catalogQuery?'По вашему запросу ничего не найдено.':`В разделе «${esc(cat.name)}» пока нет позиций.`}
`; $('tiles').innerHTML=`${cards}${empty}`; renderOrderLines();updateOrderSummary(); } window.render=renderCatalogV5; window.add=id=>{const item=boxes.find(x=>String(x.id)===String(id));if(!item)return;let line=(draft.lines||[]).find(x=>String(x.id)===String(id));if(line)line.qty=Math.max(0,Number(line.qty||0))+1;else{if(!Array.isArray(draft.lines))draft.lines=[];draft.lines.push({id:item.id,qty:1,price:Math.max(0,Number(item.price||0))})}renderCatalogV5();}; window.qty=(id,n)=>{if(!Array.isArray(draft.lines))draft.lines=[];const next=Math.max(0,Number(n||0));if(next<1)draft.lines=draft.lines.filter(x=>String(x.id)!==String(id));else{const line=draft.lines.find(x=>String(x.id)===String(id));if(line)line.qty=next}renderCatalogV5();}; function ensureManagerToolbar(){ const dialog=document.querySelector('#manager .dialog');if(!dialog)return;let bar=dialog.querySelector('.sun-manager-toolbar');const add=qa(':scope > .primary',dialog)[0]||qa('.primary',dialog).find(b=>(b.textContent||'').includes('Добавить')); if(!bar&&add){bar=document.createElement('div');bar.className='sun-manager-toolbar';add.parentNode.insertBefore(bar,add);bar.appendChild(add);const input=document.createElement('input');input.id='sunManagerSearch';input.className='sun-manager-search';input.type='search';input.placeholder=Number(activeCat)===0?'Поиск по № бокса':'Поиск по позиции';input.inputMode=Number(activeCat)===0?'numeric':'search';input.oninput=()=>{managerQuery=input.value;renderManagerV5();};bar.appendChild(input);} } function renderManagerV5(){ ensureManagerToolbar();const managerSearch=$('sunManagerSearch');if(managerSearch){const boxMode=[0,5].includes(Number(activeCat));managerSearch.placeholder=boxMode?'Поиск по № бокса':'Поиск по позиции';managerSearch.inputMode=boxMode?'numeric':'search';}const title=document.querySelector('#manager .dialog-head h2'),add=document.querySelector('#manager .sun-manager-toolbar .primary')||document.querySelector('#manager .primary');if(title)title.textContent=catName(activeCat);if(add){add.textContent=Number(activeCat)===0?'Добавить бокс':(Number(activeCat)===5?'Добавить премиум':'Добавить позицию');add.onclick=()=>window.editBox(null)} const items=boxes.filter(i=>Number(i.category||0)===Number(activeCat)&&itemMatches(i,managerQuery)),cat=catById(activeCat)||{prep:true}; $('managerList').innerHTML=items.length?items.map(item=>``).join(''):'

Ничего не найдено.

'; } window.openManager=()=>{managerQuery='';ensureManagerToolbar();if($('sunManagerSearch'))$('sunManagerSearch').value='';renderManagerV5();window.modal?.('manager');}; window.editBox=id=>{ edited=id?structuredClone(boxes.find(x=>String(x.id)===String(id))):{id:'',name:Number(activeCat)===0?'Новый бокс':(Number(activeCat)===5?'Новая премиум позиция':'Новая позиция'),category:Number(activeCat),price:0,oldPrice:0,ingredients:[]};if(!edited)return; if(!Array.isArray(edited.ingredients))edited.ingredients=[];normalizeBoxItem(edited);syncEditorCategoryOptions();$('itemCategory').value=String(edited.category??activeCat);$('boxName').value=edited.name||'';$('boxPrice').value=Number(edited.price||0);if($('boxOldPrice'))$('boxOldPrice').value=Number(edited.oldPrice||0);if($('boxCompositionText'))$('boxCompositionText').value=(Array.isArray(edited.composition)?edited.composition:[]).join('\n');syncBoxWeightEditor(edited.category??activeCat,edited);syncBanquetEditor(edited.category??activeCat,edited);$('editPhoto').style.display=edited.photo?'block':'none';$('editPhoto').src=edited.photo||'';$('deleteBox').style.display=edited.id?'block':'none'; const cat=catById(edited.category)||{name:'Каталог',prep:true};$('editTitle').textContent=edited.id?`Изменить: ${cat.name}`:(Number(edited.category)===0?'Новый бокс':(Number(edited.category)===5?'Новая премиум позиция':'Новая позиция'));const zone=document.querySelector('#editor .ingredient-zone');if(zone)zone.style.display=cat.prep?'block':'none';window.renderIngredients?.();window.modal?.('editor'); }; if($('itemCategory'))$('itemCategory').onchange=function(){if(!edited)return;edited.category=Number(this.value);const c=catById(edited.category)||{name:'Каталог',prep:true};const zone=document.querySelector('#editor .ingredient-zone');if(zone)zone.style.display=c.prep?'block':'none';syncBoxWeightEditor(edited.category,edited);syncBanquetEditor(edited.category,edited);$('editTitle').textContent=edited.id?`Изменить: ${c.name}`:(Number(edited.category)===0?'Новый бокс':(Number(edited.category)===5?'Новая премиум позиция':'Новая позиция'));}; window.saveBox=()=>{if(!edited)return;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);if([0,5].includes(edited.category)){const grams=Math.max(0,Math.round(Number($('boxWeightGrams')?.value||0)));edited.weight=grams?`${grams} г`:'';edited.pieces=Math.max(0,Math.round(Number($('boxPiecesCount')?.value||0)));}if(edited.category===BANQUET_CATEGORY){edited.catalogSection=($('banquetSection')?.value||'').trim();const grams=Math.max(0,Math.round(Number($('banquetWeightGrams')?.value||0)));edited.weight=grams?`${grams} г`:'';}window.CateriumBanquet?.saveEditor(edited,boxes.filter(i=>Number(i.category)===6));edited.ingredients=Array.isArray(edited.ingredients)?edited.ingredients:[];if([0,5].includes(edited.category)&&$('boxCompositionText')){const next=$('boxCompositionText').value.split(/\r?\n/).map(x=>x.trim()).filter(Boolean);const before=JSON.stringify(Array.isArray(edited.composition)?edited.composition:[]);edited.composition=next;if(JSON.stringify(next)!==before)edited.compositionSource='manual';}normalizeBoxItem(edited,{cleanup:true});if(!edited.name)return alert('Введите название.');if(!edited.id){edited.id=(typeof sunUUID==='function'?sunUUID():`item-${Date.now()}`);boxes.push(edited)}else{const i=boxes.findIndex(x=>String(x.id)===String(edited.id));if(i>=0)boxes[i]=edited}if(!catById(edited.category)?.hidden)activeCat=edited.category;persist();closeModal('editor');closeModal('manager');renderCatalogV5();renderOrders?.();window.sunClientOfferCatalogChanged?.(edited.id);}; window.removeBox=()=>{if(!edited?.id||!confirm(`Удалить «${edited.name}» из каталога?`))return;boxes=boxes.filter(x=>String(x.id)!==String(edited.id));draft.lines=(draft.lines||[]).filter(x=>String(x.id)!==String(edited.id));persist();closeModal('editor');closeModal('manager');renderCatalogV5();renderOrders?.();}; function fileAsDataUrl(file){return new Promise((resolve,reject)=>{const reader=new FileReader();reader.onerror=()=>reject(reader.error||new Error('FileReader error'));reader.onload=()=>resolve(String(reader.result||''));reader.readAsDataURL(file)})} function compactCatalogPhotoData(dataUrl,{maxSide=900,maxChars=130000,quality=.78,force=false}={}){return new Promise(resolve=>{if(!/^data:image\//i.test(String(dataUrl||'')))return resolve(dataUrl);const image=new Image();image.onerror=()=>resolve(dataUrl);image.onload=()=>{const iw=Math.max(1,image.naturalWidth||image.width||1),ih=Math.max(1,image.naturalHeight||image.height||1);if(!force&&String(dataUrl).length<=maxChars&&Math.max(iw,ih)<=maxSide)return resolve(dataUrl);let scale=Math.min(1,maxSide/Math.max(iw,ih)),q=quality,best=dataUrl;for(let attempt=0;attempt<5;attempt++){const canvas=document.createElement('canvas');canvas.width=Math.max(1,Math.round(iw*scale));canvas.height=Math.max(1,Math.round(ih*scale));const ctx=canvas.getContext('2d');if(!ctx)break;ctx.fillStyle='#fff';ctx.fillRect(0,0,canvas.width,canvas.height);ctx.drawImage(image,0,0,canvas.width,canvas.height);try{best=canvas.toDataURL('image/jpeg',q)||best}catch(_){break}if(best.length<=maxChars)break;scale*=.78;q=Math.max(.52,q-.07)}resolve(best)};image.src=dataUrl})} window.sunCompactCatalogPhotoData=compactCatalogPhotoData; window.readPhoto=async input=>{const file=input?.files?.[0];if(!file||!edited)return;try{const original=await fileAsDataUrl(file);const optimized=await compactCatalogPhotoData(original);edited.photo=optimized;$('editPhoto').src=optimized;$('editPhoto').style.display='block';if(optimized.length+5000({name:'',inn:'',kpp:'',ogrn:'',legalAddress:'',phone:'',email:'',bank:'',bik:'',account:'',corrAccount:'',contact:''}); function legacyBuyer(){try{const s=JSON.parse(localStorage.getItem('sunReceiptSettingsV2')||'{}');return {name:s.buyerName||'',inn:s.buyerInn||'',kpp:s.buyerKpp||'',ogrn:s.buyerOgrn||'',legalAddress:s.buyerLegalAddress||'',phone:s.buyerPhone||'',email:s.buyerEmail||'',bank:s.buyerBank||'',bik:s.buyerBik||'',account:s.buyerAccount||'',corrAccount:s.buyerCorrAccount||'',contact:s.buyerContact||''}}catch(_){return buyerDefaults()}} function ensureRequisitesTab(){ const sub=document.querySelector('#new .subnav');if(!sub)return; let btn=$('requisitesTab');if(!btn){btn=document.createElement('button');btn.id='requisitesTab';btn.type='button';btn.textContent='Реквизиты';sub.appendChild(btn)} let panel=$('orderRequisites');if(!panel){panel=document.createElement('div');panel.id='orderRequisites';panel.className='sun-order-requisites';panel.innerHTML=`

Реквизиты покупателя

`;$('details').insertAdjacentElement('afterend',panel)} $('orderTab').onclick=()=>setOrderPane('order');$('detailTab').onclick=()=>setOrderPane('details');btn.onclick=()=>setOrderPane('requisites'); qa('#orderRequisites input:not([type=file])').forEach(input=>input.addEventListener('input',captureBuyerFromFields)); } function setOrderPane(tab){ensureRequisitesTab();$('orderForm').style.display=tab==='order'?'block':'none';$('details').style.display=tab==='details'?'block':'none';$('orderRequisites').style.display=tab==='requisites'?'block':'none';$('orderTab').classList.toggle('on',tab==='order');$('detailTab').classList.toggle('on',tab==='details');$('requisitesTab').classList.toggle('on',tab==='requisites');if(tab==='requisites')syncBuyerFields();} window.details=on=>setOrderPane(on?'details':'order'); function buyerObject(){return {name:$('orderBuyerName')?.value.trim()||'',contact:$('orderBuyerContact')?.value.trim()||'',inn:$('orderBuyerInn')?.value.trim()||'',kpp:$('orderBuyerKpp')?.value.trim()||'',ogrn:$('orderBuyerOgrn')?.value.trim()||'',phone:$('orderBuyerPhone')?.value.trim()||'',legalAddress:$('orderBuyerLegalAddress')?.value.trim()||'',email:$('orderBuyerEmail')?.value.trim()||'',bank:$('orderBuyerBank')?.value.trim()||'',bik:$('orderBuyerBik')?.value.trim()||'',account:$('orderBuyerAccount')?.value.trim()||'',corrAccount:$('orderBuyerCorrAccount')?.value.trim()||''}} function captureBuyerFromFields(){if(typeof draft==='undefined'||!draft)return;draft.buyerRequisites=buyerObject();} function syncBuyerFields(){ensureRequisitesTab();let b=draft?.buyerRequisites;if(!b){const legacy=legacyBuyer(),hasLegacy=Object.values(legacy).some(Boolean);b=hasLegacy?legacy:buyerDefaults();draft.buyerRequisites={...b}};const map={orderBuyerName:'name',orderBuyerContact:'contact',orderBuyerInn:'inn',orderBuyerKpp:'kpp',orderBuyerOgrn:'ogrn',orderBuyerPhone:'phone',orderBuyerLegalAddress:'legalAddress',orderBuyerEmail:'email',orderBuyerBank:'bank',orderBuyerBik:'bik',orderBuyerAccount:'account',orderBuyerCorrAccount:'corrAccount'};Object.entries(map).forEach(([id,key])=>{if($(id))$(id).value=b?.[key]||''});} // ---------------- QR default + image handling ---------------- const QR_KEY='sunDefaultOrderQrV1'; const defaultQr=()=>{try{return localStorage.getItem(QR_KEY)||''}catch(_){return''}}; function imageData(file,max=512){return new Promise((resolve,reject)=>{const r=new FileReader();r.onerror=reject;r.onload=()=>{const img=new Image();img.onerror=reject;img.onload=()=>{const scale=Math.min(1,max/Math.max(img.width||1,img.height||1)),c=document.createElement('canvas');c.width=Math.max(1,Math.round(img.width*scale));c.height=Math.max(1,Math.round(img.height*scale));const ctx=c.getContext('2d');ctx.fillStyle='#fff';ctx.fillRect(0,0,c.width,c.height);ctx.drawImage(img,0,0,c.width,c.height);resolve(c.toDataURL('image/png'))};img.src=r.result};r.readAsDataURL(file)})} function ensureDefaultQrSetting(){ const grid=document.querySelector('#enterprise-settings .enterprise-grid');if(!grid)return;let card=$('sunDefaultQrCard');if(!card){card=document.createElement('section');card.id='sunDefaultQrCard';card.className='enterprise-card wide';card.innerHTML=`

QR-код для бланка заказа

Загрузите QR-код один раз. Он будет автоматически использоваться во всех бланках заказов, пока вы не загрузите другой.

QR не загружен
`;const audit=qa(':scope > .enterprise-card',grid).find(x=>(x.querySelector('h2')?.textContent||'').trim()==='История изменений');window.SunSafe.insertBefore(grid,card,audit||null);$('sunDefaultQrUpload').onclick=()=>$('sunDefaultQrFile').click();$('sunDefaultQrFile').onchange=async()=>{const f=$('sunDefaultQrFile').files?.[0];if(!f)return;try{localStorage.setItem(QR_KEY,await imageData(f));renderDefaultQr();toast('QR-код для бланков сохранён.')}catch(_){toast('Не удалось загрузить QR-код.','warn')}};} renderDefaultQr();const audit=qa(':scope > .enterprise-card',grid).find(x=>(x.querySelector('h2')?.textContent||'').trim()==='История изменений');if(audit&&grid.lastElementChild!==audit)grid.appendChild(audit); } function renderDefaultQr(){const root=$('sunDefaultQrPreview');if(!root)return;const src=defaultQr();root.innerHTML=src?`QR`:'QR не загружен';} // ---------------- Backup to/from JSON file ---------------- function backupFileState(){const storage={};for(let i=0;iURL.revokeObjectURL(url),2000);toast('Файл резервной копии скачан.','success');}catch(_){toast('Не удалось создать файл резервной копии.','warn')}} function restoreIdbFromExport(indexedDbDump){ function openDb(name){return new Promise((resolve,reject)=>{const req=indexedDB.open(name);req.onsuccess=()=>resolve(req.result);req.onerror=()=>reject(req.error);});} function putRecord(store,record){return new Promise((resolve,reject)=>{let req;try{req=store.put(record);}catch(_){const guessKey=record&&(record.id??record.key??record.path);try{req=store.put(record,guessKey);}catch(e2){reject(e2);return;}}req.onsuccess=()=>resolve();req.onerror=()=>reject(req.error);});} return (async()=>{ for(const [dbName,stores] of Object.entries(indexedDbDump||{})){ let db;try{db=await openDb(dbName);}catch(_){continue;} for(const [storeName,records] of Object.entries(stores||{})){ if(!db.objectStoreNames.contains(storeName))continue; const tx=db.transaction(storeName,'readwrite'),store=tx.objectStore(storeName); for(const record of (records||[])){try{await putRecord(store,record);}catch(_){}} await new Promise(resolve=>{tx.oncomplete=resolve;tx.onerror=resolve;}); } db.close(); } })(); } async function restoreBackupFile(file){ let state; try{state=JSON.parse(await file.text());}catch(_){toast('Не удалось прочитать файл — это не JSON.','warn');return;} const isSettingsFormat=state&&state.format==='sun-catering-backup'&&state.storage&&typeof state.storage==='object'; const isFullExport=state&&state.format==='caterium-full-export'&&state.localStorage&&typeof state.localStorage==='object'; if(!isSettingsFormat&&!isFullExport){toast('Неверный формат файла резервной копии.','warn');return;} if(!confirm('Загрузить базу из файла? Текущие локальные данные будут заменены содержимым файла.'))return; if(isSettingsFormat){ Object.entries(state.storage).forEach(([key,value])=>{if(key.startsWith('sun')){try{localStorage.setItem(key,String(value));}catch(_){}}}); }else{ Object.entries(state.localStorage).forEach(([key,value])=>{if(key.startsWith('sun')){try{localStorage.setItem(key,String(value));}catch(_){}}}); await restoreIdbFromExport(state.indexedDB); } toast('База восстановлена из файла. Перезагрузка…','success'); setTimeout(()=>location.reload(),600); } function ensureBackupFileCard(){ const grid=document.querySelector('#enterprise-settings .enterprise-grid');if(!grid)return; let card=$('sunBackupFileCard'); if(!card){ card=document.createElement('section');card.id='sunBackupFileCard';card.className='enterprise-card wide'; card.innerHTML=`

Резервная копия базы (файл)

Скачайте базу в файл на компьютер или загрузите ранее сохранённый файл — например, для переноса на другой компьютер или работы без интернета.

`; const audit=qa(':scope > .enterprise-card',grid).find(x=>(x.querySelector('h2')?.textContent||'').trim()==='История изменений'); window.SunSafe.insertBefore(grid,card,audit||null); $('sunBackupFileDownload').onclick=downloadBackupFile; $('sunBackupFileUpload').onclick=()=>$('sunBackupFileInput').click(); $('sunBackupFileInput').onchange=async()=>{const f=$('sunBackupFileInput').files?.[0];$('sunBackupFileInput').value='';if(f)await restoreBackupFile(f);}; } const audit=qa(':scope > .enterprise-card',grid).find(x=>(x.querySelector('h2')?.textContent||'').trim()==='История изменений');if(audit&&grid.lastElementChild!==audit)grid.appendChild(audit); } // ---------------- Shared company identity for every document ---------------- function renderPdfBrandPreview(){ const root=$('sunPdfBrandPreview');if(!root)return;const src=window.CateriumBranding.documentLogo(); root.innerHTML=src?'Логотип компании':'Логотип не загружен — документы будут без логотипа'; } function ensurePdfBrandCard(){ const grid=document.querySelector('#enterprise-settings .enterprise-grid');if(!grid)return; let card=$('sunPdfBrandCard'); if(!card){ card=document.createElement('section');card.id='sunPdfBrandCard';card.className='enterprise-card wide'; card.innerHTML='

Логотип и название компании

Используются во всех документах вашей компании: предложениях, чеках, бланках заказа, заготовках, маршрутных листах и каталогах. Бренд приложения в левой панели остаётся прежним.

PNG, JPEG или WebP, до 15 МБ. Прозрачность PNG сохраняется.

'; const audit=qa(':scope > .enterprise-card',grid).find(x=>(x.querySelector('h2')?.textContent||'').trim()==='История изменений'); window.SunSafe.insertBefore(grid,card,audit||null); $('sunPdfBrandNameInput').value=window.CateriumBranding.documentName(); $('sunPdfBrandUpload').onclick=()=>$('sunPdfBrandLogoFile').click(); $('sunPdfBrandLogoFile').onchange=async()=>{const input=$('sunPdfBrandLogoFile'),file=input.files?.[0];if(!file)return;const button=$('sunPdfBrandUpload');button.disabled=true;try{await window.CateriumBranding.saveLogo(file);renderPdfBrandPreview();toast('Логотип сохранён для всех документов компании.','success')}catch(error){toast(error.message||'Не удалось загрузить логотип.','warn')}finally{input.value='';button.disabled=false}}; $('sunPdfBrandNameInput').addEventListener('change',()=>{try{window.CateriumBranding.saveName($('sunPdfBrandNameInput').value);toast('Название компании сохранено.','success')}catch(_){toast('Не удалось сохранить название.','warn')}}); $('sunPdfBrandReset').onclick=()=>{try{window.CateriumBranding.clearLogo();renderPdfBrandPreview();toast('Логотип убран из документов.')}catch(_){toast('Не удалось убрать логотип.','warn')}}; } renderPdfBrandPreview(); } window.addEventListener('caterium:document-brand-changed',()=>{renderPdfBrandPreview();const input=$('sunPdfBrandNameInput');if(input&&document.activeElement!==input)input.value=window.CateriumBranding.documentName()}); // ---------------- Discounts + promo tracking ---------------- const PROMO_KEY='sunPromoCodesV1'; const localDateIso=()=>{const d=new Date(),p=n=>String(n).padStart(2,'0');return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}`}; function promoCodes(){try{const list=JSON.parse(localStorage.getItem(PROMO_KEY)||'[]');return Array.isArray(list)?list:[]}catch(_){return[]}} function promoActive(p,date=localDateIso()){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 value=Math.max(0,Number(p?.discountValue||0));return p?.discountType==='amount'?money(value):`${value.toLocaleString('ru-RU',{maximumFractionDigits:2})}%`} function promoById(id){return promoCodes().find(p=>String(p.id)===String(id))||null} function promoByCode(code){const key=String(code||'').trim().toLowerCase();return promoCodes().find(p=>String(p.code||'').trim().toLowerCase()===key)||null} function ensureDiscountUI(){ const form=document.querySelector('#new .money-form');if(!form)return; if(!$('deliveryCost')){ const label=document.createElement('label');label.id='deliveryCostLabel';label.innerHTML='Доставка, ₽'; const prepay=$('prepayment')?.closest('label');if(prepay)prepay.insertAdjacentElement('beforebegin',label);else form.appendChild(label); $('deliveryCost')?.addEventListener('input',()=>{captureDelivery();updateOrderSummary()}); } let panel=$('sunDiscountPanel'); const desired=``; if(!panel){panel=document.createElement('div');panel.id='sunDiscountPanel';panel.className='sun-discount-panel';panel.innerHTML=desired;form.insertAdjacentElement('afterend',panel)} else if(!$('promoCodeSelect'))panel.innerHTML=desired; if(panel.dataset.promoBound!=='1'){ panel.dataset.promoBound='1'; $('manualDiscountValue')?.addEventListener('input',()=>{captureDiscounts();updateOrderSummary()}); $('manualDiscountType')?.addEventListener('change',()=>{captureDiscounts();updateOrderSummary()}); $('promoCodeSelect')?.addEventListener('change',()=>{captureDiscounts();updateOrderSummary()}); } const summary=document.querySelector('#new .order-summary');if(summary&&!$('summaryNetTotal'))summary.innerHTML=`

Стоимость позиций0 ₽

Скидка− 0 ₽

Промокод − 0 ₽

Доставка0 ₽

Итого0 ₽

Предоплата0 ₽

К оплате0 ₽

`; } function renderPromoSelect(){ ensureDiscountUI();const select=$('promoCodeSelect');if(!select)return; const active=promoCodes().filter(p=>promoActive(p)).sort((a,b)=>String(a.code||'').localeCompare(String(b.code||''),'ru')); const currentId=String(draft?.promoId||''),currentCode=String(draft?.promoCode||'').trim(); let selected=''; const opts=['']; active.forEach(p=>{opts.push(``);if(currentId&&String(p.id)===currentId)selected=String(p.id)}); if(currentCode&&!selected){ const saved=promoById(currentId)||promoByCode(currentCode),label=saved?(promoActive(saved)?'активен':'срок истёк / отключён'):'сохранён в заказе'; opts.push(``);selected='__snapshot__'; } select.innerHTML=opts.join('');select.value=selected; updatePromoMeta(); } function updatePromoMeta(){const meta=$('promoCodeMeta'),select=$('promoCodeSelect');if(!meta||!select)return;const id=select.value;if(!id){meta.textContent='Выберите активный промокод из раздела «Рассылки».';return}if(id==='__snapshot__'){meta.textContent=`Условия сохранены в заказе: ${draft?.promoCode||''} · ${draft?.promoDiscountType==='amount'?money(draft?.promoDiscountValue||0):`${Number(draft?.promoDiscountValue||0).toLocaleString('ru-RU',{maximumFractionDigits:2})}%`}.`;return}const p=promoById(id);meta.textContent=p?`${p.code} · скидка ${promoDiscountText(p)} · действует ${p.startDate||'без даты'} — ${p.endDate||'без даты'}`:'Промокод недоступен.';} let hydratingOrder=false; function captureDiscounts(){ if(hydratingOrder)return; if(!draft)return;draft.manualDiscountValue=Math.max(0,Number($('manualDiscountValue')?.value||0));draft.manualDiscountType=$('manualDiscountType')?.value==='amount'?'amount':'percent'; const select=$('promoCodeSelect');if(!select)return;const id=String(select.value||''); if(!id){draft.promoId='';draft.promoCode='';draft.promoDiscountValue=0;draft.promoDiscountType='percent';draft.promoStartDate='';draft.promoEndDate='';return} if(id==='__snapshot__'){updatePromoMeta();return} const p=promoById(id);if(!p||!promoActive(p)){renderPromoSelect();return} draft.promoId=String(p.id||'');draft.promoCode=String(p.code||'').trim();draft.promoDiscountValue=Math.max(0,Number(p.discountValue||0));draft.promoDiscountType=p.discountType==='amount'?'amount':'percent';draft.promoStartDate=String(p.startDate||'');draft.promoEndDate=String(p.endDate||'');updatePromoMeta(); } function captureDelivery(){if(!draft||hydratingOrder)return;draft.deliveryCost=Math.max(0,Number($('deliveryCost')?.value||0));} function syncDeliveryField(){ensureDiscountUI();if($('deliveryCost'))$('deliveryCost').value=Math.max(0,Number(draft?.deliveryCost||0));} function discountAmount(value,type,base){const n=Math.max(0,Number(value||0));return Math.min(base,type==='amount'?n:base*Math.min(100,n)/100)} function pricing(order=draft){const base=baseTotal(order),manual=discountAmount(order?.manualDiscountValue,order?.manualDiscountType,base),afterManual=Math.max(0,base-manual),promo=discountAmount(order?.promoDiscountValue,order?.promoDiscountType,afterManual),itemsTotal=Math.max(0,afterManual-promo),delivery=Math.max(0,Number(order?.deliveryCost||0)),total=itemsTotal+delivery,paid=Math.max(0,Number(order===draft?($('prepayment')?.value||order?.prepayment||0):(order?.prepayment||0))),balance=Math.max(0,total-paid);return {base,manual,promo,itemsTotal,delivery,total,paid,balance,discount:manual+promo}} window.sunOrderPricing=pricing; window.calcOrderTotal=()=>pricing(draft).total; window.orderTotalValue=order=>pricing(order).total; window.updateOrderSummary=()=>{ensureDiscountUI();captureDiscounts();const p=pricing(draft);if($('orderTotal'))$('orderTotal').value=Math.round(p.total);if($('balance'))$('balance').value=Math.round(p.balance);if($('summaryTotal'))$('summaryTotal').textContent=money(p.base);if($('summaryManualDiscount'))$('summaryManualDiscount').textContent=`− ${money(p.manual)}`;if($('summaryPromoDiscount'))$('summaryPromoDiscount').textContent=`− ${money(p.promo)}`;if($('summaryPromoCode'))$('summaryPromoCode').textContent=draft.promoCode?`(${draft.promoCode})`:'';if($('summaryDelivery'))$('summaryDelivery').textContent=money(p.delivery);if($('summaryNetTotal'))$('summaryNetTotal').textContent=money(p.total);if($('summaryPrepayment'))$('summaryPrepayment').textContent=money(p.paid);if($('summaryBalance'))$('summaryBalance').textContent=money(p.balance);updatePromoMeta();}; function syncDiscountFields(){ensureDiscountUI();$('manualDiscountValue').value=Number(draft?.manualDiscountValue||0);$('manualDiscountType').value=draft?.manualDiscountType==='amount'?'amount':'percent';syncDeliveryField();renderPromoSelect();updateOrderSummary();} window.sunRefreshOrderPromos=()=>{renderPromoSelect();updateOrderSummary();}; // ---------------- Seller settings only ---------------- function simplifySellerSettings(){ const card=$('sunReceiptSettingsCard');if(!card||card.dataset.sunSellerOnly==='1')return; const sections=qa('.sun-receipt-settings-section',card);sections.forEach(s=>{if((s.querySelector('h3')?.textContent||'').includes('Покупатель'))s.classList.add('sun-buyer-settings-hidden')}); const buyerCheck=$('receiptIncludeBuyer');buyerCheck?.closest('label')?.classList.add('sun-buyer-settings-hidden'); const h=card.querySelector('h2');if(h&&(h.textContent||'').trim()!=='Реквизиты компании')h.textContent='Реквизиты компании'; const hint=card.querySelector(':scope > .hint'),hintText='Реквизиты продавца для товарного чека. Реквизиты покупателя вводятся отдельно в каждом заказе во вкладке «Реквизиты».';if(hint&&hint.textContent!==hintText)hint.textContent=hintText; card.dataset.sunSellerOnly='1'; } function readSeller(){const d={includeSeller:true,sellerName:(window.CateriumBranding.documentName()),sellerLegalName:'',sellerInn:'',sellerKpp:'',sellerOgrn:'',sellerLegalAddress:'',sellerPhone:'',sellerEmail:'',sellerBank:'',sellerBik:'',sellerAccount:'',sellerCorrAccount:''};try{return {...d,...JSON.parse(localStorage.getItem('sunReceiptSettingsV2')||'{}')}}catch(_){return d}} function party(title,rows){const list=rows.filter(([,v])=>String(v||'').trim());if(!list.length)return'';return `

${esc(title)}

${list.map(([k,v])=>`
${esc(k)}${esc(v)}
`).join('')}
`} // ---------------- Order blank + compact receipt ---------------- function currentRows(){return (draft.lines||[]).map((line,index)=>{const item=boxes.find(x=>String(x.id)===String(line.id)),price=lineUnitPrice(line),qty=Math.max(0,Number(line.qty||0)),categoryId=Number(item?.category??line.category??0);return{index:index+1,name:item?.name||line.name||`Позиция ${line.id||''}`,category:catName(categoryId),categoryId,weight:[0,5,6].includes(categoryId)?String(item?.weight||''):'',pieces:[0,5].includes(categoryId)?inferBoxPieces(item):0,price,qty,sum:price*qty}}).filter(r=>r.qty>0)} 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 docActions=()=>`
`; function showOrderBlank(){ captureBuyerFromFields();captureDiscounts();const rows=currentRows(),p=pricing(draft),qr=defaultQr(),b=draft.buyerRequisites||buyerDefaults(),dialog=document.querySelector('#preview .dialog');if(!dialog)return;const number=draft.id||'черновик',qrNote=String(draft.blankQrText||'').trim(); dialog.innerHTML=`
${window.CateriumBranding.nameHTML()}
${window.CateriumBranding.nameHTML()}${window.CateriumBranding.cityHTML()}

Заказ № ${esc(number)}

${esc($('event')?.value||'Новое мероприятие')}

${draft.status?`${esc(draft.status)}`:''}
${qr?`
QR-код
${esc(qrNote)}
`:''}
Клиент${esc($('contact')?.value||'—')}
Телефон${esc($('phone')?.value||'—')}
Дата и время выдачи${esc(docDate($('date')?.value))} · ${esc($('time')?.value||'—')}
Адрес доставки${esc($('address')?.value||'—')}
${b.name?`
Компания покупателя${esc(b.name)}
`:''}
${rows.length?rows.map(r=>``).join(''):''}
ПозицияЦенаКол.Сумма
${r.index}${esc(r.name)}${esc(r.category)}${(r.weight||r.pieces)?`${r.weight?`${r.categoryId===6?'Вес порции':'Вес бокса'}: ${esc(r.weight)}`:''}${r.weight&&r.pieces?' · ':''}${r.pieces?`Количество: ${esc(r.pieces)} шт.`:''}`:''}${money(r.price)}${r.qty}${money(r.sum)}
В заказе пока нет позиций.

Стоимость позиций${money(p.base)}

${p.manual?`

Скидка− ${money(p.manual)}

`:''}${p.promo?`

Промокод ${esc(draft.promoCode||'')}− ${money(p.promo)}

`:''}

Доставка${money(p.delivery)}

Итого${money(p.total)}

Предоплата${money(p.paid)}

Остаток к оплате${money(p.balance)}

${$('note')?.value?`
Комментарий к заказу${esc($('note').value)}
`:''}${docActions()}
`;window.modal?.('preview');const noteEl=$('sunBlankQrText');if(noteEl){const save=()=>{const text=String(noteEl.innerText||noteEl.textContent||'').replace(/\u00a0/g,' ').trim();draft.blankQrText=text;if(draft.id){const saved=(orders||[]).find(o=>String(o.id)===String(draft.id));if(saved){saved.blankQrText=text;try{persist()}catch(_){}}}};noteEl.addEventListener('input',save);noteEl.addEventListener('blur',save);} } function showReceipt(){ captureBuyerFromFields();captureDiscounts();const rows=currentRows(),p=pricing(draft),s=readSeller(),b=draft.buyerRequisites||buyerDefaults(),dialog=document.querySelector('#preview .dialog');if(!dialog)return;const seller=s.includeSeller!==false?party('Продавец',[['Наименование',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 buyer=party('Покупатель',[['Компания / ИП',b.name],['ИНН',b.inn],['КПП',b.kpp],['ОГРН / ОГРНИП',b.ogrn],['Адрес',b.legalAddress],['Контакт',b.contact],['Телефон',b.phone],['E-mail',b.email],['Банк',b.bank],['БИК',b.bik],['Расч. счёт',b.account],['Корр. счёт',b.corrAccount]]); dialog.innerHTML=`
${window.CateriumBranding.nameHTML()}
${esc(s.sellerName||(window.CateriumBranding.documentName()))}${esc(s.sellerLegalName||'')}

Товарный чек № ${esc(draft.id||'—')}

${esc(docDate($('date')?.value||draft.date))} · ${esc($('time')?.value||draft.time||'')}

${seller}${buyer}${rows.length?rows.map(r=>``).join(''):''}
НаименованиеЦенаКол.Сумма
${r.index}${esc(r.name)}${(r.weight||r.pieces)?`${r.weight?`${r.categoryId===6?'Вес порции':'Вес бокса'}: ${esc(r.weight)}`:''}${r.weight&&r.pieces?' · ':''}${r.pieces?`Количество: ${esc(r.pieces)} шт.`:''}`:''}${money(r.price)}${r.qty}${money(r.sum)}
В заказе нет позиций.

Стоимость позиций${money(p.base)}

${p.manual?`

Скидка− ${money(p.manual)}

`:''}${p.promo?`

Промокод ${esc(draft.promoCode||'')}− ${money(p.promo)}

`:''}

Доставка${money(p.delivery)}

Итого${money(p.total)}

Оплачено${money(p.paid)}

К оплате${money(p.balance)}

${$('note')?.value?`
Примечание${esc($('note').value)}
`:''}${docActions()}
`;window.modal?.('preview'); } window.sunShowConfiguredReceipt=showReceipt;window.sunShowOrderBlankV5=showOrderBlank; function patchDocButtons(){qa('#new .actions button').forEach(b=>{const text=(b.textContent||'').trim();if(text==='Товарный чек')b.onclick=showReceipt;if(text==='Бланк заказа')b.onclick=showOrderBlank;});} // ---------------- Promo statistics ---------------- function renderPromoStats(){ const view=$('stats');if(!view)return;let root=$('sunPromoStats');if(!root){root=document.createElement('section');root.id='sunPromoStats';root.className='sun-promo-stats';view.appendChild(root)} const groups=new Map();(orders||[]).filter(o=>o.status!=='Отменён'&&String(o.promoCode||'').trim()).forEach(o=>{const code=String(o.promoCode).trim(),p=pricing(o),key=code.toLowerCase(),client=(String(o.phone||'').replace(/\D/g,'')||String(o.contact||'').trim().toLowerCase()||`order:${o.id}`);if(!groups.has(key))groups.set(key,{code,orders:0,clients:new Set(),sales:0,discount:0});const g=groups.get(key);g.orders++;g.clients.add(client);g.sales+=p.total;g.discount+=p.promo;});const list=[...groups.values()].sort((a,b)=>b.sales-a.sales); root.innerHTML=`

Промокоды

Отслеживание заказов, клиентов, продаж и предоставленной скидки по промокодам.

${list.length?`
${list.map(g=>``).join('')}
ПромокодЗаказовКлиентовПродажиСкидка
${esc(g.code)}${g.orders}${g.clients.size}${money(g.sales)}${money(g.discount)}
`:'

Заказов с промокодами пока нет.

'}`; } // ---------------- Lifecycle wrappers ---------------- const previousSave=window.saveOrder; if(typeof previousSave==='function')window.saveOrder=function(...args){captureBuyerFromFields();captureDiscounts();captureDelivery();const result=previousSave.apply(this,args);renderPromoStats();return result}; const previousOpen=window.openOrder; if(typeof previousOpen==='function')window.openOrder=function(...args){hydratingOrder=true;let r;try{r=previousOpen.apply(this,args)}catch(error){hydratingOrder=false;throw error}setTimeout(()=>{try{syncBuyerFields();syncDiscountFields();syncDeliveryField();setOrderPane('order');patchDocButtons();renderCatalogV5()}finally{hydratingOrder=false;updateOrderSummary()}},0);return r}; const previousReset=window.resetDraft; if(typeof previousReset==='function')window.resetDraft=function(...args){const r=previousReset.apply(this,args);draft.buyerRequisites=buyerDefaults();draft.manualDiscountValue=0;draft.manualDiscountType='percent';draft.promoId='';draft.promoCode='';draft.promoDiscountValue=0;draft.promoDiscountType='percent';draft.promoStartDate='';draft.promoEndDate='';draft.deliveryCost=0;delete draft.qrImage;setTimeout(()=>{syncBuyerFields();syncDiscountFields();syncDeliveryField();setOrderPane('order');renderCatalogV5();},0);return r}; const previousStats=window.renderStats; window.renderStats=function(...args){const r=typeof previousStats==='function'?previousStats.apply(this,args):undefined;setTimeout(renderPromoStats,0);return r}; function onSettings(){setTimeout(()=>{simplifySellerSettings();ensureDefaultQrSetting();ensureBackupFileCard();ensurePdfBrandCard();},80);setTimeout(()=>{simplifySellerSettings();ensureDefaultQrSetting();ensureBackupFileCard();ensurePdfBrandCard();},250)} document.addEventListener('click',e=>{const nav=e.target.closest('header nav button');if(nav&&String(nav.dataset.navLabel||nav.textContent||'').trim()==='Настройки')onSettings();},true); (()=>{let queued=false;const run=()=>{if(queued)return;queued=true;setTimeout(()=>{queued=false;if($('sunReceiptSettingsCard'))simplifySellerSettings();patchDocButtons();},50);};[$('new'),$('enterprise-settings')].filter(Boolean).forEach(root=>new MutationObserver(run).observe(root,{childList:true,subtree:true}));})(); // Initial install. Populate the new piece-count field without overwriting manually edited prices/weights. try{let changed=false;(Array.isArray(boxes)?boxes:[]).forEach(item=>{if(![0,5].includes(Number(item?.category||0)))return;const before=JSON.stringify([item.pieces,item.ingredients,item.composition]);normalizeBoxItem(item,{cleanup:true});if(before!==JSON.stringify([item.pieces,item.ingredients,item.composition]))changed=true;});if(changed)persist();}catch(err){console.warn('v17.5 catalog normalization skipped',err)} ensureCategoryModal();renderCategoryTabs();syncEditorCategoryOptions();ensureCatalogTools();ensureBoxWeightEditor();ensureBoxPiecesEditor();ensureRequisitesTab();ensureDiscountUI();syncBuyerFields();syncDiscountFields();syncDeliveryField();patchDocButtons();renderCatalogV5();renderPromoStats();if($('enterprise-settings')?.classList.contains('on'))onSettings(); })(); ; /* ===== MODULE: client-offer-settings.js ===== */ /* Sun Catering: editable client-offer content and metric settings */ (()=>{ 'use strict'; if(window.__sunClientOfferSettingsV1)return; window.__sunClientOfferSettingsV1=true; const KEY='sunClientOfferSettingsV1'; const DEFAULTS={ version:3, metricMode:'weight', showAmountPerGuest:true, showBoxCount:true, showPriceBreakdown:true, showDiscountBadge:true, showGallery:true, showControl:true, showExtras:true, showFinalPhoto:true, showClientName:true, showDate:true, showGuests:true, showFooterNote:true, texts:{ proposalLabel:'Индивидуальное предложение', menuTitle:'Меню', summaryTitle:'Итого', totalPriceTitle:'Итоговая стоимость', heroNote:'Фирменные боксы с закусками — готово к подаче на стол.', salesTitle:'Почему с нами удобно', salesNote:'Меню составлено специально под ваше событие. Закуски приезжают в фирменных боксах, их удобно поставить на стол и подать без сложной сервировки. Мы готовим заказ в день доставки, используем свежие ингредиенты и контролируем детали до передачи заказа.', finalGalleryTitle:'Как это выглядит на вашем столе', weightMetricLabel:'Вес еды на гостя', piecesMetricLabel:'Канапе на гостя', amountPerGuestLabel:'Сумма на гостя', boxCountLabel:'Количество боксов', pricingTitle:'Расчёт стоимости', priceItemsLabel:'Стоимость позиций', discountLabel:'Скидка', promoLabel:'Промокод', menuAfterDiscountLabel:'Меню после скидок', deliveryLabel:'Доставка', totalLabel:'Итого', controlTitle:'Всё под контролем нашей команды', extrasTitle:'Дополнительно к заказу рекомендуем', footerNote:'Доставка считается отдельно от меню и не участвует в скидках.' }, controlLines:[ 'Фирменная упаковка', 'Уточняем детали доставки', 'Стоимость заказа не изменится', 'Готовим закуски в день мероприятия', 'Контролируем заказ до момента передачи', 'Привозим заказ вовремя и передаём вам лично', 'Всё организованно! Вы просто встречаете гостей!' ], extraServices:['Напитки','Десерты','Фрукты','Одноразовая посуда'] }; const clone=v=>JSON.parse(JSON.stringify(v)); const esc=window.SunSafe.escapeHTML; const bool=(v,d)=>typeof v==='boolean'?v:d; const lines=(value,fallback)=>{ const arr=Array.isArray(value)?value:String(value??'').split(/\r?\n/); const clean=arr.map(x=>String(x||'').trim()).filter(Boolean).slice(0,20); return clean.length?clean:[...fallback]; }; function normalize(raw={}){ const d=clone(DEFAULTS),t=raw&&typeof raw==='object'?raw:{}; d.metricMode=t.metricMode==='pieces'?'pieces':'weight'; ['showAmountPerGuest','showBoxCount','showPriceBreakdown','showDiscountBadge','showGallery','showControl','showExtras','showFinalPhoto','showClientName','showDate','showGuests','showFooterNote'].forEach(k=>d[k]=bool(t[k],d[k])); if(t.texts&&typeof t.texts==='object')Object.keys(d.texts).forEach(k=>{if(t.texts[k]!=null)d.texts[k]=String(t.texts[k]).trim()||d.texts[k]}); // v3 separates the short first-screen note from the longer selling copy. // Preserve any custom v2 hero text by moving it into the new selling block. if(Number(t.version||0)<3&&t.texts&&typeof t.texts==='object'&&!String(t.texts.salesNote||'').trim()){ const legacyHero=String(t.texts.heroNote||'').trim(); if(legacyHero)d.texts.salesNote=legacyHero; d.texts.heroNote=DEFAULTS.texts.heroNote; } d.controlLines=lines(t.controlLines,d.controlLines); d.extraServices=lines(t.extraServices,d.extraServices); // Since v17.5.17 table photos are automatic whitespace fillers, not an optional section. d.showFinalPhoto=true; return d; } function read(){try{return normalize(JSON.parse(localStorage.getItem(KEY)||'{}'))}catch(_){return clone(DEFAULTS)}} let state=read(); function save(next){state=normalize(next);localStorage.setItem(KEY,JSON.stringify(state));window.dispatchEvent(new CustomEvent('sunclientoffersettingschange',{detail:clone(state)}));return clone(state)} function reset(){return save(DEFAULTS)} const style=document.createElement('style'); style.id='sun-client-offer-settings-style'; style.textContent=` .sun-client-offer-settings-card{grid-column:1/-1!important} .sun-client-offer-settings-head{display:flex;align-items:flex-start;gap:14px;flex-wrap:wrap} .sun-client-offer-settings-head>div:first-child{flex:1;min-width:250px} .sun-client-offer-settings-card details{border:1px solid #e4e7ea;border-radius:14px;background:#fff;margin-top:10px;overflow:hidden} .sun-client-offer-settings-card summary{cursor:pointer;padding:13px 15px;font-weight:800;color:#2a3138;list-style:none;display:flex;align-items:center;justify-content:space-between;gap:10px} .sun-client-offer-settings-card summary::-webkit-details-marker{display:none} .sun-client-offer-settings-card summary:after{content:'+';font-size:20px;font-weight:500;color:#7a828a}.sun-client-offer-settings-card details[open] summary:after{content:'−'} .sun-client-offer-settings-body{border-top:1px solid #eceff1;padding:14px 15px 16px} .sun-client-offer-settings-grid{display:grid;grid-template-columns:repeat(2,minmax(220px,1fr));gap:12px} .sun-client-offer-settings-grid label,.sun-client-offer-settings-body>label{display:grid;gap:6px;font-size:12px;color:#59636d;font-weight:700} .sun-client-offer-settings-grid input,.sun-client-offer-settings-grid select,.sun-client-offer-settings-body textarea{width:100%;border:1px solid #d9dee3;border-radius:10px;background:#fff;color:#20262c;padding:10px 11px;font:inherit} .sun-client-offer-settings-body textarea{min-height:135px;resize:vertical;line-height:1.5} .sun-client-offer-switches{display:grid;grid-template-columns:repeat(3,minmax(170px,1fr));gap:9px} .sun-client-offer-switch{display:flex!important;grid-template-columns:none!important;align-items:center;gap:9px!important;border:1px solid #e2e6e9;border-radius:11px;padding:10px 11px;color:#303941!important;background:#fafbfb} .sun-client-offer-switch input{width:17px!important;height:17px;margin:0;accent-color:var(--sun-brand-sun,#FFC107)} .sun-client-offer-settings-actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:14px;padding-top:14px;border-top:1px solid #eceff1} .sun-client-offer-settings-actions .hint{margin:0 0 0 auto} @media(max-width:760px){.sun-client-offer-settings-grid,.sun-client-offer-switches{grid-template-columns:1fr}.sun-client-offer-settings-actions .hint{width:100%;margin:0}} `; document.head.appendChild(style); function html(){ const s=state,t=s.texts; return `

Настройки предложения клиенту

Тексты и состав блоков применяются ко всем шаблонам и к PDF. Внутренний номер заказа клиенту не показывается.

Показатели в блоке «Итого»

Вес и количество боксов считаются только из раздела «Боксы». Напитки, посуда и дополнения в эти показатели не попадают. В составе предложения количество канапе/закусок каждого вида показывается итогом с учётом количества заказанных боксов.

Основные тексты
Продающий блок после первого экрана

Текст не обрезается: блок увеличивается и при необходимости переносится на следующую страницу PDF.

Тексты расчёта стоимости
Блок «Всё под контролем»
Дополнительно к заказу
Что показывать клиенту
Фото на столе: авто, только в свободное место

Изменения применятся к следующему открытию предложения; открытое предложение обновится автоматически.

`; } function collect(card){ const next=clone(state); card.querySelectorAll('[data-offer-setting]').forEach(el=>{const k=el.dataset.offerSetting;if(el.type==='checkbox')next[k]=el.checked;else next[k]=el.value}); next.texts={...next.texts}; card.querySelectorAll('[data-offer-text]').forEach(el=>{next.texts[el.dataset.offerText]=String(el.value||'').trim()}); card.querySelectorAll('[data-offer-list]').forEach(el=>{next[el.dataset.offerList]=String(el.value||'').split(/\r?\n/).map(x=>x.trim()).filter(Boolean)}); return normalize(next); } function bind(card){ card.querySelector('[data-offer-settings-save]')?.addEventListener('click',()=>{save(collect(card));card.innerHTML=html();bind(card);try{window.SunEnterprise?.toast?.('Настройки предложения сохранены.')}catch(_){}}); card.querySelector('[data-offer-settings-reset]')?.addEventListener('click',()=>{reset();card.innerHTML=html();bind(card);try{window.SunEnterprise?.toast?.('Стандартные настройки предложения восстановлены.')}catch(_){}}); } function ensureCard(){ const view=document.getElementById('enterprise-settings');if(!view)return; const grid=view.querySelector('.enterprise-grid');if(!grid)return; let card=document.getElementById('sunClientOfferSettingsCard'); if(card&&grid.contains(card))return; card=document.createElement('section');card.id='sunClientOfferSettingsCard';card.className='enterprise-card wide sun-client-offer-settings-card';card.innerHTML=html(); const templates=document.getElementById('sunOfferTemplateSettingsCard'); if(templates&&templates.parentElement===grid)templates.insertAdjacentElement('afterend',card);else{ const brand=document.getElementById('sunBrandThemeSettingsCard'); if(brand&&brand.parentElement===grid)brand.insertAdjacentElement('afterend',card);else grid.appendChild(card); } bind(card); } function boot(){ ensureCard(); const settings=document.getElementById('enterprise-settings');if(settings)new MutationObserver(()=>queueMicrotask(ensureCard)).observe(settings,{childList:true,subtree:true}); document.addEventListener('click',e=>{const b=e.target.closest('header nav button');if(!b)return;const label=(b.dataset.navLabel||b.textContent||'').trim();if(label==='Настройки')setTimeout(ensureCard,35)},true); window.addEventListener('storage',e=>{if(e.key===KEY){state=read();setTimeout(()=>{const card=document.getElementById('sunClientOfferSettingsCard');if(card){card.innerHTML=html();bind(card)}},20);window.dispatchEvent(new CustomEvent('sunclientoffersettingschange',{detail:clone(state)}));}}); window.addEventListener('sun:cloud-state-applied',()=>{state=read();setTimeout(()=>{const card=document.getElementById('sunClientOfferSettingsCard');if(card){card.innerHTML=html();bind(card)}},30)}); } if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot(); window.SunClientOfferSettings={key:KEY,defaults:()=>clone(DEFAULTS),get:()=>clone(state),set:save,reset}; })(); ; /* ===== MODULE: client-offer.js ===== */ (function(){ 'use strict'; if(window.__sunClientOfferV2)return; window.__sunClientOfferV2=true; const $=id=>document.getElementById(id); const qa=(sel,root=document)=>[...root.querySelectorAll(sel)]; const esc=window.SunSafe.escapeHTML; const money=v=>`${Math.round(Number(v||0)).toLocaleString('ru-RU')} ₽`; const num=v=>Math.max(0,Number(v||0)); const toast=(m,t='success')=>{try{return window.SunEnterprise?.toast?.(m,t)}catch(_){} if(t==='error'||t==='warn')console.warn(m);}; // Document logos are supplied by the selected company. const style=document.createElement('style'); style.id='sun-client-offer-style'; style.textContent=` #sunClientOfferModal{z-index:100050} #sunClientOfferModal .dialog{width:min(1180px,calc(100vw - 28px));max-height:94vh;padding:0;overflow:hidden;border-radius:16px;background:#fffaf2} .sun-offer-modal-head{display:flex;align-items:center;gap:10px;padding:13px 15px;background:#fffdf9;border-bottom:1px solid #e8d9bf;flex-wrap:wrap} .sun-offer-modal-head h2{margin:0;color:#28533a;font-size:19px}.sun-offer-modal-head .hint{margin:0;color:#73808a;font-size:12px} .sun-offer-modal-head .spacer{flex:1}.sun-offer-modal-actions{display:flex;gap:7px;flex-wrap:wrap}.sun-offer-modal-actions button{min-height:36px} .sun-offer-editor{display:none;padding:12px 15px;background:#fff;border-bottom:1px solid #e8d9bf}.sun-offer-editor.on{display:block}.sun-offer-editor-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:9px}.sun-offer-editor label{display:grid;gap:4px;font-size:10px;font-weight:700;color:#607068}.sun-offer-editor label.wide{grid-column:span 2}.sun-offer-editor input,.sun-offer-editor textarea{width:100%;box-sizing:border-box;border:1px solid #d9d2c7;border-radius:9px;padding:8px 9px;background:#fff;font:12px/1.35 Arial;color:#1d2b24}.sun-offer-editor textarea{min-height:62px;resize:vertical}.sun-offer-editor-note{margin:8px 0 0;color:#778178;font-size:10px}.sun-offer-editor-gallery{display:grid;grid-template-columns:minmax(0,1fr) 190px;gap:12px;align-items:center;border:1px solid #e7dfd2;border-radius:10px;padding:9px;background:#fffdf9}.sun-offer-editor-gallery b{display:block;font-size:11px;color:#425248}.sun-offer-editor-gallery small{display:block;margin-top:4px;font-size:10px;line-height:1.4;color:#7b827d}.sun-offer-editor-gallery-images{display:grid;grid-template-columns:1fr 1fr;gap:5px}.sun-offer-editor-gallery-images img{width:100%;height:70px;object-fit:cover;border-radius:7px;border:1px solid #e5ded3} .sun-offer-frame-wrap{height:min(78vh,900px);background:#eee9e0;padding:12px;overflow:auto} #sunClientOfferPreview{display:grid;gap:14px;justify-items:center;min-height:100%}.sun-offer-page-canvas{display:block;width:min(100%,794px);height:auto;background:#fff;box-shadow:0 5px 24px #4a38272b}.sun-offer-rendering{align-self:center;justify-self:center;margin:70px 20px;padding:14px 18px;border-radius:12px;background:#fff;color:#526159;font-size:12px;box-shadow:0 4px 18px #00000012} .sun-offer-stale{display:none;padding:7px 12px;background:#fff6df;color:#8b6213;border-radius:8px;font-size:12px}.sun-offer-stale.on{display:block} #new .actions #sunClientOfferButton{background:#fff8e9;border-color:#c99b47;color:#28533a} @media(max-width:900px){.sun-offer-editor-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} @media(max-width:700px){.sun-offer-editor-gallery{grid-template-columns:1fr}.sun-offer-editor-gallery-images img{height:95px}#sunClientOfferModal{padding:0}#sunClientOfferModal .dialog{width:100vw;height:100vh;max-height:none;border-radius:0}.sun-offer-frame-wrap{height:calc(100vh - 112px);padding:5px}.sun-offer-modal-head{padding:9px}.sun-offer-modal-head h2{font-size:16px}.sun-offer-modal-head .hint{display:none}.sun-offer-modal-actions{width:100%;display:grid;grid-template-columns:1.15fr 1fr 1fr 1fr}.sun-offer-modal-actions button{padding:8px 4px;font-size:10px}.sun-offer-editor{padding:9px}.sun-offer-editor-grid{grid-template-columns:1fr}.sun-offer-editor label.wide{grid-column:auto}} `; document.head.appendChild(style); const getBoxes=()=>{try{if(typeof boxes!=='undefined'&&Array.isArray(boxes))return boxes}catch(_){}try{const v=JSON.parse(localStorage.getItem('sunBoxes')||'[]');return Array.isArray(v)?v:[]}catch(_){return[]}}; const getLegacyBoxes=()=>Array.isArray(window.SUN_LEGACY_CATALOG_V175)?window.SUN_LEGACY_CATALOG_V175:[]; const normalizedCatalogName=value=>String(value||'').toLowerCase().replace(/ё/g,'е').replace(/[^a-zа-я0-9]+/gi,' ').trim().replace(/\s+/g,' '); function resolveCatalogLine(line,all=getBoxes()){ const id=String(line?.id??''); let item=(all||[]).find(x=>String(x.id)===id); if(item)return item; const legacy=getLegacyBoxes(); item=legacy.find(x=>String(x.id)===id); if(item)return item; const name=normalizedCatalogName(line?.name||line?.catalogName||''); if(name){ item=(all||[]).find(x=>normalizedCatalogName(x?.name)===name)||legacy.find(x=>normalizedCatalogName(x?.name)===name); if(item)return item; } return null; } const getOrders=()=>{try{if(typeof orders!=='undefined'&&Array.isArray(orders))return orders}catch(_){}try{const v=JSON.parse(localStorage.getItem('sunOrders')||'[]');return Array.isArray(v)?v:[]}catch(_){return[]}}; const getDraft=()=>{try{return typeof draft!=='undefined'?draft:null}catch(_){return null}}; const catName=id=>{try{return window.sunCatalogCategoryName?.(id)||({0:'Боксы',1:'Посуда и упаковка',2:'Дополнительно',3:'Напитки',4:'Доставка',5:'Премиум'}[Number(id)]||'Каталог')}catch(_){return 'Каталог'}}; function linePrice(line,item){const p=Number(line?.price);return Number.isFinite(p)&&p>=0?p:num(item?.price)} function discountAmount(value,type,base){const n=num(value);return Math.min(base,type==='amount'?n:base*Math.min(100,n)/100)} function priceCalc(order){ const all=getBoxes(); const base=(order?.lines||[]).reduce((sum,line)=>{const item=resolveCatalogLine(line,all);return sum+linePrice(line,item)*num(line.qty)},0); const manual=discountAmount(order?.manualDiscountValue,order?.manualDiscountType,base); const afterManual=Math.max(0,base-manual); const promo=discountAmount(order?.promoDiscountValue,order?.promoDiscountType,afterManual); const itemsTotal=Math.max(0,afterManual-promo); const delivery=num(order?.deliveryCost); return {base,manual,promo,itemsTotal,delivery,total:itemsTotal+delivery,discount:manual+promo}; } function parseMeasure(text){ const s=String(text||'').toLowerCase().replace(/,/g,'.'); const m=s.match(/(\d+(?:\.\d+)?)\s*(кг|kg|мл|ml|л|l|г|g)(?:\s|$|[^a-zа-яё])/iu)||s.match(/(\d+(?:\.\d+)?)\s*(кг|kg|мл|ml|л|l|г|g)$/iu); if(!m)return null;const value=Number(m[1]),u=m[2];if(!Number.isFinite(value))return null; if(u==='кг'||u==='kg')return {kind:'food',value:value*1000,unit:'г'}; if(u==='г'||u==='g')return {kind:'food',value,unit:'г'}; if(u==='л'||u==='l')return {kind:'drink',value:value*1000,unit:'мл'}; if(u==='мл'||u==='ml')return {kind:'drink',value,unit:'мл'}; return null; } function ingredientWeight(row){if(!Array.isArray(row))return 0;const direct=Math.max(0,num(row[3]));if(direct)return direct;const m=String(row[0]||'').match(/[—–-]\s*(\d+(?:[.,]\d+)?)\s*г(?:\s*\/\s*шт\.?)?\s*$/i);return m?Math.max(0,num(String(m[1]).replace(',','.'))):0} function cleanIngredientName(name){return String(name||'').replace(/\s*[—–-]\s*\d+(?:[.,]\d+)?\s*г(?:\s*\/\s*шт\.?)?\s*$/i,'').trim()} function ingredientLine(row){if(!Array.isArray(row)||!String(row[0]||'').trim())return'';const name=cleanIngredientName(row[0]),qty=Math.max(0,num(row[1])),unit=String(row[2]||'').trim(),w=ingredientWeight(row);if(/^поз\.?$/i.test(unit)&&qty<=1)return `${name}${w?` — ${w.toLocaleString('ru-RU')} г/шт.`:''}`;const shown=Number.isInteger(qty)?String(qty):qty.toLocaleString('ru-RU',{maximumFractionDigits:2});return `${shown} ${unit} ${name}${w?` — ${w.toLocaleString('ru-RU')} г/шт.`:''}`.replace(/\s+/g,' ').trim()} function composition(item){ // Client-facing composition is independent from kitchen TTK ingredients. if(Array.isArray(item?.composition)&&item.composition.length)return item.composition.map(x=>String(x||'').trim()).filter(Boolean); if(Array.isArray(item?.ingredients)&&item.ingredients.some(x=>Array.isArray(x)&&x[0]))return item.ingredients.map(ingredientLine).filter(Boolean); return []; } function orderSignature(order){ const clean={id:String(order?.id??''),event:order?.event||'',date:order?.date||'',time:order?.time||'',contact:order?.contact||'',guestsCount:num(order?.guestsCount),deliveryCost:num(order?.deliveryCost),manualDiscountValue:num(order?.manualDiscountValue),manualDiscountType:order?.manualDiscountType||'percent',promoId:order?.promoId||'',promoCode:order?.promoCode||'',promoDiscountValue:num(order?.promoDiscountValue),promoDiscountType:order?.promoDiscountType||'percent',lines:(order?.lines||[]).map(l=>({id:String(l.id),qty:num(l.qty),price:Number.isFinite(Number(l.price))?Number(l.price):null}))}; return JSON.stringify(clean); } function currentSavedOrder(id){return getOrders().find(o=>String(o.id)===String(id))||null} function currentOfferSourceOrder(id,preferDraft=false){ const saved=currentSavedOrder(id),d=getDraft(); if(preferDraft&&d&&String(d.id)===String(id))return d; return saved; } function snapshotIsStale(order,snapshot){ if(!snapshot||Number(snapshot.version||0)<7||snapshot.sourceSignature!==orderSignature(order))return true; const all=getBoxes(); return (snapshot.items||[]).some(item=>{ const missingName=!String(item?.name||'').trim()||String(item?.name||'').trim()==='Позиция'; const missingPhoto=!String(item?.photo||'').trim(); return (missingName||missingPhoto)&&Boolean(resolveCatalogLine(item,all)); }); } function sectionTitle(item){const s=String(item?.catalogSection||'').trim();return s||String(item?.categoryName||'Меню')} function canapePieces(item){ if(item?.demo&&Number(item.pieces)===0)return 0; // v17.5.1: any food box may contain countable ready-to-serve pieces, not only sections named "canape". if(![0,5].includes(Number(item?.category??item?.categoryId??0)))return 0; const explicit=Math.max(0,num(item?.pieces)); if(explicit>0)return explicit; let total=0; if(Array.isArray(item?.ingredients)){ for(const row of item.ingredients){ if(!Array.isArray(row))continue; const value=num(row[1]),unit=String(row[2]||'').toLowerCase().replace(/\s+/g,''); if(/^(шт\.?|пор\.?|кус\.?|шарик(?:а|ов|и)?|креманк(?:а|и|е)?)$/i.test(unit))total+=value; } } if(total>0)return total; for(const text of Array.isArray(item?.composition)?item.composition:[]){ const re=/(\d+(?:[.,]\d+)?)\s*(?:шт\.?|пор\.?|кус\.?)/ig;let m;while((m=re.exec(String(text||''))))total+=num(String(m[1]).replace(',','.')); } return total; } function multiplyLeadingPieces(text,qty){ const raw=String(text||'').trim(),q=Math.max(0,num(qty));if(!raw||q<=0)return raw; return raw.replace(/^(\d+(?:[.,]\d+)?)\s*(шт\.?|пор\.?|кус\.?)\s+/i,(m,n,u)=>{ const total=Number(String(n).replace(',','.'))*q; const shown=Number.isInteger(total)?String(total):total.toLocaleString('ru-RU',{maximumFractionDigits:2}); return `${shown} ${u} `; }); } function totalComposition(item,qty,categoryId){ const list=composition(item);return [0,5].includes(Number(categoryId))?list.map(x=>multiplyLeadingPieces(x,qty)):list; } function offerComposition(item){ const list=Array.isArray(item?.compositionTotal)&&item.compositionTotal.length?item.compositionTotal:(Array.isArray(item?.composition)?item.composition:[]); return list.map(x=>String(x||'').trim()).filter(Boolean); } function roundUp(value,step=1){const n=Math.max(0,Number(value||0)),s=Math.max(1,Number(step||1));return Math.ceil((n-1e-9)/s)*s} function guestRangeLabel(raw){ const value=String(raw||'').trim().replace(/(\d)\s*[-–—]\s*(\d)/g,'$1–$2'); if(!value)return'';return /(?:гост|чел)/i.test(value)?value:`${value} гостей`; } function boxQuantityLabel(qty){const n=Math.max(0,Math.round(num(qty)));return n?`${n} ${plural(n,'бокс','бокса','боксов')}`:''} function totalMeasureLabel(item){ const raw=String(item?.weight||'').trim(),qty=Math.max(0,num(item?.qty));if(!raw||!qty)return''; const measure=parseMeasure(raw);if(!measure)return qty===1?`Вес бокса: ${raw}`:`Вес бокса: ${raw} × ${qty}`; const total=measure.value*qty,shown=(Number.isInteger(total)?total:Math.round(total*100)/100).toLocaleString('ru-RU',{maximumFractionDigits:2}); return `Общий ${measure.kind==='drink'?'объём':'вес'}: ${shown} ${measure.unit}`; } function weightLabel(item){ const raw=String(item?.weight||'').trim(),isBox=[0,5].includes(Number(item?.categoryId??item?.category??0)),pieces=isBox?Math.max(0,num(item?.pieces)):0,qty=Math.max(0,num(item?.qty)); if(isBox){ const parts=[]; const totalWeight=totalMeasureLabel(item);if(totalWeight)parts.push(totalWeight);else if(raw)parts.push(`Общий вес: ${raw}`); if(pieces&&qty)parts.push(`Всего: ${(pieces*qty).toLocaleString('ru-RU',{maximumFractionDigits:2})} шт.`); return parts.join(' · ') } return raw?`Вес / объём: ${raw}`:''; } async function createSnapshot(order){ const all=getBoxes(),p=priceCalc(order),guests=num(order?.guestsCount)||num($('eventGuests')?.value),selected=new Set((order?.lines||[]).map(l=>String(l.id))); let foodGrams=0,drinkMl=0,foodPieces=0; const items=(order?.lines||[]).map(line=>{ const item=resolveCatalogLine(line,all)||{};const qty=num(line.qty),price=linePrice(line,item),measure=parseMeasure(item.weight),rawItemCategory=item.category,rawLineCategory=line.category,itemCategory=rawItemCategory!==undefined&&rawItemCategory!==null&&rawItemCategory!==''?Number(rawItemCategory):NaN,lineCategory=rawLineCategory!==undefined&&rawLineCategory!==null&&rawLineCategory!==''?Number(rawLineCategory):NaN,categoryId=Number.isFinite(itemCategory)?itemCategory:(Number.isFinite(lineCategory)?lineCategory:-1),pieces=canapePieces(item),comp=composition(item),compositionTotal=[0,5].includes(Number(categoryId))?comp.map(x=>multiplyLeadingPieces(x,qty)):comp; if(measure){if(measure.kind==='drink'||categoryId===3)drinkMl+=measure.value*qty;else if([0,5,6].includes(categoryId))foodGrams+=measure.value*qty;} if([0,5].includes(categoryId)&&pieces>0)foodPieces+=pieces*qty; return {id:String(item.id??line.id??''),name:String(item.name||line.name||'Позиция'),categoryId,categoryName:catName(categoryId),catalogSection:String(item.catalogSection||''),qty,unitPrice:price,sum:price*qty,weight:String(item.weight||''),boxGuests:String(item.guests||''),photo:String(item.photo||''),composition:comp,compositionTotal,pieces}; }).filter(x=>x.qty>0); const extras=all.filter(item=>[1,2,3].includes(Number(item.category))&&!selected.has(String(item.id))&&!item.hidden).slice(0,6).map(item=>({name:String(item.name||''),categoryName:catName(item.category)})).filter(x=>x.name); const template=order?.clientOfferTemplateId||order?.clientOfferSnapshot?.offerTemplateId; return {version:8,createdAt:new Date().toISOString(),orderId:order?.id||'',offerTemplateId:OFFER_TEMPLATE_IDS.has(template)?template:offerTemplateId(),sourceSignature:orderSignature(order),event:String(order?.event||''),date:String(order?.date||''),time:String(order?.time||''),client:String(order?.contact||''),guests,foodGrams,foodPieces,drinkMl,pricing:p,promoCode:String(order?.promoCode||''),items,extras,offerOverrides:structuredClone(order?.clientOfferSnapshot?.offerOverrides||{})}; } function persistSnapshot(orderId,snapshot){ let list=getOrders(),i=list.findIndex(o=>String(o.id)===String(orderId));if(i<0)return false; list[i].clientOfferSnapshot=snapshot;list[i].clientOfferUpdatedAt=snapshot.createdAt; try{if(typeof orders!=='undefined'&&Array.isArray(orders)){const j=orders.findIndex(o=>String(o.id)===String(orderId));if(j>=0){orders[j].clientOfferSnapshot=snapshot;orders[j].clientOfferUpdatedAt=snapshot.createdAt;}}}catch(_){} const d=getDraft();if(d&&String(d.id)===String(orderId)){d.clientOfferSnapshot=snapshot;d.clientOfferUpdatedAt=snapshot.createdAt;} try{if(typeof persist==='function')persist();else localStorage.setItem('sunOrders',JSON.stringify(list));return true}catch(err){console.error(err);toast('Не удалось сохранить предложение: возможно, переполнено локальное хранилище.','error');return false} } function ensureModal(){ if($('sunClientOfferModal'))return; const modal=document.createElement('div');modal.id='sunClientOfferModal';modal.className='modal';modal.innerHTML=`

Предложение клиенту

Заказ изменён — предложение будет автоматически обновлено из текущих данных.

Изменения относятся только к этому предложению. Просмотр ниже — те же страницы, которые попадут в скачанный PDF.

`;document.body.appendChild(modal); $('sunOfferClose').onclick=()=>{offerRenderSeq++;offerAssetController?.abort();clearTimeout(currentOffer.editTimer);window.closeModal?.('sunClientOfferModal')}; $('sunOfferRefresh').onclick=refreshCurrentOffer;$('sunOfferPdf').onclick=downloadCurrentOfferPdf;$('sunOfferEdit').onclick=toggleOfferEditor;bindOfferEditor(); } function dateText(raw){if(!raw)return'Дата уточняется';const d=new Date(`${raw}T00:00:00`);return Number.isNaN(d.getTime())?raw:d.toLocaleDateString('ru-RU',{day:'numeric',month:'long'})} function perGuest(value,guests){return guests>0?value/guests:0} function metricValue(value,unit,guests){if(!guests||!value)return'—';const per=value/guests,rounded=unit==='г'?roundUp(per,5):roundUp(per,1);return `${rounded.toLocaleString('ru-RU')} ${unit}`} function itemPerGuest(item,guests){const measure=parseMeasure(item?.weight);if(!guests||!measure)return'—';const per=measure.value*num(item.qty)/guests,rounded=measure.kind==='drink'?roundUp(per,1):roundUp(per,5);return `${rounded.toLocaleString('ru-RU')} ${measure.kind==='drink'?'мл':'г'}`} const EXTRA_SERVICES=[ ['Напитки',''], ['Десерты',''], ['Фрукты',''], ['Одноразовая посуда',''] ]; const DELIVERY_CONTROL=[ 'Фирменная упаковка', 'Уточняем детали доставки', 'Стоимость заказа не изменится', 'Готовим закуски в день мероприятия', 'Контролируем заказ до момента передачи', 'Привозим заказ вовремя и передаём вам лично', 'Всё организованно! Вы просто встречаете гостей!' ]; const FINAL_GALLERY_PATHS=[...(Array.isArray(window.SUN_OFFER_GALLERY_DATA)?window.SUN_OFFER_GALLERY_DATA.filter(Boolean).slice(0,2):[])];if(FINAL_GALLERY_PATHS.length<2)FINAL_GALLERY_PATHS.push(...Array.from({length:2-FINAL_GALLERY_PATHS.length},(_,i)=>`offer-gallery/${String(FINAL_GALLERY_PATHS.length+i+1).padStart(3,'0')}.jpg`)); function plural(n,one,few,many){const x=Math.abs(Number(n||0))%100,y=x%10;return x>10&&x<20?many:y>1&&y<5?few:y===1?one:many} const SAFE_PIXEL='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Zwv8AAAAASUVORK5CYII='; const isSafeRasterDataUrl=src=>/^data:image\/(?!svg\+xml)[a-z0-9.+-]+;/i.test(String(src||'')); async function asDataUrlOne(src,{signal}={}){ if(!src||signal?.aborted)return ''; const raw=window.SunSafe.imageAssetSrc(src); if(isSafeRasterDataUrl(raw))return raw; if(/^data:/i.test(raw))return ''; const controller=new AbortController(),cancel=()=>controller.abort(); signal?.addEventListener('abort',cancel,{once:true}); const timer=setTimeout(cancel,6000); try{ const url=new URL(raw,document.baseURI||location.href); if(!['http:','https:','blob:'].includes(url.protocol))return ''; const sameOrigin=url.protocol==='blob:'||url.origin===location.origin; const read=async()=>{ const res=await fetch(url,{mode:'cors',credentials:sameOrigin?'same-origin':'omit',cache:'force-cache',signal:controller.signal}); if(!res.ok)throw new Error(`HTTP ${res.status}`); const blob=await res.blob(); if(!/^image\/(?!svg\+xml)[a-z0-9.+-]+$/i.test(String(blob.type||'')))return ''; return await new Promise((resolve,reject)=>{const r=new FileReader();r.onload=()=>resolve(String(r.result||''));r.onerror=()=>reject(r.error||new Error('FileReader'));r.readAsDataURL(blob)}); }; // The explicit race also bounds a stalled response body/FileReader, not // just receipt of HTTP headers. Aborting stops the underlying fetch. const data=await Promise.race([read(),new Promise(resolve=>controller.signal.addEventListener('abort',()=>resolve(''),{once:true}))]); return isSafeRasterDataUrl(data)?data:''; }catch(_){return ''} finally{clearTimeout(timer);signal?.removeEventListener('abort',cancel);controller.abort()} } async function asDataUrl(src,options={}){ const list=window.SunCatalogPhoto?.candidates?.(src)||[src]; for(const candidate of list){if(options.signal?.aborted)break;const data=await asDataUrlOne(candidate,options);if(data)return data;} return ''; } async function preparedSnapshot(snapshot){ const copy=structuredClone(snapshot),liveCatalog=getBoxes(),legacyCatalog=getLegacyBoxes(); // Resolve current company/catalog presentation before the shared preview/PDF media pass. const companyBrand=window.CateriumBranding.identity(); copy.logo=companyBrand.logo;copy.brandName=companyBrand.name; copy.brandContacts=companyBrand.contacts;copy.brandCity=companyBrand.city; copy.finalPhoto=''; copy.finalGallery=FINAL_GALLERY_PATHS.slice(0,2); const dataOverrides=copy.offerOverrides?.data&&typeof copy.offerOverrides.data==='object'?copy.offerOverrides.data:{}; if(Object.prototype.hasOwnProperty.call(dataOverrides,'event'))copy.event=String(dataOverrides.event??''); if(Object.prototype.hasOwnProperty.call(dataOverrides,'client'))copy.client=String(dataOverrides.client??''); if(Object.prototype.hasOwnProperty.call(dataOverrides,'date'))copy.date=String(dataOverrides.date??''); if(Object.prototype.hasOwnProperty.call(dataOverrides,'guests'))copy.guests=Math.max(0,num(dataOverrides.guests)); for(const item of copy.items){ // Quantity and price come from the order snapshot; catalog presentation data is always current. const live=liveCatalog.find(x=>String(x.id)===String(item.id))||legacyCatalog.find(x=>String(x.id)===String(item.id)); if(live){ item.name=String(live.name||item.name||'Позиция'); if(Number.isFinite(Number(live.category)))item.categoryId=Number(live.category); item.categoryName=catName(item.categoryId); item.photo=String(live.photo||'');item.weight=String(live.weight||'');item.boxGuests=String(live.guests||'');item.catalogSection=String(live.catalogSection||item.catalogSection||''); const comp=composition(live);item.composition=comp;item.compositionTotal=[0,5].includes(Number(item.categoryId))?comp.map(x=>multiplyLeadingPieces(x,item.qty)):comp;item.pieces=canapePieces(live); }else item.compositionTotal=[0,5].includes(Number(item.categoryId))?(item.composition||[]).map(x=>multiplyLeadingPieces(x,item.qty)):(item.composition||[]); item.photoData=item.photo||copy.logo; } copy.foodGrams=0;copy.drinkMl=0;copy.foodPieces=0; for(const item of copy.items){const measure=parseMeasure(item.weight);if(measure){if(measure.kind==='drink'||Number(item.categoryId)===3)copy.drinkMl+=measure.value*num(item.qty);else if([0,5,6].includes(Number(item.categoryId)))copy.foodGrams+=measure.value*num(item.qty);}if([0,5].includes(Number(item.categoryId))&&num(item.pieces)>0)copy.foodPieces+=num(item.pieces)*num(item.qty)} try{const cfg=offerSettingsForSnapshot(copy);copy.controlLines=Array.isArray(cfg.controlLines)&&cfg.controlLines.length?cfg.controlLines:DELIVERY_CONTROL;copy.extraServices=Array.isArray(cfg.extraServices)&&cfg.extraServices.length?cfg.extraServices:EXTRA_SERVICES.map(x=>x[0]);copy.controlTitle=cfg.texts?.controlTitle||'';copy.extrasTitle=cfg.texts?.extrasTitle||'';}catch(_){} return copy; } async function preparedPdfSnapshot(prepared,{signal,onProgress}={}){ const copy=structuredClone(prepared),cache=new Map(),missing=new Set(),controller=new AbortController(); const cancel=()=>controller.abort();signal?.addEventListener('abort',cancel,{once:true});if(signal?.aborted)cancel(); // One budget for the whole batch, including all fallback candidates. const timer=setTimeout(cancel,10000);let done=0; if(copy.orderId&&window.SunOfferWorkspaceV1769?.galleryFor)copy.finalGallery=window.SunOfferWorkspaceV1769.galleryFor(copy.orderId); const cfg=copy.pdfSettings||offerSettingsForSnapshot(copy);if(cfg.showFinalPhoto===false)copy.finalGallery=[]; const resolve=src=>{ const key=String(src||'');if(!key)return Promise.resolve(''); if(!cache.has(key))cache.set(key,asDataUrl(key,{signal:controller.signal}).then(data=>{if(!data)missing.add(key);onProgress?.(++done,cache.size);return data})); return cache.get(key); }; try{ // A slow decorative gallery must not delay every product photo. const [logo,photos,gallery]=await Promise.all([resolve(copy.logo),Promise.all((copy.items||[]).map(item=>resolve(item.photoData||item.photo||''))),Promise.all((copy.finalGallery||[]).map(resolve))]); copy.logo=logo;copy.finalGallery=gallery.filter(Boolean); (copy.items||[]).forEach((item,i)=>{item.photoData=photos[i]||logo;item.photo=photos[i]||''}); copy.missingPhotoCount=missing.size; return copy; }finally{clearTimeout(timer);signal?.removeEventListener('abort',cancel);controller.abort()} } const OFFER_TEMPLATE_KEY='sunOfferTemplateV1'; const OFFER_TEMPLATE_IDS=new Set(['cream-elegance','neon-menu','emerald-circles','midnight-checklist','gourmet-hero','diamond-gold','premium-dark','premium-emerald','light','midnight-glass','editorial-grid','warm-sun','bento-cards','event-story','black-gold','personal-letter','event-ticket','solar-experience','midnight-compact','emerald-gold','neon-emerald']); function offerTemplateId(){ try{ const api=window.SunOfferTemplate?.get?.(); if(api?.id&&OFFER_TEMPLATE_IDS.has(api.id))return api.id; const raw=JSON.parse(localStorage.getItem(OFFER_TEMPLATE_KEY)||'{}')||{}; if(raw.id&&OFFER_TEMPLATE_IDS.has(raw.id))return raw.id; }catch(_){} return 'light'; } const CLIENT_OFFER_SETTINGS_KEY='sunClientOfferSettingsV1'; const CLIENT_OFFER_DEFAULTS={metricMode:'weight',showAmountPerGuest:true,showBoxCount:true,showPriceBreakdown:true,showDiscountBadge:true,showGallery:true,showControl:true,showExtras:true,showFinalPhoto:true,showClientName:true,showDate:true,showGuests:true,showFooterNote:true,texts:{proposalLabel:'Индивидуальное предложение',menuTitle:'Меню',summaryTitle:'Итого',totalPriceTitle:'Итоговая стоимость',heroNote:'Фирменные боксы с закусками — готово к подаче на стол.',salesTitle:'Почему с нами удобно',salesNote:'Меню составлено специально под ваше событие. Закуски приезжают в фирменных боксах, их удобно поставить на стол и подать без сложной сервировки. Мы готовим заказ в день доставки, используем свежие ингредиенты и контролируем детали до передачи заказа.',finalGalleryTitle:'Как это выглядит на вашем столе',weightMetricLabel:'Вес еды на гостя',piecesMetricLabel:'Канапе на гостя',amountPerGuestLabel:'Сумма на гостя',boxCountLabel:'Количество боксов',pricingTitle:'Расчёт стоимости',priceItemsLabel:'Стоимость позиций',discountLabel:'Скидка',promoLabel:'Промокод',menuAfterDiscountLabel:'Меню после скидок',deliveryLabel:'Доставка',totalLabel:'Итого',controlTitle:'Всё под контролем нашей команды',extrasTitle:'Дополнительно к заказу рекомендуем',footerNote:'Доставка считается отдельно от меню и не участвует в скидках.'},controlLines:[...DELIVERY_CONTROL],extraServices:EXTRA_SERVICES.map(x=>x[0])}; function offerSettings(){ try{const api=window.SunClientOfferSettings?.get?.();if(api)return api}catch(_){} try{ const raw=JSON.parse(localStorage.getItem(CLIENT_OFFER_SETTINGS_KEY)||'{}')||{},d=structuredClone(CLIENT_OFFER_DEFAULTS); Object.keys(d).forEach(k=>{if(k!=='texts'&&k!=='controlLines'&&k!=='extraServices'&&raw[k]!=null)d[k]=raw[k]}); if(raw.texts&&typeof raw.texts==='object')d.texts={...d.texts,...raw.texts}; if(Array.isArray(raw.controlLines)&&raw.controlLines.length)d.controlLines=raw.controlLines; if(Array.isArray(raw.extraServices)&&raw.extraServices.length)d.extraServices=raw.extraServices; return d; }catch(_){return structuredClone(CLIENT_OFFER_DEFAULTS)} } function offerSettingsForSnapshot(s){ const cfg=offerSettings(),textOverrides=s?.offerOverrides?.texts; if(textOverrides&&typeof textOverrides==='object')cfg.texts={...cfg.texts,...textOverrides}; if(Array.isArray(s?.offerOverrides?.controlLines))cfg.controlLines=s.offerOverrides.controlLines.map(x=>String(x||'').trim()).filter(Boolean).slice(0,20); // Table photos are automatic: they are shown only when the final page has free space. cfg.showFinalPhoto=true; return cfg; } function offerBoxCount(items){return (items||[]).filter(item=>[0,5].includes(Number(item.categoryId??0))).reduce((n,item)=>n+num(item.qty),0)} function offerPrimaryMetric(s,cfg,g){ if(cfg.metricMode==='pieces')return {value:g&&num(s.foodPieces)?`${roundUp(num(s.foodPieces)/g,1).toLocaleString('ru-RU')} шт.`:'—',label:cfg.texts?.piecesMetricLabel||'Канапе на гостя'}; return {value:metricValue(s.foodGrams,'г',g),label:cfg.texts?.weightMetricLabel||'Вес еды на гостя'}; } function offerIconSvg(title=''){ const t=String(title).toLowerCase(); if(/рыб|мор|лосос|кревет/.test(t))return ''; if(/мяс|ростбиф|бургер|ветчин|салям/.test(t))return ''; if(/вег|овощ|салат/.test(t))return ''; if(/десерт|слад|макарон|торт/.test(t))return ''; return ''; } function offerGroups(items){ const map=new Map(); (items||[]).forEach(item=>{const k=sectionTitle(item)||'Меню';if(!map.has(k))map.set(k,[]);map.get(k).push(item)}); return [...map.entries()].map(([name,list])=>({name,list})); } function editorialGroups(items){ return offerGroups(items).map(group=>{ const weights=group.list.map(item=>parseMeasure(item.weight)).filter(m=>m&&m.kind!=='drink').map(m=>Math.round(m.value)).filter(v=>v>0); const minW=weights.length?Math.min(...weights):0,maxW=weights.length?Math.max(...weights):0; const qty=group.list.reduce((n,item)=>n+Math.max(0,num(item.qty)),0); const weightText=minW?(minW===maxW?`${minW} г`:`${minW}-${maxW} г`):''; const positions=`${group.list.length} ${plural(group.list.length,'блюдо','блюда','блюд')}`; return {...group,qty,weightText,positions,meta:[weightText,positions].filter(Boolean).join(' · ')}; }); } function offerHtml(s){ const templateId=OFFER_TEMPLATE_IDS.has(s.offerTemplateId)?s.offerTemplateId:offerTemplateId(); const themeClass=`theme-${templateId}`; const cfg=offerSettingsForSnapshot(s),p=s.pricing||{},g=num(s.guests),menuPer=perGuest(p.itemsTotal,g); const items=s.items||[],boxCount=offerBoxCount(items),positionCount=items.length,groups=offerGroups(items),heroPhoto=items.find(item=>item.photoData)?.photoData||s.finalGallery?.[0]||s.logo||''; const primaryMetric=offerPrimaryMetric(s,cfg,g),eventName=String(s.event||'Персональное предложение').trim(),clientName=cfg.showClientName?String(s.client||'').trim():''; const visibleDate=cfg.showDate?dateText(s.date):'',visibleGuests=cfg.showGuests&&g?`${g} ${plural(g,'гость','гостя','гостей')}`:''; const discountPct=p.base>0?Math.round((p.discount/p.base)*100):0; const compositionText=(item,sep=' · ')=>offerComposition(item).join(sep); const itemMeta=item=>[weightLabel(item),compositionText(item)].filter(Boolean).join(' · '); const photo=(item,i,cls='')=>`
${i+1}${esc(item.name)}
${esc(item.name)}
`; const row=item=>`
${offerIconSvg(sectionTitle(item))}
${esc(item.name)}${itemMeta(item)?`${esc(itemMeta(item))}`:''}
× ${Math.round(num(item.qty))}
`; const groupTable=group=>`

${esc(group.name)}

Итого по позицииКоличество
${group.list.map(item=>{const comp=compositionText(item);return `
${esc(item.name)}${comp?`${esc(comp)}`:''}
${esc(weightLabel(item)||item.weight||'—')}× ${Math.round(num(item.qty))}
`}).join('')}
`; const gallery=items.map((item,i)=>photo(item,i)).join(''); const controlLines=Array.isArray(cfg.controlLines)&&cfg.controlLines.length?cfg.controlLines:DELIVERY_CONTROL; const extrasList=Array.isArray(cfg.extraServices)&&cfg.extraServices.length?cfg.extraServices:EXTRA_SERVICES.map(x=>x[0]); const deliveryControl=controlLines.map(item=>`
  • ${esc(item)}
  • `).join(''); const deliveryControlShort=controlLines.slice(0,5).map(item=>`
  • ${esc(item)}
  • `).join(''); const extraServices=extrasList.map(name=>`
    +${esc(name)}
    `).join(''); const manual=p.manual?`
    ${esc(cfg.texts.discountLabel||'Скидка')}- ${money(p.manual)}
    `:''; const promo=p.promo?`
    ${esc(cfg.texts.promoLabel||'Промокод')}${s.promoCode?` ${esc(s.promoCode)}`:''}- ${money(p.promo)}
    `:''; const breakdown=cfg.showPriceBreakdown?`
    ${esc(cfg.texts.priceItemsLabel||'Стоимость позиций')}${money(p.base)}
    ${manual}${promo}
    ${esc(cfg.texts.menuAfterDiscountLabel||'Меню после скидок')}${money(p.itemsTotal)}
    ${esc(cfg.texts.deliveryLabel||'Доставка')}${money(p.delivery)}
    ${esc(cfg.texts.totalLabel||'Итого')}${money(p.total)}
    `:''; const metricCards=[`
    ${esc(primaryMetric.value)}${esc(primaryMetric.label)}
    `,cfg.showAmountPerGuest?`
    ${g?money(menuPer):'—'}${esc(cfg.texts.amountPerGuestLabel||'Сумма на гостя')}
    `:'',cfg.showBoxCount?`
    ${Math.round(boxCount)}${esc(cfg.texts.boxCountLabel||'Количество боксов')}
    `:''].filter(Boolean); const metrics=`
    ${metricCards.join('')}
    `; const infoParts=[]; if(cfg.showControl)infoParts.push(`

    ${esc(cfg.texts.controlTitle)}

      ${deliveryControl}
    `); if(cfg.showExtras)infoParts.push(`

    ${esc(cfg.texts.extrasTitle)}

    ${extraServices}
    `); const info=infoParts.length?`
    ${infoParts.join('')}
    `:''; const actions=''; const brand=`
    ${s.logo?``:`${esc(s.brandName||'Моя компания')}`}${visibleDate?`
    ${esc(visibleDate)}
    `:''}
    `; const heroStats=[visibleDate?`${esc(visibleDate)}`:'',visibleGuests?`${esc(visibleGuests)}`:''].filter(Boolean).join(''); const standardHero=`
    ${esc(cfg.texts.proposalLabel)}

    ${esc(eventName)}

    ${clientName?`
    ${esc(clientName)}
    `:''}${heroStats?`
    ${heroStats}
    `:''}

    ${esc(cfg.texts.heroNote)}

    ${esc(eventName)}
    `; const sellingBlock=(String(cfg.texts.salesTitle||'').trim()||String(cfg.texts.salesNote||'').trim())?`
    ${String(cfg.texts.salesTitle||'').trim()?`

    ${esc(cfg.texts.salesTitle)}

    `:''}${String(cfg.texts.salesNote||'').trim()?`

    ${esc(cfg.texts.salesNote)}

    `:''}
    `:''; const identityLine=[clientName,visibleDate,visibleGuests].filter(Boolean).join(' · '); const blackControl=cfg.showControl?`

    ${esc(cfg.texts.controlTitle)}

      ${deliveryControlShort}
    `:''; const blackExtras=cfg.showExtras?`

    ${esc(cfg.texts.extrasTitle)}

    ${extraServices}
    `:''; const egSideParts=[cfg.showControl?`

    ${esc(cfg.texts.controlTitle)}

      ${deliveryControlShort}
    `:'',cfg.showExtras?`

    ${esc(cfg.texts.extrasTitle)}

    ${extraServices}
    `:''].filter(Boolean); const egSide=egSideParts.length?``:''; const egFacts=[visibleDate,clientName,visibleGuests].filter(Boolean); const footerNote=cfg.showFooterNote?`
    ${esc(cfg.texts.footerNote)}
    `:''; let body=''; if(templateId==='editorial-grid'){ const editorial=editorialGroups(items); const editorialRows=editorial.map((group,gi)=>`
    ${String(gi+1).padStart(2,'0')}
    ${esc(group.name)}${esc(group.meta)}
    × ${Math.round(group.qty)}
    `).join(''); const editorialGallery=items.slice(0,8).map((item,i)=>photo(item,i,'ed-photo')).join(''); body=`${brand}
    ${esc(cfg.texts.proposalLabel)}

    ${esc(eventName)}

    ${clientName?`
    ${esc(clientName)}
    `:''}
    ${visibleDate?`◷ ${esc(visibleDate)}`:''}${visibleGuests?`○ ${esc(visibleGuests)}`:''}

    ${esc(cfg.texts.heroNote)}

    ${esc(eventName)}
    ${sellingBlock}
    ${esc(cfg.texts.menuTitle)}
    ${editorialRows}
    ${cfg.showGallery?`
    ${editorialGallery}
    `:''}

    ${esc(cfg.texts.summaryTitle)}

    ${metrics}
    ${esc(cfg.texts.totalPriceTitle)}${money(p.total)}${g?`на гостя: ${money(menuPer)}`:'Финальная сумма'}${cfg.showDiscountBadge&&discountPct?`-${discountPct}% учтено`:''}
    ${breakdown}${info}${actions}`; }else if(templateId==='light'){ body=`${brand}${standardHero}${sellingBlock}
    ${esc(cfg.texts.menuTitle)}
    ${cfg.showGallery?``:''}

    ${esc(cfg.texts.summaryTitle)}

    ${metrics}
    ${esc(cfg.texts.totalPriceTitle)}${money(p.total)}${g?`на гостя: ${money(menuPer)}`:'Финальная сумма'}${cfg.showDiscountBadge&&discountPct?`-${discountPct}% учтено`:''}
    ${breakdown}
    ${info}${actions}`; }else if(templateId==='midnight-glass'){ body=`${brand}
    ${esc(cfg.texts.proposalLabel)}

    ${esc(cfg.texts.menuTitle)} на ваше событие

    ${esc(eventName)}${identityLine?` · ${esc(identityLine)}`:''}

    ${esc(cfg.texts.heroNote)}

    Разнообразие блюдСвежие ингредиентыПремиальное качество
    ${sellingBlock}

    ${esc(cfg.texts.menuTitle)}

    ${items.map(row).join('')}
    ${cfg.showGallery?``:''}

    ${esc(cfg.texts.summaryTitle)}

    ${metrics}
    ${esc(cfg.texts.totalPriceTitle)}${money(p.total)}${g?`на гостя: ${money(menuPer)}`:'Финальная сумма'}${cfg.showDiscountBadge&&discountPct?`-${discountPct}% учтено`:''}
    ${breakdown}
    ${info}${actions}`; }else if(templateId==='midnight-compact'){ body=`${brand}
    ${esc(cfg.texts.proposalLabel)}

    ${esc(eventName)}

    ${identityLine?`

    ${esc(identityLine)}

    `:''}
    ${esc(cfg.texts.heroNote)}
    ${groups.map(groupTable).join('')}
    ${cfg.showGallery?``:''}

    ${esc(cfg.texts.summaryTitle)}

    ${metrics}
    ${esc(cfg.texts.totalPriceTitle)}${money(p.total)}${cfg.showDiscountBadge&&discountPct?`-${discountPct}%`:''}
    ${info}${breakdown}${actions}`; }else if(templateId==='black-gold'){ body=`${brand}
    ${esc(cfg.texts.proposalLabel)}

    Кейтеринг
    для вашего события

    ${esc(eventName)}${identityLine?`
    ${esc(identityLine)}`:''}

    ${esc(cfg.texts.menuTitle)} для вашего мероприятия
    ${items.map((item,i)=>`
    ${esc(item.name)}${String(i+1).padStart(2,'0')}×${Math.round(num(item.qty))}

    ${esc(item.name)}

    ${esc([weightLabel(item)].filter(Boolean).join(' · ')||sectionTitle(item))}

    ${compositionText(item,', ')?`${esc(compositionText(item,', '))}`:''}
    `).join('')}

    ${esc(cfg.texts.summaryTitle)}

    ${metrics}
    ${money(p.total)}${g?`на гостя: ${money(menuPer)}`:''}
    ${blackControl}
    ${blackExtras}${breakdown}${actions}`; }else if(templateId==='emerald-gold'){ body=`${brand}
    ${esc(cfg.texts.proposalLabel)}

    ${esc(eventName)}

    ${esc(cfg.texts.heroNote)}

    ${egFacts.length?`
    ${egFacts.map(x=>`${esc(x)}`).join('')}
    `:''}
    ${metrics}
    ${esc(cfg.texts.totalPriceTitle)}${money(p.total)}
    ${items[1]?``:''}
    ${sellingBlock}

    ${esc(cfg.texts.menuTitle)}

    ${items.map(row).join('')}
    ${egSide}
    ${breakdown}${actions}`; }else{ body=`${brand}
    ${visibleDate?`${esc(visibleDate)}`:''}

    ${esc(eventName)}

    ${esc(cfg.texts.heroNote)}

    ${visibleGuests?`${g}гостей`:''}

    ${esc(cfg.texts.proposalLabel)}

    Свежие ингредиентыАвторские рецептыКонтроль качества
    ${groups.map(groupTable).join('')}

    ${esc(cfg.texts.summaryTitle)}

    ${metrics}
    ${esc(cfg.texts.totalPriceTitle)}${money(p.total)}${cfg.showDiscountBadge&&discountPct?`-${discountPct}%`:''}
    ${info}${breakdown}${actions}`; } const finalGalleryItems=cfg.showFinalPhoto&&Array.isArray(s.finalGallery)?s.finalGallery.filter(Boolean).slice(0,2):[]; const finalPhoto=finalGalleryItems.length?``:''; return `Предложение клиенту
    ${body}${footerNote}${finalPhoto}
    ${esc([s.brandName||'Моя компания',s.brandCity].filter(Boolean).join(' · '))}${esc(s.brandContacts||'')}
    `; } let offerRenderSeq=0; let currentOffer={orderId:null,snapshot:null,prepared:null,pdfPrepared:null,pages:null,pdfBlob:null,pdfPromise:null,renderToken:0,preferDraft:false,transient:false,editTimer:null}; function renderOfferPages(pages){const host=$('sunClientOfferPreview');if(!host)return;host.innerHTML='';for(const page of (pages||[])){page.classList.add('sun-offer-page-canvas');page.setAttribute('aria-label','Страница предложения');host.appendChild(page)}} function offerRenderMessage(text='Обновляю предложение…'){const host=$('sunClientOfferPreview');if(host)host.innerHTML=`
    ${esc(text)}
    `} let offerAssetController=null; let offerRenderTemplateId=null; async function renderOffer(snapshot){ offerAssetController?.abort();const controller=new AbortController();offerAssetController=controller; const renderToken=++offerRenderSeq,current=()=>renderToken===offerRenderSeq&&!controller.signal.aborted; const explicitTemplate=String(snapshot?.offerTemplateId||'').trim(),selectedTemplate=OFFER_TEMPLATE_IDS.has(explicitTemplate)?explicitTemplate:offerTemplateId();offerRenderTemplateId=selectedTemplate; currentOffer.snapshot=snapshot;currentOffer.pdfBlob=null;currentOffer.pdfPromise=null;currentOffer.pages=null; offerRenderMessage('Загружаю фотографии…'); const pdfBtn=$('sunOfferPdf');if(pdfBtn){pdfBtn.disabled=true;pdfBtn.textContent='Готовлю просмотр…'} $('sunOfferAssetWarning')?.remove(); try{ const prepared=await preparedSnapshot(snapshot);if(!current())return null; prepared.offerTemplateId=selectedTemplate; // Warm only the proposal's fonts while its photos load. const fonts=window.CateriumProposalPDF?.readyFonts?.(); const pdfPrepared=await preparedPdfSnapshot(prepared,{signal:controller.signal,onProgress:(done,total)=>{if(current())offerRenderMessage(`Загружаю фотографии: ${done} из ${total}…`)}}); if(!current())return null;offerRenderMessage('Готовлю страницы предложения…'); const pages=await withTimeout(Promise.resolve(fonts).then(()=>renderOfferPdfPages(pdfPrepared)),15000,'Предпросмотр'); if(!current())return null; currentOffer.snapshot=snapshot;currentOffer.prepared=prepared;currentOffer.pdfPrepared=pdfPrepared;currentOffer.pages=pages;currentOffer.renderToken=renderToken; renderOfferPages(pages);syncOfferEditor(); if(pdfPrepared.missingPhotoCount){ const notice=document.createElement('div');notice.id='sunOfferAssetWarning';notice.className='sun-offer-stale on';notice.setAttribute('role','status'); notice.textContent=`Не загрузились фотографии: ${pdfPrepared.missingPhotoCount}. Предложение открыто с доступными изображениями. Нажмите «Обновить», чтобы повторить загрузку фото.`; $('sunClientOfferPreview')?.before(notice); } if(pdfBtn){pdfBtn.disabled=false;pdfBtn.textContent='Скачать PDF'} return pages; }catch(error){ if(!current())return null; console.error('Offer preview failed:',error); currentOffer.prepared=null;currentOffer.pdfPrepared=null;currentOffer.pages=null; offerRenderMessage('Не удалось подготовить предложение. Нажмите «Обновить», чтобы повторить.'); if(pdfBtn){pdfBtn.disabled=true;pdfBtn.textContent='PDF недоступен'} return null; }finally{controller.abort();if(offerAssetController===controller)offerAssetController=null} } function toggleOfferEditor(){const panel=$('sunOfferEditor'),btn=$('sunOfferEdit');if(!panel)return;const on=!panel.classList.contains('on');panel.classList.toggle('on',on);if(btn)btn.textContent=on?'Скрыть редактор':'Редактировать';if(on)syncOfferEditor()} function offerOverrideTextValue(key){const over=currentOffer.snapshot?.offerOverrides?.texts||{},cfg=offerSettings();return Object.prototype.hasOwnProperty.call(over,key)?String(over[key]??''):String(cfg.texts?.[key]??'')} function offerOverrideDataValue(key){const over=currentOffer.snapshot?.offerOverrides?.data||{},snap=currentOffer.snapshot||{};if(Object.prototype.hasOwnProperty.call(over,key))return over[key]??'';return key==='guests'?(snap.guests??''):(snap[key]??'')} function syncOfferEditor(){const panel=$('sunOfferEditor');if(!panel||!currentOffer.snapshot)return;panel.querySelectorAll('[data-offer-text]').forEach(el=>{if(document.activeElement!==el)el.value=offerOverrideTextValue(el.dataset.offerText)});panel.querySelectorAll('[data-offer-data]').forEach(el=>{if(document.activeElement!==el)el.value=offerOverrideDataValue(el.dataset.offerData)});panel.querySelectorAll('[data-offer-list="controlLines"]').forEach(el=>{if(document.activeElement!==el){const over=currentOffer.snapshot?.offerOverrides?.controlLines;const lines=Array.isArray(over)?over:offerSettings().controlLines;el.value=(lines||[]).join('\n')}})} function saveOfferOverrides(){if(!currentOffer.snapshot)return;currentOffer.snapshot.createdAt=new Date().toISOString();if(!currentOffer.transient&¤tOffer.orderId)persistSnapshot(currentOffer.orderId,currentOffer.snapshot);const meta=$('sunOfferMeta');if(meta)meta.textContent=`Изменено ${new Date(currentOffer.snapshot.createdAt).toLocaleString('ru-RU')}`} function scheduleOfferEditRender(){clearTimeout(currentOffer.editTimer);currentOffer.editTimer=setTimeout(()=>{if(currentOffer.snapshot)renderOffer(currentOffer.snapshot).catch(err=>{console.error(err);toast('Не удалось обновить предпросмотр.','error')})},500)} function bindOfferEditor(){const panel=$('sunOfferEditor');if(!panel||panel.dataset.bound)return;panel.dataset.bound='1';panel.addEventListener('input',e=>{const el=e.target.closest('[data-offer-text],[data-offer-data],[data-offer-list]');if(!el||!currentOffer.snapshot)return;currentOffer.snapshot.offerOverrides=currentOffer.snapshot.offerOverrides||{};if(el.dataset.offerText){currentOffer.snapshot.offerOverrides.texts=currentOffer.snapshot.offerOverrides.texts||{};currentOffer.snapshot.offerOverrides.texts[el.dataset.offerText]=el.value}else if(el.dataset.offerList==='controlLines'){currentOffer.snapshot.offerOverrides.controlLines=String(el.value||'').split(/\r?\n/).map(x=>x.trim()).filter(Boolean).slice(0,20)}else{currentOffer.snapshot.offerOverrides.data=currentOffer.snapshot.offerOverrides.data||{};currentOffer.snapshot.offerOverrides.data[el.dataset.offerData]=el.dataset.offerData==='guests'?Math.max(0,Number(el.value||0)):el.value}currentOffer.pdfBlob=null;currentOffer.pages=null;scheduleOfferEditRender()});panel.addEventListener('change',saveOfferOverrides);panel.addEventListener('focusout',e=>{if(e.target.matches?.('[data-offer-text],[data-offer-data],[data-offer-list]'))saveOfferOverrides()})} function setMeta(order,snapshot,{transient=false,autoRefreshed=false}={}){ const stale=snapshotIsStale(order,snapshot),notice=$('sunOfferStale'); if(notice){ notice.classList.toggle('on',Boolean(stale||transient)); notice.textContent=transient ?'В предложении учтены текущие изменения заказа. Сохраните заказ, чтобы закрепить их.' :(stale?'Заказ изменён. Предложение обновляется по актуальным данным…':''); } if($('sunOfferMeta'))$('sunOfferMeta').textContent=`Версия от ${new Date(snapshot.createdAt).toLocaleString('ru-RU')}${autoRefreshed?' · обновлено автоматически':''}`; } async function openOfferForOrder(orderId,{forceRefresh=false,preferDraft=false}={}){ ensureModal(); const saved=currentSavedOrder(orderId);if(!saved)return toast('Заказ не найден.','error'); const order=currentOfferSourceOrder(orderId,preferDraft)||saved; const draftDiff=order!==saved&&orderSignature(order)!==orderSignature(saved); let snapshot=saved.clientOfferSnapshot,autoRefreshed=false; const stale=snapshotIsStale(order,snapshot); if(stale||forceRefresh){ if(forceRefresh)toast('Обновляю предложение…'); snapshot=await createSnapshot(order);autoRefreshed=stale&&!forceRefresh; // Never freeze an unsaved draft into the saved order. Saved orders, however, // always receive the fresh snapshot so every template/PDF opens consistently. if(!draftDiff&&!persistSnapshot(saved.id,snapshot))return; } // Older snapshots can predate the per-order template field. Resolve it // before rendering instead of waiting for the asynchronously mounted picker. if(!OFFER_TEMPLATE_IDS.has(snapshot.offerTemplateId))snapshot={...snapshot,offerTemplateId:OFFER_TEMPLATE_IDS.has(order.clientOfferTemplateId)?order.clientOfferTemplateId:offerTemplateId()}; currentOffer={orderId:saved.id,snapshot,prepared:null,pdfPrepared:null,pages:null,pdfBlob:null,pdfPromise:null,renderToken:0,preferDraft:Boolean(preferDraft),transient:draftDiff,editTimer:null}; setMeta(order,snapshot,{transient:draftDiff,autoRefreshed}); window.modal?.('sunClientOfferModal');const pages=await renderOffer(snapshot); if(pages&&autoRefreshed)toast('Предложение автоматически обновлено по текущему заказу.'); return pages; } async function openCurrentDraftOffer(){ const d=getDraft();if(!d?.id)return alert('Сначала сохраните заказ. После сохранения появится предложение клиенту.'); const saved=currentSavedOrder(d.id);if(!saved)return alert('Сначала сохраните заказ.'); return openOfferForOrder(saved.id,{preferDraft:true}); } async function refreshCurrentOffer(){ if(!currentOffer.orderId)return; const pages=await openOfferForOrder(currentOffer.orderId,{forceRefresh:true,preferDraft:currentOffer.preferDraft}); if(pages)toast('Предложение обновлено.'); } async function ensureCurrentOfferFresh(){ if(!currentOffer.orderId)return false; const saved=currentSavedOrder(currentOffer.orderId);if(!saved)return false; const order=currentOfferSourceOrder(currentOffer.orderId,currentOffer.preferDraft)||saved; if(!snapshotIsStale(order,currentOffer.snapshot))return true; await openOfferForOrder(currentOffer.orderId,{forceRefresh:true,preferDraft:currentOffer.preferDraft}); return true; } function filename(ext){const d=currentOffer.prepared?.date||currentOffer.snapshot?.date||'';return `predlozhenie-klientu${d?'-'+d:''}.${ext}`} function pdfRoundRect(ctx,x,y,w,h,r,fill,stroke){ const rr=Math.min(r,w/2,h/2);ctx.beginPath();ctx.moveTo(x+rr,y);ctx.arcTo(x+w,y,x+w,y+h,rr);ctx.arcTo(x+w,y+h,x,y+h,rr);ctx.arcTo(x,y+h,x,y,rr);ctx.arcTo(x,y,x+w,y,rr);ctx.closePath();if(fill){ctx.fillStyle=fill;ctx.fill()}if(stroke){ctx.strokeStyle=stroke;ctx.lineWidth=2;ctx.stroke()} } function pdfWrap(ctx,text,maxWidth){ const input=String(text||'').replace(/\s+/g,' ').trim();if(!input)return[]; const words=input.split(' '),lines=[];let line=''; const pushLong=word=>{let chunk='';for(const ch of word){const next=chunk+ch;if(ctx.measureText(next).width>maxWidth&&chunk){lines.push(chunk);chunk=ch}else chunk=next}return chunk}; for(const word of words){ if(ctx.measureText(word).width>maxWidth){if(line){lines.push(line);line=''}line=pushLong(word);continue} const test=line?`${line} ${word}`:word; if(ctx.measureText(test).width<=maxWidth)line=test;else{if(line)lines.push(line);line=word} } if(line)lines.push(line);return lines; } function pdfText(ctx,text,x,y,maxWidth,lineHeight,opts={}){ ctx.save();ctx.font=opts.font||'28px Arial';ctx.fillStyle=opts.color||'#1a1a1a';ctx.textAlign=opts.align||'left';ctx.textBaseline='top'; let lines=pdfWrap(ctx,text,maxWidth);const maxLines=Math.max(0,Number(opts.maxLines||0)); if(maxLines&&lines.length>maxLines){lines=lines.slice(0,maxLines);let last=lines[lines.length-1]||'';while(last&&ctx.measureText(last+'...').width>maxWidth)last=last.slice(0,-1);lines[lines.length-1]=last+'...'} const lh=Math.max(lineHeight||0,Math.ceil(parseFloat(String(ctx.font).match(/([0-9.]+)px/)?.[1]||16)*1.18)); lines.forEach((line,i)=>ctx.fillText(line,x,y+i*lh));ctx.restore();return lines.length*lh; } function pdfDrawCheck(ctx,x,y,r,fill,stroke){ctx.save();ctx.fillStyle=fill;ctx.beginPath();ctx.arc(x,y,r,0,Math.PI*2);ctx.fill();ctx.strokeStyle=stroke;ctx.lineWidth=Math.max(2,r*.22);ctx.lineCap='round';ctx.lineJoin='round';ctx.beginPath();ctx.moveTo(x-r*.45,y);ctx.lineTo(x-r*.1,y+r*.34);ctx.lineTo(x+r*.52,y-r*.38);ctx.stroke();ctx.restore()} function pdfLoadImage(src){return new Promise(resolve=>{const safe=isSafeRasterDataUrl(src)?String(src):'';if(!safe)return resolve(null);const img=new Image();let done=false;const finish=v=>{if(done)return;done=true;clearTimeout(timer);resolve(v)};const timer=setTimeout(()=>finish(null),4500);img.onload=()=>finish(img);img.onerror=()=>finish(null);img.src=safe})} function pdfDrawCover(ctx,img,x,y,w,h,r=24){ if(!img)return;const scale=Math.max(w/img.naturalWidth,h/img.naturalHeight),sw=w/scale,sh=h/scale,sx=(img.naturalWidth-sw)/2,sy=(img.naturalHeight-sh)/2; ctx.save();pdfRoundRect(ctx,x,y,w,h,r);ctx.clip();ctx.drawImage(img,sx,sy,sw,sh,x,y,w,h);ctx.restore(); } function pdfDrawContain(ctx,img,x,y,w,h){ if(!img)return;const scale=Math.min(w/img.naturalWidth,h/img.naturalHeight),dw=img.naturalWidth*scale,dh=img.naturalHeight*scale;ctx.drawImage(img,x+(w-dw)/2,y+(h-dh)/2,dw,dh); } const pdfFromJpegs=images=>window.SunPdfEngine.fromJpegs(images); function pdfCanvasJpeg(canvas){ return new Promise((resolve,reject)=>{ let done=false;const finish=(err,value)=>{if(done)return;done=true;clearTimeout(timer);err?reject(err):resolve(value)}; const fromDataUrl=()=>{try{const data=canvas.toDataURL('image/jpeg',0.94),base64=String(data).split(',')[1]||'';if(!base64)throw new Error('Пустое изображение страницы');const raw=atob(base64),bytes=new Uint8Array(raw.length);for(let i=0;i{if(done)return;if(!blob)return fromDataUrl();try{finish(null,{width:canvas.width,height:canvas.height,bytes:new Uint8Array(await blob.arrayBuffer())})}catch(err){fromDataUrl()}},'image/jpeg',0.94); }catch(_){fromDataUrl()} }) } function pdfThemeFor(id){ const themes={ light:{BG:'#ffffff',PANEL:'#ffffff',LINE:'#e7d8bd',TEXT:'#1d211d',MUTED:'#6f746e',GOLD:'#c99b47',GOLD2:'#e2c28b',GREEN:'#476f4d',GREEN_DEEP:'#28533a',GREEN_SOFT:'#f1f5ef',FOOT:'#ffffff'}, 'editorial-grid':{BG:'#fbfaf6',PANEL:'#fffefb',LINE:'#e8dfcf',TEXT:'#163127',MUTED:'#776f62',GOLD:'#b99355',GOLD2:'#d8c4a2',GREEN:'#245f46',GREEN_DEEP:'#1f5a42',GREEN_SOFT:'#e9f1eb',FOOT:'#fbfaf6'}, 'midnight-glass':{BG:'#061521',PANEL:'#0b2230',LINE:'#315263',TEXT:'#f4f1e8',MUTED:'#9aaeb8',GOLD:'#d4a64b',GOLD2:'#765f35',GREEN:'#24a98f',GREEN_DEEP:'#d7ad58',GREEN_SOFT:'#123d38',FOOT:'#04111b'}, 'midnight-compact':{BG:'#03131c',PANEL:'#0a1b25',LINE:'#3a4d57',TEXT:'#f4f3ef',MUTED:'#9daab1',GOLD:'#e0b751',GOLD2:'#5e5231',GREEN:'#62a868',GREEN_DEEP:'#f1c96c',GREEN_SOFT:'#264b37',FOOT:'#06141c'}, 'black-gold':{BG:'#0c0c0b',PANEL:'#151513',LINE:'#453d2e',TEXT:'#f2eee6',MUTED:'#aaa49b',GOLD:'#c99a3c',GOLD2:'#57451f',GREEN:'#b98a31',GREEN_DEEP:'#d2a650',GREEN_SOFT:'#342c1d',FOOT:'#090908'}, 'emerald-gold':{BG:'#061318',PANEL:'#0b2023',LINE:'#3b4f50',TEXT:'#f2efe7',MUTED:'#a7b0ae',GOLD:'#d7ac55',GOLD2:'#6a5930',GREEN:'#1c765d',GREEN_DEEP:'#d9b466',GREEN_SOFT:'#123b35',FOOT:'#07171a'}, 'neon-emerald':{BG:'#020e12',PANEL:'#06191d',LINE:'#277b4d',TEXT:'#effff5',MUTED:'#9cb3aa',GOLD:'#d0a841',GOLD2:'#665322',GREEN:'#39f07c',GREEN_DEEP:'#67ff91',GREEN_SOFT:'#0c3f27',FOOT:'#020b0e'} }; return themes[id]||themes.light; } async function renderOfferPdfPagesBase(s){ const id=s.offerTemplateId||offerTemplateId(),W=1000,H=1414,SCALE=1.6,M=52,T=pdfThemeFor(id),{BG,PANEL,LINE,TEXT,MUTED,GOLD,GOLD2,GREEN,GREEN_DEEP,GREEN_SOFT,FOOT}=T; const cfg=offerSettingsForSnapshot(s),items=s.items||[],imageMap=new Map(); for(const item of items)imageMap.set(item.id,await pdfLoadImage(item.photoData||s.logo||'')); const logo=await pdfLoadImage(s.logo||''); const finalGallery=cfg.showFinalPhoto?(await Promise.all((Array.isArray(s.finalGallery)?s.finalGallery:[]).map(pdfLoadImage))).filter(Boolean):[]; const heroImg=items.map(i=>imageMap.get(i.id)).find(Boolean)||finalGallery[0]||logo,p=s.pricing||{},g=num(s.guests),menuPer=perGuest(p.itemsTotal,g),boxCount=offerBoxCount(items),discountPct=p.base>0?Math.round((p.discount/p.base)*100):0; const primaryMetric=offerPrimaryMetric(s,cfg,g),visibleDate=cfg.showDate?dateText(s.date):'',visibleClient=cfg.showClientName?String(s.client||'').trim():'',visibleGuests=cfg.showGuests&&g?`${g} ${plural(g,'гость','гостя','гостей')}`:''; const identity=[visibleClient,visibleDate,visibleGuests].filter(Boolean).join(' · '),control=Array.isArray(cfg.controlLines)&&cfg.controlLines.length?cfg.controlLines:DELIVERY_CONTROL,extras=Array.isArray(cfg.extraServices)&&cfg.extraServices.length?cfg.extraServices:EXTRA_SERVICES.map(x=>x[0]); const canvases=[];let canvas,ctx,y;const bottom=H-78; function newPage(){canvas=document.createElement('canvas');canvas.width=Math.round(W*SCALE);canvas.height=Math.round(H*SCALE);ctx=canvas.getContext('2d',{alpha:false});ctx.setTransform(SCALE,0,0,SCALE,0,0);ctx.fillStyle=BG;ctx.fillRect(0,0,W,H);y=M;canvases.push(canvas);return ctx} function space(h){if(y+h>bottom)newPage()} function fitBlockHeight(natural,minHeight=natural,gap=0,softRatio=.84){ const h=Math.max(1,Number(natural||0)),minH=Math.min(h,Math.max(1,Number(minHeight||h))),available=Math.max(0,bottom-y-gap); if(h<=available)return h; if(available>=minH&&available>=h*softRatio)return available; newPage();return h; } function card(x,yy,w,h,r=20,fill=PANEL,stroke=LINE){pdfRoundRect(ctx,x,yy,w,h,r,fill,stroke)} function line(x1,yy1,x2,yy2,color=LINE,width=1){const snap=v=>width===1?Math.round(v)+.5:v;ctx.save();ctx.strokeStyle=color;ctx.lineWidth=width;ctx.beginPath();ctx.moveTo(snap(x1),snap(yy1));ctx.lineTo(snap(x2),snap(yy2));ctx.stroke();ctx.restore()} function wrapCount(text,width,font){ctx.save();ctx.font=font;const n=Math.max(1,pdfWrap(ctx,String(text||''),width).length);ctx.restore();return n} function wrappedLines(text,width,font){ctx.save();ctx.font=font;const out=pdfWrap(ctx,String(text||''),width);ctx.restore();return out} function drawWrappedLines(lines,x,yy,lineHeight,{font='12px Arial',color=TEXT,align='left'}={}){ctx.save();ctx.font=font;ctx.fillStyle=color;ctx.textAlign=align;ctx.textBaseline='top';lines.forEach((line,i)=>ctx.fillText(line,x,yy+i*lineHeight));ctx.restore()} function compositionTextPdf(item,sep=' · '){return offerComposition(item).join(sep)} function brandHeader(){space(82);if(logo)pdfDrawContain(ctx,logo,M,y,195,62);else pdfText(ctx,s.brandName||'Моя компания',M,y+10,400,28,{font:'700 22px Arial',color:TEXT,maxLines:2});if(visibleDate)pdfText(ctx,visibleDate,W-M,y+24,300,24,{font:'14px Arial',color:MUTED,align:'right',maxLines:1});y+=78} function hero(){ const event=s.event||'Персональное предложение'; if(id==='black-gold'){ space(425);const h=405;card(M,y,W-2*M,h,24,'#0c0c0b',LINE);if(heroImg)pdfDrawCover(ctx,heroImg,M,y,W-2*M,h,24);const grad=ctx.createLinearGradient(M,y,M+650,y);grad.addColorStop(0,'rgba(0,0,0,.94)');grad.addColorStop(.68,'rgba(0,0,0,.48)');grad.addColorStop(1,'rgba(0,0,0,0)');ctx.save();pdfRoundRect(ctx,M,y,W-2*M,h,24);ctx.clip();ctx.fillStyle=grad;ctx.fillRect(M,y,W-2*M,h);ctx.restore();pdfText(ctx,cfg.texts.proposalLabel,M+34,y+36,430,22,{font:'700 12px Arial',color:GOLD,maxLines:1});pdfText(ctx,'Кейтеринг для вашего события',M+34,y+78,500,56,{font:'500 43px Georgia',color:'#ffffff',maxLines:3});pdfText(ctx,[event,identity].filter(Boolean).join(' · '),M+34,y+273,510,28,{font:'16px Arial',color:'#ded8cd',maxLines:3});y+=h+18;return; } if(id==='editorial-grid'){ const leftW=386,rightX=M+leftW,rightW=W-M-rightX,leftTextW=leftW-56,eventLines=wrappedLines(event,leftTextW,'500 34px Georgia'),noteLines=wrappedLines(cfg.texts.heroNote,leftTextW,'12px Arial'); const h=Math.max(350,94+eventLines.length*43+(visibleClient?31:0)+([visibleDate,visibleGuests].filter(Boolean).length?42:0)+Math.max(52,noteLines.length*19+18)); space(h+18);card(M,y,W-2*M,h,18,PANEL,LINE);if(heroImg)pdfDrawCover(ctx,heroImg,rightX+1,y+1,rightW-1,h-2,17); pdfText(ctx,cfg.texts.proposalLabel,M+26,y+26,leftTextW,20,{font:'700 11px Arial',color:GOLD,maxLines:2}); drawWrappedLines(eventLines,M+26,y+62,43,{font:'500 34px Georgia',color:TEXT});let ty=y+62+eventLines.length*43+12; if(visibleClient){pdfText(ctx,visibleClient,M+26,ty,leftTextW,25,{font:'500 17px Georgia',color:GREEN_DEEP,maxLines:1});ty+=31} const facts=[visibleDate,visibleGuests].filter(Boolean).join(' · ');if(facts){pdfText(ctx,facts,M+26,ty,leftTextW,22,{font:'12px Arial',color:TEXT,maxLines:2});ty+=38} line(M+26,ty,M+26+leftTextW,ty,GOLD2,1);ty+=15;if(noteLines.length)drawWrappedLines(noteLines,M+26,ty,19,{font:'12px Arial',color:MUTED}); y+=h+18;return; } if(id==='midnight-compact'){ const innerX=M+28,innerW=W-2*M-56,leftW=510,gap=28,rightX=innerX+leftW+gap,rightW=innerW-leftW-gap; const eventLines=wrapCount(event,leftW,'700 31px Arial'),noteLines=wrapCount(cfg.texts.heroNote,rightW,'15px Arial'),h=Math.max(228,138+Math.max(eventLines*42,noteLines*21));space(h+20);card(M,y,W-2*M,h,20,PANEL,LINE); pdfText(ctx,cfg.texts.proposalLabel,innerX,y+24,leftW,22,{font:'700 12px Arial',color:GOLD,maxLines:1}); pdfText(ctx,event,innerX,y+57,leftW,42,{font:'700 31px Arial',color:TEXT,maxLines:3}); line(rightX-gap/2,y+28,rightX-gap/2,y+h-28,LINE,1); pdfText(ctx,cfg.texts.heroNote,rightX,y+55,rightW,21,{font:'15px Arial',color:GREEN_DEEP,maxLines:7}); if(identity)pdfText(ctx,identity,innerX,y+h-39,leftW,23,{font:'13px Arial',color:MUTED,maxLines:1}); y+=h+18;return; } const left=id==='neon-emerald'?500:425,rightX=M+left,rightW=W-M-rightX,leftTextW=left-56; const eventFont=id==='neon-emerald'?'700 34px Arial':'500 38px Georgia',eventLH=48,noteFont='12px Arial',noteLH=20; const eventLines=wrappedLines(event,leftTextW,eventFont),noteLines=wrappedLines(cfg.texts.heroNote,leftTextW,noteFont); const identityRows=(visibleClient?31:0)+([visibleDate,visibleGuests].filter(Boolean).length?52:0); const textNeed=68+Math.max(1,eventLines.length)*eventLH+24+identityRows+(noteLines.length?Math.max(54,noteLines.length*noteLH+14):0)+22; const h=Math.max(id==='emerald-gold'?380:335,textNeed);space(h+20);card(M,y,W-2*M,h,24,PANEL,LINE);if(heroImg)pdfDrawCover(ctx,heroImg,rightX+1,y+1,rightW-1,h-2,22); pdfText(ctx,cfg.texts.proposalLabel,M+28,y+29,leftTextW,22,{font:'700 12px Arial',color:id==='neon-emerald'?GREEN:GOLD,maxLines:2}); drawWrappedLines(eventLines,M+28,y+68,eventLH,{font:eventFont,color:TEXT}); let ty=y+68+Math.max(1,eventLines.length)*eventLH+20;if(visibleClient){pdfText(ctx,visibleClient,M+28,ty,leftTextW,27,{font:'500 18px Georgia',color:GREEN_DEEP,maxLines:1});ty+=31} const facts=[visibleDate,visibleGuests].filter(Boolean).join(' · ');if(facts){pdfText(ctx,facts,M+28,ty,leftTextW,26,{font:'14px Arial',color:TEXT,maxLines:2});ty+=48} if(noteLines.length){line(M+28,ty-8,M+28+leftTextW,ty-8,GOLD2,1);drawWrappedLines(noteLines,M+28,ty+6,noteLH,{font:noteFont,color:MUTED})} y+=h+18; } function sellingBlock(){ const title=String(cfg.texts.salesTitle||'').trim(),body=String(cfg.texts.salesNote||'').trim();if(!title&&!body)return; const x=M,w=W-2*M,padX=30,titleFont='500 22px Georgia',bodyFont='14px Arial',titleLH=30,bodyLH=23,innerW=w-padX*2-12; let bodyLines=wrappedLines(body,innerW,bodyFont),first=true; do{ const titleLines=first&&title?wrappedLines(title,innerW,titleFont):[]; const headingH=titleLines.length?titleLines.length*titleLH+12:0; const minRows=Math.min(3,Math.max(1,bodyLines.length||1)); let minH=30+headingH+(bodyLines.length?minRows*bodyLH:0)+24; if(bottom-y{ const nameLines=Math.max(1,wrapCount(item.name,cw-32,'500 17px Georgia')); const meta=[weightLabel(item),compositionTextPdf(item,', ')].filter(Boolean).join(' · '); const metaLines=meta?Math.max(1,wrapCount(meta,cw-32,'10px Arial')):0; const minH=252+nameLines*24+(metaLines?8+metaLines*19:0)+18; return {item,nameLines,meta,metaLines,minH,h:Math.max(340,minH)}; }); const naturalRh=Math.max(...measured.map(v=>v.h),340),minRh=Math.max(...measured.map(v=>v.minH)),rh=fitBlockHeight(naturalRh,minRh,14,.88); measured.forEach((v,j)=>{ const {item,nameLines,meta,metaLines}=v,x=M+j*(cw+gap); card(x,y,cw,rh,16,PANEL,LINE);const img=imageMap.get(item.id)||logo;if(img)pdfDrawCover(ctx,img,x+1,y+1,cw-2,205,15); pdfText(ctx,String(i+j+1).padStart(2,'0'),x+12,y+12,38,20,{font:'700 11px Arial',color:GOLD,maxLines:1}); pdfText(ctx,`x${Math.round(num(item.qty))}`,x+cw-13,y+12,40,20,{font:'700 11px Arial',color:'#ffffff',align:'right',maxLines:1}); pdfText(ctx,item.name,x+16,y+222,cw-32,24,{font:'500 17px Georgia',color:TEXT,maxLines:nameLines}); if(meta)pdfText(ctx,meta,x+16,y+222+nameLines*24+8,cw-32,19,{font:'10px Arial',color:MUTED,maxLines:metaLines}); }); y+=rh+14 } return; } if(id==='editorial-grid'){ const groups=editorialGroups(items),gap=10,cw=(W-2*M-gap)/2; for(let i=0;i{ const x=M+j*(cw+gap);card(x,y,cw,rh,13,PANEL,LINE); ctx.save();ctx.fillStyle=GREEN_DEEP;ctx.beginPath();ctx.arc(x+31,y+39,20,0,Math.PI*2);ctx.fill();ctx.restore(); pdfText(ctx,String(i+j+1).padStart(2,'0'),x+31,y+30,30,18,{font:'700 10px Arial',color:'#ffffff',align:'center',maxLines:1}); pdfText(ctx,group.name,x+62,y+15,cw-150,24,{font:'500 16px Georgia',color:TEXT,maxLines:2}); if(group.meta)pdfText(ctx,group.meta,x+62,y+48,cw-150,18,{font:'10px Arial',color:GOLD,maxLines:1}); card(x+cw-68,y+24,48,28,14,PANEL,LINE);pdfText(ctx,`x${Math.round(group.qty)}`,x+cw-44,y+29,40,18,{font:'11px Arial',color:TEXT,align:'center',maxLines:1}); }); y+=rh+10; } if(cfg.showGallery)drawGallery(4,126,false);return; } if(id==='midnight-compact'){ const groups=offerGroups(items); for(const group of groups){ const titleLines=wrapCount(group.name,600,'700 16px Arial'),headerH=Math.max(45,18+titleLines*24);space(headerH+20); card(M,y,W-2*M,headerH,13,PANEL,LINE);pdfText(ctx,group.name,M+18,y+12,650,24,{font:'700 16px Arial',color:GREEN_DEEP,maxLines:titleLines});y+=headerH; for(const item of group.list){ const nameLines=Math.max(1,wrapCount(item.name,535,'14px Arial')); const comp=compositionTextPdf(item); const compLines=comp?Math.max(1,wrapCount(comp,535,'10px Arial')):0; const minH=28+nameLines*21+(compLines?5+compLines*17:0)+10,naturalH=Math.max(64,minH),h=fitBlockHeight(naturalH,minH,0,.88); line(M,y,W-M,y,LINE,1); pdfText(ctx,item.name,M+18,y+14,535,21,{font:'14px Arial',color:TEXT,maxLines:nameLines}); if(comp)pdfText(ctx,comp,M+18,y+14+nameLines*21+5,535,17,{font:'10px Arial',color:MUTED,maxLines:compLines}); pdfText(ctx,weightLabel(item)||item.weight||'—',M+588,y+14,188,36,{font:'11px Arial',color:MUTED,align:'center',maxLines:2}); pdfText(ctx,`x${Math.round(num(item.qty))}`,W-M-25,y+14,80,21,{font:'700 13px Arial',color:TEXT,align:'right',maxLines:1}); y+=h } y+=10 } if(cfg.showGallery)drawGallery(5,118,true);return; } if(id==='neon-emerald'){ const gap=12,cw=(W-2*M-gap)/2; for(let i=0;i{ const nameLines=Math.max(1,wrapCount(item.name,cw-202,'700 15px Arial')); const meta=[weightLabel(item),compositionTextPdf(item)].filter(Boolean).join(' · '); const metaLines=meta?Math.max(1,wrapCount(meta,cw-202,'10px Arial')):0; const minH=38+nameLines*22+(metaLines?6+metaLines*18:0)+20; return {item,nameLines,meta,metaLines,minH,h:Math.max(124,minH)}; }); const naturalRh=Math.max(...measured.map(v=>v.h)),minRh=Math.max(...measured.map(v=>v.minH)),rh=fitBlockHeight(naturalRh,minRh,12,.88); measured.forEach((v,j)=>{ const {item,nameLines,meta,metaLines}=v,x=M+j*(cw+gap);card(x,y,cw,rh,16,PANEL,LINE); const img=imageMap.get(item.id)||logo;if(img)pdfDrawCover(ctx,img,x+8,y+8,102,rh-16,12); pdfText(ctx,item.name,x+122,y+17,cw-202,22,{font:'700 15px Arial',color:TEXT,maxLines:nameLines}); if(meta)pdfText(ctx,meta,x+122,y+17+nameLines*22+6,cw-202,18,{font:'10px Arial',color:GREEN,maxLines:metaLines}); pdfText(ctx,`x${Math.round(num(item.qty))}`,x+cw-18,y+18,55,20,{font:'700 12px Arial',color:GREEN,align:'right',maxLines:1}); }); y+=rh+12 } return; } if(id==='emerald-gold'){ for(const item of items){ const nameLines=Math.max(1,wrapCount(item.name,W-2*M-220,'500 18px Georgia')); const meta=[weightLabel(item),compositionTextPdf(item)].filter(Boolean).join(' · '); const metaLines=meta?Math.max(1,wrapCount(meta,W-2*M-240,'10px Arial')):0; const minH=38+nameLines*27+(metaLines?8+metaLines*19:0)+20,naturalH=Math.max(130,minH),h=fitBlockHeight(naturalH,minH,10,.88); card(M,y,W-2*M,h,18,PANEL,LINE);const img=imageMap.get(item.id)||logo;if(img)pdfDrawCover(ctx,img,W-M-160,y+8,150,h-16,12); pdfText(ctx,item.name,M+22,y+18,W-2*M-220,27,{font:'500 18px Georgia',color:TEXT,maxLines:nameLines}); if(meta)pdfText(ctx,meta,M+22,y+18+nameLines*27+8,W-2*M-240,19,{font:'10px Arial',color:MUTED,maxLines:metaLines}); pdfText(ctx,`x${Math.round(num(item.qty))}`,W-M-176,y+18,58,22,{font:'700 12px Arial',color:GOLD,align:'right',maxLines:1});y+=h+10 } return; } const gap=14,cw=(W-2*M-gap)/2; for(let i=0;i{ const meta=[weightLabel(item),compositionTextPdf(item)].filter(Boolean).join(' · '); const nameLines=Math.max(1,wrapCount(item.name,cw-224,'500 18px Georgia')); const metaLines=meta?Math.max(1,wrapCount(meta,cw-230,'11px Arial')):0,minH=36+nameLines*27+(metaLines?8+metaLines*20:0); return {minH,h:Math.max(150,minH)}; }),naturalRh=Math.max(...measured.map(v=>v.h)),minRh=Math.max(...measured.map(v=>v.minH)),rh=fitBlockHeight(naturalRh,minRh,14,.88); row.forEach((item,j)=>itemCardLight(item,M+j*(cw+gap),y,cw,rh));y+=rh+14 } // PDF cards already contain the position photo; a second gallery duplicated the same images and created extra sparse pages. } function drawGallery(cols=6,h=120,boxesOnly=false){const galleryItems=boxesOnly?items.filter(i=>[0,5].includes(Number(i.categoryId))):items;if(!galleryItems.length)return;const gap=8,shown=Math.min(galleryItems.length,cols*2),rows=Math.ceil(shown/cols),natural=rows*h+(rows-1)*gap,minCell=Math.min(h,96),minTotal=rows*minCell+(rows-1)*gap,total=fitBlockHeight(natural,minTotal,20,.86),cellH=Math.max(minCell,(total-(rows-1)*gap)/rows),w=(W-2*M-gap*(cols-1))/cols;for(let i=0;i{const x=M+22+i*mw;pdfText(ctx,it[0],x+mw/2,metricsY,mw-20,34,{font:'500 23px Georgia',color:TEXT,align:'center',maxLines:1});pdfText(ctx,it[1],x+mw/2,metricsY+43,mw-20,38,{font:'10px Arial',color:MUTED,align:'center',maxLines:2});if(isum+Math.max(minRow,wrapCount(t,width,font)*lineH+9),0)} function drawControlCard(x,yy,w,h){card(x,yy,w,h,18,PANEL,LINE);const titleLines=wrapCount(cfg.texts.controlTitle,w-40,'500 18px Georgia');pdfText(ctx,cfg.texts.controlTitle,x+w/2,yy+20,w-40,27,{font:'500 18px Georgia',color:GREEN_DEEP,align:'center',maxLines:3});let cy=yy+34+titleLines*27;for(const t of control){const lines=wrapCount(t,w-78,'12px Arial'),rowH=Math.max(31,lines*20+9);pdfDrawCheck(ctx,x+29,cy+9,8,GREEN_SOFT,GREEN_DEEP);pdfText(ctx,t,x+48,cy,w-70,rowH,{font:'12px Arial',color:TEXT,maxLines:5});cy+=rowH}} function drawExtrasCard(x,yy,w,h){card(x,yy,w,h,18,PANEL,LINE);const titleLines=wrapCount(cfg.texts.extrasTitle,w-40,'500 18px Georgia');pdfText(ctx,cfg.texts.extrasTitle,x+w/2,yy+20,w-40,27,{font:'500 18px Georgia',color:GREEN_DEEP,align:'center',maxLines:3});let cy=yy+36+titleLines*27;for(const name of extras){const lines=wrapCount(name,w-70,'12px Arial'),rowH=Math.max(37,lines*20+9);ctx.save();ctx.fillStyle=GREEN_SOFT;ctx.beginPath();ctx.arc(x+31,cy+8,8,0,Math.PI*2);ctx.fill();ctx.restore();pdfText(ctx,'+',x+31,cy+1,16,16,{font:'700 11px Arial',color:GOLD,align:'center',maxLines:1});pdfText(ctx,name,x+50,cy,w-70,rowH,{font:'12px Arial',color:TEXT,maxLines:4});cy+=rowH}} function infoCards(){ if(!cfg.showControl&&!cfg.showExtras)return;const gap=14,count=(cfg.showControl?1:0)+(cfg.showExtras?1:0),cw=count===1?W-2*M:(W-2*M-gap)/2; const heights=[];if(cfg.showControl){const title=wrapCount(cfg.texts.controlTitle,cw-40,'500 18px Georgia');heights.push(50+title*27+measureListHeight(control,cw-78))}if(cfg.showExtras){const title=wrapCount(cfg.texts.extrasTitle,cw-40,'500 18px Georgia');heights.push(58+title*27+measureListHeight(extras,cw-70,'12px Arial',20,37))}const minH=Math.max(1,...heights),naturalH=Math.max(230,minH),h=fitBlockHeight(naturalH,minH,18,.9);let x=M;if(cfg.showControl){drawControlCard(x,y,cw,h);x+=cw+gap}if(cfg.showExtras)drawExtrasCard(x,y,cw,h);y+=h+18; } function footerNoteNaturalHeight(){if(!cfg.showFooterNote||!String(cfg.texts.footerNote||'').trim())return 0;const lines=wrapCount(cfg.texts.footerNote,W-2*M-34,'11px Arial');return Math.max(44,22+lines*18)+12} function pricing(){if(!cfg.showPriceBreakdown)return;const rows=[[cfg.texts.priceItemsLabel||'Стоимость позиций',p.base],...(p.manual?[[cfg.texts.discountLabel||'Скидка',-p.manual]]:[]),...(p.promo?[[`${cfg.texts.promoLabel||'Промокод'}${s.promoCode?' '+s.promoCode:''}`,-p.promo]]:[]),[cfg.texts.menuAfterDiscountLabel||'Меню после скидок',p.itemsTotal],[cfg.texts.deliveryLabel||'Доставка',p.delivery],[cfg.texts.totalLabel||'Итого',p.total]],title=cfg.texts.pricingTitle||'Расчёт стоимости',titleLines=wrapCount(title,420,'500 18px Georgia'),naturalH=48+titleLines*27+rows.length*31,minH=44+titleLines*27+rows.length*26,footerReserve=footerNoteNaturalHeight(); if(y+naturalH+16<=bottom&&footerReserve&&y+naturalH+16+footerReserve>bottom)newPage(); const h=fitBlockHeight(naturalH,minH,16,.88),rowStep=Math.max(26,Math.min(31,(h-44-titleLines*27)/Math.max(1,rows.length)));card(M,y,W-2*M,h,16,PANEL,LINE);pdfText(ctx,title,M+20,y+16,420,27,{font:'500 18px Georgia',color:GREEN_DEEP,maxLines:2});let yy=y+24+titleLines*27;for(const [label,val] of rows){const grand=label===(cfg.texts.totalLabel||'Итого');pdfText(ctx,label,M+20,yy,490,21,{font:grand?'700 13px Arial':'12px Arial',color:grand?GREEN_DEEP:MUTED,maxLines:1});pdfText(ctx,(val<0?'- ':'')+money(Math.abs(val)),W-M-20,yy,250,21,{font:grand?'700 14px Arial':'12px Arial',color:grand?GREEN_DEEP:TEXT,align:'right',maxLines:1});yy+=rowStep}y+=h+16} function footerNoteBlock(){if(!cfg.showFooterNote||!String(cfg.texts.footerNote||'').trim())return;const lines=wrapCount(cfg.texts.footerNote,W-2*M-34,'11px Arial'),naturalH=Math.max(44,22+lines*18),minH=Math.max(36,18+lines*16),h=fitBlockHeight(naturalH,minH,12,.82);card(M,y,W-2*M,h,12,PANEL,LINE);pdfText(ctx,cfg.texts.footerNote,W/2,y+Math.max(8,(h-lines*18)/2),W-2*M-34,18,{font:'11px Arial',color:MUTED,align:'center',maxLines:6});y+=h+12} function finalGalleryFill(){ if(!finalGallery.length)return; const title=cfg.texts.finalGalleryTitle||'Как это выглядит на вашем столе',gap=12; const titleLines=wrappedLines(title,W-2*M,'500 21px Georgia'),titleH=Math.max(36,titleLines.length*28+8); const free=Math.max(0,bottom-y),minPhotoH=92; // This block may consume only the whitespace already left on the current last page. // It never calls newPage() and never creates an extra page. if(free=205&&finalGallery.length>=2?2:1; if(count===1){const img=finalGallery[1]||finalGallery[0];if(img)pdfDrawCover(ctx,img,M,y,W-2*M,available,14)} else{const imgs=finalGallery.slice(0,2),cellW=(W-2*M-gap)/2;if(imgs[0])pdfDrawCover(ctx,imgs[0],M,y,cellW,available,14);if(imgs[1])pdfDrawCover(ctx,imgs[1],M+cellW+gap,y,cellW,available,14)} y=bottom; } newPage(); if(id==='editorial-grid'){brandHeader();hero();drawItems();summary();pricing();sellingBlock();infoCards();finalGalleryFill();footerNoteBlock();} else if(id==='midnight-glass'){brandHeader();hero();sellingBlock();summary();infoCards();drawItems();pricing();finalGalleryFill();footerNoteBlock();} else if(id==='emerald-gold'){brandHeader();hero();summary();sellingBlock();infoCards();pricing();drawItems();footerNoteBlock();finalGalleryFill();} else{brandHeader();hero();sellingBlock();drawItems();summary();infoCards();pricing();footerNoteBlock();finalGalleryFill();} canvases.forEach((c,i)=>{const x=c.getContext('2d');x.setTransform(SCALE,0,0,SCALE,0,0);x.save();x.fillStyle=FOOT;x.fillRect(0,H-52,W,52);x.strokeStyle=LINE;x.lineWidth=1;x.beginPath();x.moveTo(0,H-52.5);x.lineTo(W,H-52.5);x.stroke();pdfText(x,([s.brandName||'Моя компания',s.brandCity].filter(Boolean).join(' · ')),M,H-36,620,22,{font:'12px Arial',color:MUTED,maxLines:1});pdfText(x,`${i+1} / ${canvases.length}`,W-M,H-36,100,22,{font:'12px Arial',color:MUTED,align:'right',maxLines:1});x.restore()}); return canvases; } async function renderOfferPdfPages(s){ const quality=window.CateriumProposalPDF; if(quality?.IDS?.includes(s.offerTemplateId||offerTemplateId()))return quality.renderPages({...s,offerTemplateId:s.offerTemplateId||offerTemplateId(),pdfSettings:offerSettingsForSnapshot(s)}); const signature=window.SunSignatureOfferPDFV18,classic=window.SunClassicOfferPDFV1767; if(signature?.SIGNATURE_IDS?.includes(s.offerTemplateId)&&signature?.renderPages)return signature.renderPages(s,renderOfferPdfPagesBase); if(classic?.renderPages)return classic.renderPages(s,renderOfferPdfPagesBase); return renderOfferPdfPagesBase(s); } async function pdfBlobFromPages(canvases){const jpeg=[];for(const c of (canvases||[]))jpeg.push(await pdfCanvasJpeg(c));return pdfFromJpegs(jpeg)} async function buildOfferPdfBlob(s){return pdfBlobFromPages(await renderOfferPdfPages(s))} async function triggerPdfDownload(blob,name){ const isAppleTouch=/iP(?:ad|hone|od)/i.test(navigator.userAgent||'')||(navigator.platform==='MacIntel'&&Number(navigator.maxTouchPoints||0)>1); if(isAppleTouch&&typeof File==='function'&&navigator.share&&navigator.canShare){ try{const file=new File([blob],name,{type:'application/pdf'});if(navigator.canShare({files:[file]})){await navigator.share({files:[file],title:'Предложение клиенту'});return 'share'}}catch(err){if(err?.name==='AbortError')return 'cancel';console.warn('PDF share fallback:',err)} } const url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=name;a.style.display='none';a.rel='noopener';document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),120000);return 'download'; } function withTimeout(promise,ms,label='Операция'){let timer;return Promise.race([promise,new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error(label+' превысила время ожидания')),ms)})]).finally(()=>clearTimeout(timer))} function prepareCurrentOfferPdf(prepared,renderToken=currentOffer.renderToken){ const btn=$('sunOfferPdf');if(btn){btn.disabled=true;btn.textContent='Готовлю PDF…'} let promise; const source=currentOffer.pages?.length?pdfBlobFromPages(currentOffer.pages):preparedPdfSnapshot(prepared).then(buildOfferPdfBlob); promise=withTimeout(source,60000,'Подготовка PDF').then(blob=>{if(currentOffer.renderToken===renderToken){currentOffer.pdfBlob=blob;if(btn){btn.disabled=false;btn.textContent='Скачать PDF'}}return blob}).catch(err=>{console.error(err);if(currentOffer.renderToken===renderToken){currentOffer.pdfBlob=null;if(btn){btn.disabled=false;btn.textContent='Скачать PDF'}}toast(`Не удалось подготовить PDF: ${err?.message||'неизвестная ошибка'}. Нажмите «Скачать PDF», чтобы повторить.`,'error');return null}).finally(()=>{if(currentOffer.pdfPromise===promise)currentOffer.pdfPromise=null});currentOffer.pdfPromise=promise;return promise; } async function downloadCurrentOfferPdf(){ if(!currentOffer.prepared&&!currentOffer.pdfBlob){toast('Сначала откройте предложение клиенту.','error');return} const btn=$('sunOfferPdf');if(btn){btn.disabled=true;btn.textContent='Готовлю PDF…'} try{ if(currentOffer.editTimer){clearTimeout(currentOffer.editTimer);currentOffer.editTimer=null;saveOfferOverrides();await renderOffer(currentOffer.snapshot)} else if(!currentOffer.pages?.length&¤tOffer.snapshot)await renderOffer(currentOffer.snapshot); await ensureCurrentOfferFresh(); let blob=currentOffer.pdfBlob; if(!blob&¤tOffer.pdfPromise)blob=await currentOffer.pdfPromise; if(!blob&¤tOffer.prepared)blob=await prepareCurrentOfferPdf(currentOffer.prepared); // One automatic retry after a failed background preparation. if(!blob&¤tOffer.prepared)blob=await prepareCurrentOfferPdf(currentOffer.prepared); if(!blob)throw new Error('PDF не удалось сформировать'); currentOffer.pdfBlob=blob; const mode=await triggerPdfDownload(blob,filename('pdf')); if(mode==='share')toast('PDF готов. Выберите «Сохранить в Файлы» или нужное приложение.'); else if(mode!=='cancel')toast('PDF скачивается.'); }catch(err){console.error(err);toast(`Не удалось скачать PDF: ${err?.message||'неизвестная ошибка'}. Попробуйте ещё раз.`,'error')} finally{if(btn){btn.disabled=false;btn.textContent='Скачать PDF'}} } function ensureButton(){ const actions=document.querySelector('#new .actions');if(!actions)return;let b=$('sunClientOfferButton');if(!b){b=document.createElement('button');b.id='sunClientOfferButton';b.type='button';b.className='outline';b.textContent='Предложение клиенту';b.onclick=openCurrentDraftOffer;const del=$('deleteOrderBtn');if(del)window.SunSafe.insertBefore(actions,b,del);else actions.appendChild(b);}const d=getDraft();b.title=d?.id?'Открыть клиентское предложение':'Сначала сохраните заказ'; } function patchOrderViewer(){qa('.sun-order-view-actions').forEach(actions=>{if(actions.querySelector('[data-sun-offer-id]'))return;const edit=actions.querySelector('[data-viewer-action="edit"]');const id=edit?.dataset?.id;if(!id)return;const b=document.createElement('button');b.className='outline';b.type='button';b.textContent='Предложение';b.dataset.sunOfferId=id;b.onclick=()=>openOfferForOrder(id);const spacer=actions.querySelector('.spacer');window.SunSafe.insertBefore(actions,b,spacer||null);});} async function catalogPhotoChanged(itemId){ if(!currentOffer.snapshot||!currentOffer.orderId)return; if(!$('sunClientOfferModal')?.classList.contains('on'))return; if(!currentOffer.snapshot.items?.some(item=>String(item.id)===String(itemId)))return; await renderOffer(currentOffer.snapshot); } const refreshOpenOffer=()=>{if(currentOffer.snapshot&¤tOffer.orderId&&$('sunClientOfferModal')?.classList.contains('on'))renderOffer(currentOffer.snapshot).catch(err=>console.warn('Offer refresh:',err))}; window.addEventListener('sunoffertemplatechange',event=>{ if(!currentOffer.snapshot||!currentOffer.orderId)return; const change=event.detail||{};if(change.perOffer&&String(change.orderId)!==String(currentOffer.orderId))return; const order=currentSavedOrder(currentOffer.orderId),id=change.perOffer?change.id:(order?.clientOfferTemplateId||order?.clientOfferSnapshot?.offerTemplateId||offerTemplateId()); if(!OFFER_TEMPLATE_IDS.has(id)||id===offerRenderTemplateId)return; currentOffer.snapshot={...currentOffer.snapshot,offerTemplateId:id};refreshOpenOffer(); }); window.addEventListener('sunclientoffersettingschange',refreshOpenOffer); window.sunOpenClientOffer=openOfferForOrder;window.sunOpenCurrentClientOffer=openCurrentDraftOffer;window.sunGetClientOfferPdfBlob=()=>currentOffer.pdfBlob;window.sunClientOfferDownload=downloadCurrentOfferPdf;window.sunClientOfferCatalogChanged=catalogPhotoChanged;window.sunClientOfferDebugHtml=offerHtml;window.sunClientOfferDebugPdf=async prepared=>buildOfferPdfBlob(await preparedPdfSnapshot(prepared));window.sunClientOfferDebugPdfPages=async prepared=>renderOfferPdfPages(await preparedPdfSnapshot(prepared));window.sunClientOfferDebugCreateSnapshot=createSnapshot;window.sunClientOfferDebugBoxCount=offerBoxCount; (()=>{let queued=false;const run=()=>{if(queued)return;queued=true;setTimeout(()=>{queued=false;ensureButton();patchOrderViewer();},50);};[$('new'),$('orders')].filter(Boolean).forEach(root=>new MutationObserver(run).observe(root,{childList:true,subtree:true}));})(); document.addEventListener('click',e=>{const b=e.target.closest('[data-sun-offer-id]');if(b){e.preventDefault();e.stopPropagation();openOfferForOrder(b.dataset.sunOfferId)}},true); ensureModal();ensureButton();patchOrderViewer();setTimeout(ensureButton,150);setTimeout(patchOrderViewer,300); })(); ; /* ===== MODULE: auto-offer.js ===== */ (function(){ 'use strict'; if(window.__sunAutoOfferV1)return;window.__sunAutoOfferV1=true; const $=id=>document.getElementById(id); const qa=(sel,root=document)=>[...root.querySelectorAll(sel)]; const esc=window.SunSafe.escapeHTML; const money=v=>`${Math.round(Number(v||0)).toLocaleString('ru-RU')} \u20bd`; const txt={ gen:'\u0421\u0433\u0435\u043d\u0435\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043f\u0440\u0435\u0434\u043b\u043e\u0436\u0435\u043d\u0438\u0435', title:'\u0421\u0433\u0435\u043d\u0435\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043f\u0440\u0435\u0434\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u043a\u043b\u0438\u0435\u043d\u0442\u0443', hint:'\u041e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e \u0442\u043e\u043b\u044c\u043a\u043e \u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e \u0433\u043e\u0441\u0442\u0435\u0439. \u0411\u044e\u0434\u0436\u0435\u0442 \u0438 \u0432\u0435\u0441 \u043c\u043e\u0436\u043d\u043e \u043d\u0435 \u0437\u0430\u043f\u043e\u043b\u043d\u044f\u0442\u044c.', guests:'\u041a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e \u0433\u043e\u0441\u0442\u0435\u0439', budget:'\u0411\u044e\u0434\u0436\u0435\u0442 \u043d\u0430 \u0433\u043e\u0441\u0442\u044f, \u20bd', weight:'\u0412\u0435\u0441 \u043d\u0430 \u0433\u043e\u0441\u0442\u044f, \u0433', optional:'\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e', close:'\u0417\u0430\u043a\u0440\u044b\u0442\u044c', regen:'\u041f\u0435\u0440\u0435\u0433\u0435\u043d\u0435\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c', apply:'\u041f\u0440\u0438\u043c\u0435\u043d\u0438\u0442\u044c \u043a \u0437\u0430\u043a\u0430\u0437\u0443', total:'\u041c\u0435\u043d\u044e',per:'\u041d\u0430 \u0433\u043e\u0441\u0442\u044f',weightPer:'\u0412\u0435\u0441 / \u0433\u043e\u0441\u0442\u044f',piecesPer:'\u0417\u0430\u043a\u0443\u0441\u043e\u043a / \u0433\u043e\u0441\u0442\u044f', empty:'\u0423\u043a\u0430\u0436\u0438\u0442\u0435 \u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e \u0433\u043e\u0441\u0442\u0435\u0439 \u0438 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u00ab\u0421\u0433\u0435\u043d\u0435\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u00bb.', lowBudget:'\u0417\u0430\u0434\u0430\u043d\u043d\u044b\u0439 \u0431\u044e\u0434\u0436\u0435\u0442 \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u043d\u0438\u0437\u043a\u0438\u0439 \u0434\u043b\u044f \u043f\u043e\u0434\u0431\u043e\u0440\u0430 \u043c\u0435\u043d\u044e.', applied:'\u041f\u043e\u0434\u043e\u0431\u0440\u0430\u043d\u043d\u043e\u0435 \u043c\u0435\u043d\u044e \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 \u0437\u0430\u043a\u0430\u0437. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u0435\u0433\u043e \u0438 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u00ab\u0421\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c\u00bb.', noEvent:'\u2014 \u041d\u0435 \u0443\u043a\u0430\u0437\u044b\u0432\u0430\u0442\u044c \u2014',custom:'\u0421\u0432\u043e\u0451\u2026',customPh:'\u041d\u0430\u043f\u0438\u0448\u0438\u0442\u0435 \u0441\u0432\u043e\u0439 \u0444\u043e\u0440\u043c\u0430\u0442' }; const toast=(m,t='success')=>{try{window.SunEnterprise?.toast?.(m,t)}catch(_){};}; const getBoxes=()=>{try{if(Array.isArray(boxes))return boxes}catch(_){}try{return JSON.parse(localStorage.getItem('sunBoxes')||'[]')}catch(_){return[]}}; const getOrders=()=>{try{if(Array.isArray(orders))return orders}catch(_){}try{return JSON.parse(localStorage.getItem('sunOrders')||'[]')}catch(_){return[]}}; const getDraft=()=>{try{return draft}catch(_){return null}}; const parseWeight=v=>{const s=String(v||'').toLowerCase().replace(/,/g,'.'),m=s.match(/(\d+(?:\.\d+)?)\s*(\u043a\u0433|kg|\u0433|g)(?:\s|$)/);if(!m)return 0;const n=Number(m[1]);return m[2]==='\u043a\u0433'||m[2]==='kg'?n*1000:n}; const guestMax=v=>{const a=String(v||'').match(/\d+/g)?.map(Number)||[];return a.length?Math.max(...a):0}; function portions(item){ const explicit=Math.max(0,Number(item?.pieces||0));if(explicit>0)return explicit; let sum=0; for(const x of item?.ingredients||[]){if(!Array.isArray(x))continue;const q=Math.max(0,Number(x[1]||0)),u=String(x[2]||'').toLowerCase();if(/\u0448\u0442|\u043f\u043e\u0440|\u043a\u0443\u0441/.test(u))sum+=q;} if(sum>=5)return sum; return Math.max(sum,guestMax(item?.guests),10); } function popularityMap(){ const stats=new Map();let maxOrders=0,maxQty=0; for(const o of getOrders()){ if(String(o?.status||'').toLowerCase()==='отменён')continue; const seen=new Set(); for(const l of o?.lines||[]){const id=String(l?.id??'');if(!id)continue;let st=stats.get(id);if(!st){st={orders:0,qty:0};stats.set(id,st)}st.qty+=Math.max(0,Number(l?.qty||0));if(!seen.has(id)){st.orders+=1;seen.add(id)}} } for(const st of stats.values()){maxOrders=Math.max(maxOrders,st.orders);maxQty=Math.max(maxQty,st.qty)} const out=new Map();for(const [id,st] of stats)out.set(id,(maxOrders?st.orders/maxOrders:0)*.78+(maxQty?st.qty/maxQty:0)*.22);return out; } function foodCandidates(){const pop=popularityMap();return getBoxes().filter(x=>Number(x?.category||0)===0&&x?.hidden!==true&&Number(x?.price||0)>0&&parseWeight(x?.weight)>0&&!/\u0433\u043e\u0442\u043e\u0432\u044b\u0435\s+\u043c\u0435\u043d\u044e/i.test(String(x?.catalogSection||''))).map(x=>({raw:x,id:String(x.id),name:String(x.name||''),section:String(x.catalogSection||'\u0411\u043e\u043a\u0441\u044b'),price:Number(x.price||0),grams:parseWeight(x.weight),portions:portions(x),photo:String(x.photo||(window.CateriumBranding.documentImage())),popularity:Number(pop.get(String(x.id))||0)}));} function hashNoise(text,seed){let h=2166136261^(seed*2654435761);for(const c of String(text)){h^=c.charCodeAt(0);h=Math.imul(h,16777619)}return ((h>>>0)%10000)/10000;} function stateKey(s){return [...s.q.entries()].sort((a,b)=>a[0].localeCompare(b[0])).map(([id,q])=>id+':'+q).join('|')} function score(s,t,seed){ const pg=s.portions/t.targetPortions,pp=pg<1?(1-pg)*5:(pg-1)*1.15; let wp=0;if(t.targetWeight){const r=s.grams/t.targetWeight;wp=r<1?(1-r)*4:(r-1)*1.1;} let bp=0;if(t.budget){if(s.cost>t.budget)return 100+(s.cost-t.budget)/t.budget*20;bp=Math.max(0,(t.budget-s.cost)/t.budget)*.72}else bp=(s.cost/Math.max(1,t.guests*5000))*.04; const boxes=[...s.q.values()].reduce((a,b)=>a+b,0),unique=s.q.size,sections=s.sections.size; const minPositions=t.guests>=15?4:(t.guests>=8?3:2);const diversity=(boxes-unique)*.08+Math.max(0,Math.min(boxes,4)-sections)*.055-Math.min(sections,4)*.025+Math.max(0,minPositions-unique)*.24; const popularityBonus=boxes?Math.min(.22,(Number(s.popularity||0)/boxes)*.18):0; return pp+wp+bp+diversity-popularityBonus+hashNoise(stateKey(s),seed)*.012; } function generateMenu(t,seed){ const cand=foodCandidates();if(!cand.length)return null; const empty={q:new Map(),portions:0,grams:0,cost:0,sections:new Set(),popularity:0}; let beam=[empty],all=[empty]; const maxBoxes=Math.min(30,Math.max(4,Math.ceil(t.targetPortions/20)+4,t.targetWeight?Math.ceil(t.targetWeight/700)+4:0)); for(let step=0;step=4)continue; const cost=st.cost+c.price;if(t.budget&&cost>t.budget)continue; const q=new Map(st.q);q.set(c.id,old+1);const sections=new Set(st.sections);sections.add(c.section); const ns={q,portions:st.portions+c.portions,grams:st.grams+c.grams,cost,sections,popularity:Number(st.popularity||0)+Number(c.popularity||0)};const key=stateKey(ns),prev=map.get(key); if(!prev||score(ns,t,seed)score(a,t,seed)-score(b,t,seed)).slice(0,130);beam=next;all.push(...next.slice(0,60)); const best=next[0];if(best&&best.portions>=t.targetPortions*1.05&&(!t.targetWeight||best.grams>=t.targetWeight*1.03)&&step>=3)break; } const useful=all.filter(s=>s.q.size>0&&( !t.budget || s.cost<=t.budget));if(!useful.length)return null; const best=useful.sort((a,b)=>score(a,t,seed)-score(b,t,seed))[0]; const items=[...best.q.entries()].map(([id,qty])=>{const c=cand.find(x=>x.id===id);return {...c,qty,sum:c.price*qty,totalGrams:c.grams*qty,totalPortions:c.portions*qty}}).sort((a,b)=>a.section.localeCompare(b.section,'ru')||a.name.localeCompare(b.name,'ru')); return {...best,items}; } function ensureStyle(){if($('sun-auto-offer-style'))return;const st=document.createElement('style');st.id='sun-auto-offer-style';st.textContent=` #sunAutoOfferModal{z-index:100060}#sunAutoOfferModal .dialog{width:min(980px,calc(100vw - 28px));max-height:94vh;padding:0;overflow:auto;border-radius:18px;background:#f5f3fa}.sun-auto-head{position:sticky;top:0;z-index:3;display:flex;align-items:flex-start;gap:12px;padding:15px 17px;background:#fff;border-bottom:1px solid #e4e0ef}.sun-auto-head h2{margin:0;color:#241d45;font-size:20px}.sun-auto-head p{margin:4px 0 0;color:#7b748d;font-size:12px}.sun-auto-head .spacer{flex:1}.sun-auto-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;padding:16px 17px 5px}.sun-auto-form label{display:grid;gap:6px;color:#5d5670;font-size:11px;font-weight:800}.sun-auto-form small{font-weight:500;color:#9b94aa}.sun-auto-form input{width:100%;border:1px solid #d9d2e9;border-radius:11px;padding:10px 11px;background:#fff;color:#29243d}.sun-auto-generate{margin:10px 17px 2px}.sun-auto-result{padding:12px 17px 18px}.sun-auto-placeholder{padding:30px;text-align:center;border:1px dashed #cbc2df;border-radius:16px;color:#827a95;background:#fff}.sun-auto-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:9px;margin-bottom:12px}.sun-auto-kpi{background:#241d45;color:#fff;border-radius:14px;padding:13px}.sun-auto-kpi small{display:block;color:#c9c2db;font-size:10px}.sun-auto-kpi b{display:block;margin-top:5px;font-size:18px}.sun-auto-warning{margin:0 0 12px;padding:10px 12px;border-radius:11px;background:#fff4d9;color:#77550c;font-size:12px}.sun-auto-items{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}.sun-auto-item{display:grid;grid-template-columns:82px minmax(0,1fr);gap:10px;padding:9px;background:#fff;border:1px solid #e1dceb;border-radius:14px}.sun-auto-item img{width:82px;height:82px;object-fit:cover;border-radius:10px;background:#eeeaf7}.sun-auto-item h3{margin:2px 0 5px;color:#302a45;font-size:13px;line-height:1.25}.sun-auto-item p{margin:3px 0;color:#827b92;font-size:10px}.sun-auto-item b{color:#5b48a6}.sun-auto-foot{display:flex;justify-content:flex-end;gap:8px;padding:13px 17px;background:#fff;border-top:1px solid #e4e0ef;position:sticky;bottom:0}.sun-event-custom-input{display:none!important;margin-top:6px!important}.sun-event-custom-input.on{display:block!important}#sunOrderCards .sun-order-card.sun-has-prepayment .sun-order-card-date{background:linear-gradient(145deg,#238b57,#36ad72)!important;border-color:#238b57!important;box-shadow:0 5px 14px rgba(35,139,87,.24)!important} @media(max-width:700px){#sunAutoOfferModal{padding:0}#sunAutoOfferModal .dialog{width:100vw;height:100vh;max-height:none;border-radius:0}.sun-auto-form{grid-template-columns:1fr}.sun-auto-summary{grid-template-columns:1fr 1fr}.sun-auto-items{grid-template-columns:1fr}.sun-auto-head h2{font-size:17px}} `;document.head.appendChild(st)} let generationSeed=1,currentResult=null,currentTargets=null; function ensureModal(){if($('sunAutoOfferModal'))return;const m=document.createElement('div');m.className='modal';m.id='sunAutoOfferModal';m.innerHTML=`

    ${txt.title}

    ${txt.hint}

    ${txt.empty}
    `;document.body.appendChild(m);$('sunAutoClose').onclick=closeGenerator;$('sunAutoGenerate').onclick=()=>runGenerator(false);$('sunAutoRegen').onclick=()=>runGenerator(true);$('sunAutoApply').onclick=applyGenerated;} function openGenerator(){ensureStyle();ensureModal();const d=getDraft()||{},g=Number($('eventGuests')?.value||d.guestsCount||0);$('sunAutoGuests').value=g||'';$('sunAutoResult').innerHTML=`
    ${txt.empty}
    `;$('sunAutoFoot').style.display='none';currentResult=null;$('sunAutoOfferModal').classList.add('on');} function closeGenerator(){$('sunAutoOfferModal')?.classList.remove('on')} function runGenerator(regen){ const guests=Math.max(0,Math.round(Number($('sunAutoGuests')?.value||0))),perBudget=Math.max(0,Number($('sunAutoBudget')?.value||0)),perWeight=Math.max(0,Number($('sunAutoWeight')?.value||0)); if(!guests){toast(txt.guests,'warn');$('sunAutoGuests')?.focus();return} if(regen)generationSeed++;const targets={guests,targetPortions:guests*5,targetWeight:perWeight?guests*perWeight:0,budget:perBudget?guests*perBudget:0,perBudget,perWeight};const result=generateMenu(targets,generationSeed);currentTargets=targets;currentResult=result;renderGenerated(result,targets); } function renderGenerated(r,t){const root=$('sunAutoResult'),foot=$('sunAutoFoot');if(!r){root.innerHTML=`
    ${txt.lowBudget}
    `;foot.style.display='none';return} const pp=r.cost/t.guests,wp=r.grams/t.guests,pieces=r.portions/t.guests;const warns=[];if(r.portions
    ${txt.total}${money(r.cost)}
    ${txt.per}${money(pp)}
    ${txt.weightPer}${Math.round(wp).toLocaleString('ru-RU')} \u0433
    ${txt.piecesPer}${pieces.toFixed(1)}
    ${warns.length?`
    ${warns.join('
    ')}
    `:''}
    ${r.items.map(i=>`

    ${esc(i.name)}

    ${esc(i.section)} \u00b7 \u00d7${i.qty}

    ${money(i.sum)} \u00b7 ${Math.round(i.totalGrams).toLocaleString('ru-RU')} \u0433 \u00b7 ${Math.round(i.totalPortions)} \u043f\u043e\u0440\u0446.

    `).join('')}
    `;foot.style.display='flex';} function applyGenerated(){if(!currentResult||!currentTargets)return;const d=getDraft();if(!d)return;d.lines=currentResult.items.map(i=>({id:i.id,qty:i.qty}));d.guestsCount=currentTargets.guests;const g=$('eventGuests');if(g){g.value=currentTargets.guests;g.dispatchEvent(new Event('input',{bubbles:true}))}try{window.render?.();window.updateOrderSummary?.()}catch(_){}closeGenerator();toast(txt.applied,'success');} function ensureGeneratorButton(){const actions=document.querySelector('#new .actions');if(!actions)return;let b=$('sunAutoOfferButton');if(!b){b=document.createElement('button');b.id='sunAutoOfferButton';b.type='button';b.className='outline';b.textContent=txt.gen;b.onclick=openGenerator;const offer=$('sunClientOfferButton');if(offer)offer.insertAdjacentElement('afterend',b);else actions.appendChild(b)}} function decoratePaidCards(){const map=new Map(getOrders().map(o=>[String(o.id),o]));qa('#sunOrderCards .sun-order-card').forEach(card=>{const o=map.get(String(card.dataset.orderId||''));card.classList.toggle('sun-has-prepayment',Number(o?.prepayment||0)>0)})} function ensureEventOptions(){ const s=$('event');if(!s||s.tagName!=='SELECT')return;const keep=String(s.value||''); qa('option',s).filter(o=>(o.textContent||'').trim()==='Новое мероприятие'||o.dataset.sunCustom==='1').forEach(o=>o.remove()); $('sunEventCustomInput')?.remove(); let blank=qa('option',s).find(o=>o.dataset.sunBlank==='1'||o.value===''); if(!blank){blank=document.createElement('option');blank.value='';blank.textContent=txt.noEvent;blank.dataset.sunBlank='1';s.insertBefore(blank,s.firstChild)}else{blank.value='';if(blank.textContent!==txt.noEvent)blank.textContent=txt.noEvent;blank.dataset.sunBlank='1';if(s.firstElementChild!==blank)s.insertBefore(blank,s.firstChild)} const opts=qa('option',s);s.value=opts.some(o=>o.value===keep)?keep:''; if(s.dataset.sunPersistentEventBound!=='1'){s.dataset.sunPersistentEventBound='1';s.addEventListener('change',()=>{const d=getDraft();if(d)d.event=s.value||''})} } function syncEventFromDraft(){ensureEventOptions();const s=$('event'),d=getDraft();if(!s||!d)return;const value=String(d.event||'').trim();if(!value){s.value='';return}let opt=qa('option',s).find(o=>o.value===value);if(!opt){opt=document.createElement('option');opt.value=value;opt.textContent=value;opt.dataset.sunLegacy='1';s.appendChild(opt)}s.value=value;} function prepareEventForSave(){ensureEventOptions();const s=$('event'),d=getDraft();if(s&&d)d.event=s.value||'';} const oldSave=window.saveOrder;if(typeof oldSave==='function')window.saveOrder=function(...a){prepareEventForSave();const r=oldSave.apply(this,a);setTimeout(()=>{syncEventFromDraft();decoratePaidCards()},0);return r}; const oldOpen=window.openOrder;if(typeof oldOpen==='function')window.openOrder=function(...a){const r=oldOpen.apply(this,a);setTimeout(syncEventFromDraft,0);setTimeout(syncEventFromDraft,80);return r}; const oldReset=window.resetDraft;if(typeof oldReset==='function')window.resetDraft=function(...a){const r=oldReset.apply(this,a);setTimeout(syncEventFromDraft,0);return r}; ensureStyle();ensureModal();ensureGeneratorButton();ensureEventOptions();syncEventFromDraft();decoratePaidCards(); (()=>{let queued=false;const run=()=>{if(queued)return;queued=true;setTimeout(()=>{queued=false;ensureGeneratorButton();ensureEventOptions();decoratePaidCards();},50);};[$('new'),$('orders')].filter(Boolean).forEach(root=>new MutationObserver(run).observe(root,{childList:true,subtree:true}));})(); setTimeout(()=>{ensureGeneratorButton();syncEventFromDraft();decoratePaidCards()},180);setTimeout(()=>{ensureGeneratorButton();syncEventFromDraft();decoratePaidCards()},700); })(); ; /* ===== MODULE: mobile-pwa.js ===== */ /* Solnce Catering classic PWA/sync layer - keeps the original application UI */ (()=>{ 'use strict'; if(window.__sunClassicPwaV1)return; window.__sunClassicPwaV1=true; const q=(s,r=document)=>r.querySelector(s); const qa=(s,r=document)=>[...r.querySelectorAll(s)]; const esc=window.SunSafe.escapeHTML; /* PWA only. No alternate mobile chrome or mobile home is created here. */ function initPwa(){ if('serviceWorker' in navigator && /^https?:$/.test(location.protocol)){ navigator.serviceWorker.register('./service-worker.js').then(reg=>reg.update()).catch(()=>{}); } } /* Cross-device sync. This is deliberately UI-neutral. */ const SYNC_META='sunCloudSyncMetaV1'; const SYNC_TOKEN='sunCloudSyncTokenV1'; const SYNC_WORKSPACE='sunCloudSyncWorkspaceV1'; const sync={busy:false,status:'local',timer:null,lastError:''}; const excludedKey=k=>k===SYNC_META||k===SYNC_TOKEN||k===SYNC_WORKSPACE||k.startsWith('sunCloudSyncTransient'); function hash(s){ s=String(s??''); let h=2166136261; for(let i=0;i>>0).toString(16); } function getMeta(){try{return JSON.parse(localStorage.getItem(SYNC_META)||'{}')||{}}catch(_){return{}}} function setMeta(m){localStorage.setItem(SYNC_META,JSON.stringify(m))} function syncWorkspace(){return localStorage.getItem(SYNC_WORKSPACE)||'main'} function syncHeaders(){return {'Content-Type':'application/json','X-Sun-Token':localStorage.getItem(SYNC_TOKEN)||''}} function captureSyncQuery(){ try{ const u=new URL(location.href); const token=u.searchParams.get('syncToken'); const workspace=u.searchParams.get('workspace'); let changed=false; if(token){localStorage.setItem(SYNC_TOKEN,token);u.searchParams.delete('syncToken');changed=true} if(workspace){localStorage.setItem(SYNC_WORKSPACE,workspace);u.searchParams.delete('workspace');changed=true} if(changed)history.replaceState(null,'',u.pathname+u.search+u.hash); }catch(_){ } } function setSyncStatus(status,msg=''){ sync.status=status; sync.lastError=msg; const statusEl=q('#sunSyncSettingsStatus'); if(statusEl){ statusEl.textContent=status==='ok'?'Синхронизация активна':status==='error'?`Ошибка: ${msg||'нет связи'}`:'Локальный режим'; } } async function pullRemote(){ const token=localStorage.getItem(SYNC_TOKEN); if(!token||!/^https?:$/.test(location.protocol))return {entries:{}}; const r=await fetch(`/api/sync?workspace=${encodeURIComponent(syncWorkspace())}`,{headers:syncHeaders(),cache:'no-store'}); if(!r.ok)throw new Error(`HTTP ${r.status}`); return r.json(); } function scanLocalChanges(meta,force=false){ const changes={}; const now=Date.now(); for(let i=0;iv.id)||null} function refreshActiveView(){ const active=document.activeElement; const editing=active&&/^(INPUT|TEXTAREA|SELECT)$/.test(active.tagName); if(editing||qa('.modal.on').length)return; const view=activeView(); if(!view)return; const map={orders:'Заказы',calendar:'Календарь',shopping:'Закупки',stock:'Склад',clients:'Клиенты',mailings:'Рассылки',money:'Деньги',team:'Команда',suppliers:'Поставщики',stats:'Статистика','sun-print-center':'Печать','sun-routes-view':'Маршруты','enterprise-settings':'Настройки'}; const label=map[view.id]; if(!label)return; const button=qa('header nav button').find(b=>String(b.textContent||'').trim()===label||String(b.textContent||'').trim()===(label==='Закупки'?'Закупка':label)); if(button){try{HTMLElement.prototype.click.call(button)}catch(_){}} } async function syncNow(){ if(sync.busy)return; const token=localStorage.getItem(SYNC_TOKEN); if(!token||!/^https?:$/.test(location.protocol)){setSyncStatus('local');ensureSyncSettings();return} sync.busy=true; try{ const meta=getMeta(); const remote=await pullRemote(); let remoteChanged=false; const entries=remote.entries||{}; Object.entries(entries).forEach(([key,item])=>{ if(excludedKey(key)||!item)return; const rt=Number(item.updatedAt||0),lt=Number(meta[key]?.t||0); if(rt>lt){ localStorage.setItem(key,String(item.value??'')); meta[key]={h:hash(item.value??''),t:rt}; remoteChanged=true; } }); const changes=scanLocalChanges(meta,Object.keys(entries).length===0); setMeta(meta); await pushChanges(changes); setSyncStatus('ok'); ensureSyncSettings(); if(remoteChanged){ window.dispatchEvent(new CustomEvent('suncloudsync',{detail:{remoteChanged:true}})); refreshActiveView(); } }catch(e){ setSyncStatus('error',e?.message||'нет связи'); ensureSyncSettings(); }finally{sync.busy=false} } window.sunSyncNow=syncNow; function ensureSyncSettings(){ const settings=q('#enterprise-settings'); if(!settings||q('#sunSyncSettingsCard'))return; const card=document.createElement('section'); card.id='sunSyncSettingsCard'; card.className='settings-card'; card.style.cssText='margin-top:14px;border:1px solid #e2e4e9;border-radius:12px;background:#fff;padding:14px'; const token=localStorage.getItem(SYNC_TOKEN)||''; card.innerHTML=`

    Синхронизация устройств

    Одна база для компьютера и телефона. Интерфейс приложения остаётся одинаковым на всех устройствах.

    ${token?'Подключение…':'Локальный режим'}

    ${token?'':'

    Чтобы включить общую базу, запускайте приложение через server.js и открывайте на телефоне ссылку сервера.

    '}`; const audit=[...settings.children].find(el=>(el.querySelector?.(':scope > h2')?.textContent||'').trim()==='История изменений'); if(audit)window.SunSafe.insertBefore(settings,card,audit);else settings.appendChild(card); q('#sunSyncWorkspaceInput',card)?.addEventListener('change',e=>{localStorage.setItem(SYNC_WORKSPACE,String(e.target.value||'main').trim()||'main');syncNow()}); const now=q('#sunSyncNowBtn',card);if(now)now.onclick=()=>syncNow(); const forget=q('#sunSyncForgetBtn',card);if(forget)forget.onclick=()=>{if(confirm('Отключить синхронизацию на этом устройстве? Локальные данные останутся.')){localStorage.removeItem(SYNC_TOKEN);setSyncStatus('local');card.remove();ensureSyncSettings()}}; setSyncStatus(sync.status,sync.lastError); } function initSync(){ // Legacy same-Wi-Fi server sync is intentionally disabled in Cloud v2 builds. // Supabase synchronization is handled by cloud-sync-v2.js. setSyncStatus('local'); } function init(){ initPwa(); initSync(); document.documentElement.classList.add('sun-classic-mobile-ready'); } if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init,{once:true});else init(); })(); ; /* ===== MODULE: batch-update.js ===== */ (function(){ 'use strict'; if(window.__sunBatchUpdate20260828)return;window.__sunBatchUpdate20260828=true; const $=id=>document.getElementById(id); const qa=(sel,root=document)=>[...root.querySelectorAll(sel)]; const orderList=()=>{try{if(typeof orders!=='undefined'&&Array.isArray(orders))return orders}catch(_){}try{return JSON.parse(localStorage.getItem('sunOrders')||'[]')}catch(_){return[]}}; const localToday=()=>{const d=new Date();return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`}; const dateKey=o=>/^\d{4}-\d{2}-\d{2}$/.test(String(o?.date||''))?String(o.date):''; const timeKey=o=>/^\d{2}:\d{2}$/.test(String(o?.time||''))?String(o.time):'23:59'; let ordersMode='current'; function splitOrders(all){ const today=localToday(); const current=[],done=[]; all.forEach(o=>{const d=dateKey(o);(d&&d{const ad=dateKey(a)||'9999-12-31',bd=dateKey(b)||'9999-12-31';return (ad+timeKey(a)).localeCompare(bd+timeKey(b))||Number(a.id||0)-Number(b.id||0)}); done.sort((a,b)=>{const ad=dateKey(a)||'0000-00-00',bd=dateKey(b)||'0000-00-00';return (bd+timeKey(b)).localeCompare(ad+timeKey(a))||Number(b.id||0)-Number(a.id||0)}); return {current,done}; } function ensureOrdersTabs(){ const view=$('orders');if(!view)return; let bar=$('sunOrdersPeriodTabs'); if(!bar){bar=document.createElement('div');bar.id='sunOrdersPeriodTabs';bar.className='sun-orders-period-tabs';bar.innerHTML='';const anchor=view.querySelector('.sun-orders-viewbar')||view.querySelector('.head')||view.firstElementChild?.nextSibling;const h=view.querySelector('h1');if(h)h.insertAdjacentElement('afterend',bar);else view.prepend(bar);bar.addEventListener('click',e=>{const b=e.target.closest('[data-orders-period]');if(!b)return;ordersMode=b.dataset.ordersPeriod==='done'?'done':'current';window.renderOrders?.();});} const split=splitOrders(orderList()); qa('[data-orders-period]',bar).forEach(b=>{const key=b.dataset.ordersPeriod;b.classList.toggle('on',key===ordersMode);const text=key==='current'?`Текущие · ${split.current.length}`:`Завершённые · ${split.done.length}`;if(b.textContent!==text)b.textContent=text}); } const previousRenderOrders=window.renderOrders; if(typeof previousRenderOrders==='function')window.renderOrders=function(...args){ ensureOrdersTabs(); let ref=null,backup=null; try{if(typeof orders!=='undefined'&&Array.isArray(orders)){ref=orders;backup=orders.slice();const split=splitOrders(backup),show=ordersMode==='done'?split.done:split.current;orders.length=0;orders.push(...show)}}catch(_){} let result; try{result=previousRenderOrders.apply(this,args)}finally{if(ref&&backup){ref.length=0;ref.push(...backup)}} setTimeout(()=>{ensureOrdersTabs();compactSettingsLists();setDeliverySteps()},0); return result; }; let backupExpanded=false,auditExpanded=false; function compactList(host,rowSelector,kind){ if(!host)return;const rows=qa(rowSelector,host);const expanded=kind==='backup'?backupExpanded:auditExpanded; rows.forEach((r,i)=>{r.style.display=(expanded||i<4)?'':'none'}); let b=host.querySelector(':scope > .sun-compact-toggle'); if(rows.length<=4){b?.remove();return} if(!b){b=document.createElement('button');b.type='button';b.className='outline sun-compact-toggle';b.onclick=()=>{if(kind==='backup')backupExpanded=!backupExpanded;else auditExpanded=!auditExpanded;compactSettingsLists()};host.appendChild(b)} const text=expanded?'Скрыть':`Показать ещё (${rows.length-4})`;if(b.textContent!==text)b.textContent=text; } function compactSettingsLists(){compactList($('backupList'),'.backup-row','backup');compactList($('auditList'),'.audit-row','audit')} function setDeliverySteps(){ const delivery=$('deliveryCost');if(delivery)delivery.step='100'; const transport=$('orderDeliveryCost');if(transport)transport.step='100'; } function ensureCss(){if($('sun-batch-update-style'))return;const st=document.createElement('style');st.id='sun-batch-update-style';st.textContent=` .sun-orders-period-tabs{display:flex;gap:7px;align-items:center;margin:10px 0 13px;padding:4px;width:max-content;max-width:100%;border-radius:12px;background:#f1eff8;border:1px solid #e1dced}.sun-orders-period-tabs button{border:0;background:transparent;color:#716a84;border-radius:9px;padding:7px 12px;font-size:11px;font-weight:800;cursor:pointer}.sun-orders-period-tabs button.on{background:#241d45;color:#fff;box-shadow:0 4px 12px rgba(36,29,69,.18)}.sun-compact-toggle{margin-top:9px!important}.sun-extra-services-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:8px}.sun-extra-service{display:flex;gap:10px;align-items:flex-start;padding:12px 14px;border:1px solid #d9d2e9;border-radius:14px;background:#fff;color:#4e4961}.sun-extra-service:before{content:'✓';display:grid;place-items:center;flex:0 0 24px;height:24px;border-radius:8px;background:#241d45;color:#f2c85f;font-weight:900}.sun-extra-service b{display:block;color:#241d45;font-size:14px}.sun-extra-service small{display:block;color:#817a94;margin-top:2px;line-height:1.3}@media(max-width:620px){.sun-orders-period-tabs{width:100%}.sun-orders-period-tabs button{flex:1}.sun-extra-services-grid{grid-template-columns:1fr}} `;document.head.appendChild(st)} function maintain(){ensureCss();ensureOrdersTabs();compactSettingsLists();setDeliverySteps()} (()=>{let queued=false;const run=()=>{if(queued)return;queued=true;setTimeout(()=>{queued=false;maintain();},60);};[$('new'),$('orders'),$('enterprise-settings')].filter(Boolean).forEach(root=>new MutationObserver(run).observe(root,{childList:true,subtree:true}));})(); document.addEventListener('click',e=>{const nav=e.target.closest('header nav button');if(nav&&String(nav.dataset.navLabel||nav.textContent||'').trim()==='Настройки')setTimeout(compactSettingsLists,80)},true); maintain();setTimeout(maintain,200);setTimeout(maintain,900); })(); ; /* ===== MODULE: cloud-sync-v2.js ===== */ (() => { 'use strict'; if (window.SunCloudV2) return; const VERSION = '2026.09.05-cloud-rbac-v7-developer-support'; const CONFIG_KEY = 'sunCloudV2Config'; const CLIENT_ID_KEY = 'sunCloudClientIdV2'; const DB_NAME = 'SunCloudV2'; const DB_VERSION = 1; const BUCKET = 'sun-media'; const SUPABASE_JS = 'https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2.112.4'; const MEDIA_PREFIX = '__sun_media__:'; const SYNC_DEBOUNCE = 1800; const $ = id => document.getElementById(id); const esc=window.SunSafe.escapeHTML; const clone = value => value == null ? value : (typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value))); const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); const toast = (text, type='info', timeout=4500) => { try { if (window.SunEnterprise?.toast) return window.SunEnterprise.toast(text, type, timeout); } catch (_) {} console[type === 'error' ? 'error' : 'log']('[SunCloud]', text); }; const DEFAULT_SUPABASE_URL = 'https://usfjwhztqoopzzfmfbis.supabase.co'; const DEFAULT_SUPABASE_KEY = 'sb_publishable_CAxfhMKrduJjuk_5ybCQLg_TqSGWGoy'; const SUPABASE_API_PROXY = 'https://api.caterium.ru'; const PROXY_FETCH_TIMEOUT_MS = 12000; const supabaseProxyFetch=window.CateriumCloudTransport.create({upstream:DEFAULT_SUPABASE_URL,proxy:SUPABASE_API_PROXY,timeout:PROXY_FETCH_TIMEOUT_MS}); let config = loadConfig(); let client = null; let clientSignature = ''; let session = null; let authLoading = true; let memberships = []; let workspace = null; let supportMode = null; let membershipsLoading = false; let membershipsLoaded = false; let membershipError = ''; let membershipLoad = null; let realtimeChannel = null; let syncTimer = null; let networkRetryTimer = null; let isSyncing = false; let dirty = false; const CLOUD_RPC_TIMEOUT_MS=45000; const CLOUD_CONFLICT_MAX_RETRIES=4; function errText(e,fallback='Неизвестная ошибка'){ if(e==null)return fallback; if(typeof e==='string')return e||fallback; if(e.message)return String(e.message); try{const j=JSON.stringify(e);if(j&&j!=='{}'&&j!=='null')return j;}catch(_){} return fallback; } async function sunCloudAwait(promise,label='Облачный запрос',timeout=CLOUD_RPC_TIMEOUT_MS){ let timer=0; try{return await Promise.race([Promise.resolve(promise),new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error(label+': превышено время ожидания ('+Math.round(timeout/1000)+' сек.)')),timeout)})]);} finally{if(timer)clearTimeout(timer)} } let suppressStorageTracking = false; let observedSignature = ''; let lastStatus = 'local'; let lastError = ''; function loadConfig() { let value = {}; try { value = JSON.parse(localStorage.getItem(CONFIG_KEY) || '{}') || {}; } catch (_) {} if (!value.url || !value.key) { try { const legacy = JSON.parse(localStorage.getItem('sunEnterpriseSettingsV1') || '{}') || {}; const cloud = legacy.cloud || {}; value.url ||= String(cloud.supabaseUrl || '').trim(); value.key ||= String(cloud.anonKey || '').trim(); } catch (_) {} } // Move the retired Caterium backend to the fresh project once. Preserve the // old workspace identity so switchTenantLocal archives its data separately. if (/^https:\/\/(?:cksuehzcimitsxmeloes\.supabase\.co|api\.caterium\.ru)\/?$/i.test(String(value.url||'')) || value.key==='sb_publishable_v8Z3hEBnu7zsDwAb5KCWcg_T96IejsM') { try { localStorage.setItem('cateriumRetiredBackend20260917',JSON.stringify(value)); } catch (_) {} value.legacyLocalWorkspaceId ||= value.localWorkspaceId || value.workspaceId || 'retired-caterium'; value.url=DEFAULT_SUPABASE_URL; value.key=DEFAULT_SUPABASE_KEY; value.workspaceId=''; value.migrated={}; value.lastSync=''; try { localStorage.setItem(CONFIG_KEY,JSON.stringify(value)); } catch (_) {} } return { url: String(value.url || DEFAULT_SUPABASE_URL || '').trim().replace(/\/$/, ''), key: String(value.key || DEFAULT_SUPABASE_KEY || '').trim(), autoSync: value.autoSync !== false, workspaceId: String(value.workspaceId || ''), migrated: value.migrated && typeof value.migrated === 'object' ? value.migrated : {}, lastSync: String(value.lastSync || ''), localWorkspaceId: String(value.localWorkspaceId || ''), tenantStorageReady: value.tenantStorageReady === true, legacyLocalWorkspaceId: String(value.legacyLocalWorkspaceId || (value.tenantStorageReady === true ? '' : (value.workspaceId || ''))) }; } function saveConfig() { localStorage.setItem(CONFIG_KEY, JSON.stringify(config)); } function disableLegacyCloud() { try { const raw = localStorage.getItem('sunEnterpriseSettingsV1'); if (!raw) return; const data = JSON.parse(raw); if (!data || typeof data !== 'object') return; data.cloud = Object.assign({}, data.cloud || {}, {enabled:false, autoSync:false, lastSync:data.cloud?.lastSync || ''}); localStorage.setItem('sunEnterpriseSettingsV1', JSON.stringify(data)); } catch (_) {} } function clientId() { let id = localStorage.getItem(CLIENT_ID_KEY); if (!id) { id = crypto.randomUUID ? crypto.randomUUID() : `client-${Date.now()}-${Math.random().toString(36).slice(2)}`; localStorage.setItem(CLIENT_ID_KEY, id); } return id; } function workspaceMigrated() { return Boolean(workspace?.id && config.migrated?.[workspace.id]); } const ROLE_LABELS = {admin:'Администратор',manager:'Менеджер',kitchen:'Кухня',courier:'Курьер',viewer:'Только просмотр'}; function hasPermission(permission) { if (!workspace || workspace.is_active === false) return false; const value=workspace.permissions?.[permission]; if (typeof value === 'boolean') return value; return workspace.role === 'admin'; } function canWrite() { if (supportMode) return false; if (!workspace || workspace.is_active === false) return false; const permissions=workspace.permissions || {}; const entries=Object.entries(permissions); if (entries.length) return entries.some(([key,value]) => value === true && (key.endsWith('.edit') || key.endsWith('.create') || key.endsWith('.delete') || key === 'users.manage' || key === 'backups.manage')); return workspace.role === 'admin'; } function roleLabel(role) { return ROLE_LABELS[role] || role || '—'; } function shouldSyncKey(key) { key = String(key || ''); if (!key.startsWith('sun')) return false; if (key === CONFIG_KEY || key === CLIENT_ID_KEY) return false; if (key === 'sunUsersV1' || key === 'sunSessionUserV1') return false; if (key === 'sunFallbackBackupsV1') return false; if (key === 'sunStaticMapGeocodeCacheV2') return false; if (key === 'sunCloudSyncMetaV1' || key === 'sunCloudSyncTokenV1' || key === 'sunCloudSyncWorkspaceV1') return false; if (key === 'sunOrdersView' || key === 'sunCalendarMode' || key === 'sunCatalogViewModeV1' || key === 'sunNewOrderSplitRatioV5') return false; if (key.startsWith('sunRecoveryCorrupt.')) return false; return true; } function sanitizeEnterpriseSettings(raw) { try { const data = JSON.parse(raw); if (!data || typeof data !== 'object') return raw; data.rolesEnabled = false; if (data.cloud) data.cloud = {enabled:false,supabaseUrl:'',anonKey:'',workspaceId:'',autoSync:false,lastSync:''}; return JSON.stringify(data); } catch (_) { return raw; } } function openDb() { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains('meta')) db.createObjectStore('meta', {keyPath:'key'}); if (!db.objectStoreNames.contains('media')) db.createObjectStore('media', {keyPath:'path'}); }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } async function idbGet(store, key) { try { const db = await openDb(); return await new Promise((resolve, reject) => { const request = db.transaction(store, 'readonly').objectStore(store).get(key); request.onsuccess = () => resolve(request.result || null); request.onerror = () => reject(request.error); }); } catch (_) { return null; } } async function idbPut(store, value) { try { const db = await openDb(); await new Promise((resolve, reject) => { const tx = db.transaction(store, 'readwrite'); tx.objectStore(store).put(value); tx.oncomplete = resolve; tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error || new Error('IndexedDB transaction aborted')); }); return true; } catch (_) { return false; } } // Keep the browser working copy isolated by cloud workspace. The live app still // reads the historical unprefixed sun* keys, so before changing workspace we // snapshot those keys to IndexedDB, clear them, and restore only the target // workspace. This prevents orders/clients/finance/etc. from leaking between // accounts while avoiding a second large copy inside localStorage. function isTenantLocalKey(key) { key=String(key||''); if(key==='cateriumClientsV1772'||key==='cateriumClientsServerV1773')return true; if(!key.startsWith('sun'))return false; if(key===CONFIG_KEY||key===CLIENT_ID_KEY)return false; if(key.startsWith('sunCloud'))return false; if(key==='sunStaticMapGeocodeCacheV2')return false; return true; } function tenantLocalKeys() { const out=[]; for(let i=0;ilocalStorage.removeItem(key)); } async function restoreTenantLocal(workspaceId) { const id=String(workspaceId||'').trim(); if(!id)return false; const row=await idbGet('meta',`tenant-local:${id}`),storage=row?.value?.storage; if(!storage||typeof storage!=='object')return false; for(const [key,value] of Object.entries(storage)){ if(isTenantLocalKey(key))localStorage.setItem(key,String(value??'')); } return true; } async function switchTenantLocal(nextWorkspaceId) { const next=String(nextWorkspaceId||'').trim(); // One-time upgrade adoption. If the old config already named a workspace, // current local data belongs to that legacy workspace only. This prevents a // signed-out old database from being accidentally attached to a different // account on the first login after upgrading. if(!config.tenantStorageReady){ const legacy=String(config.legacyLocalWorkspaceId||'').trim(); if(!legacy){ if(!next)return {changed:false,restored:false}; // Never attach an old anonymous browser database to the next account. // Preserve it privately for recovery, then pull the selected company. if(tenantLocalKeys().length)await captureTenantLocal('unassigned-local-recovery'); clearTenantLocal(); config.tenantStorageReady=true;config.localWorkspaceId=next;config.legacyLocalWorkspaceId=''; const restored=next?await restoreTenantLocal(next):false; if(next&&!restored)config.migrated[next]=false; saveConfig(); return {changed:Boolean(next),restored}; } if(legacy&&legacy!==next){ await captureTenantLocal(legacy); clearTenantLocal(); config.tenantStorageReady=true; config.localWorkspaceId=next; config.legacyLocalWorkspaceId=''; let restored=false; if(next)restored=await restoreTenantLocal(next); if(next&&!restored)config.migrated[next]=false; saveConfig(); return {changed:true,restored,legacyCaptured:true}; } if(next){ config.tenantStorageReady=true; config.localWorkspaceId=next; config.legacyLocalWorkspaceId=''; saveConfig(); await captureTenantLocal(next); return {changed:false,restored:true,adopted:true}; } // No signed-in company is available yet. return {changed:false,restored:false}; } const prev=String(config.localWorkspaceId||'').trim(); if(prev===next)return {changed:false,restored:Boolean(next)}; if(prev)await captureTenantLocal(prev); clearTenantLocal(); config.localWorkspaceId=next; let restored=false; if(next)restored=await restoreTenantLocal(next); // If this device has no saved working copy for the target workspace, force a // clean cloud pull on reload instead of merging an empty browser state. if(next&&!restored)config.migrated[next]=false; saveConfig(); return {changed:true,restored}; } async function adoptCurrentLocalAsWorkspace(workspaceId) { const id=String(workspaceId||'').trim(); if(!id)return false; config.tenantStorageReady=true; config.localWorkspaceId=id; config.legacyLocalWorkspaceId=''; config.workspaceId=id; config.migrated[id]=false; saveConfig(); await captureTenantLocal(id); return true; } function seedOfficialCatalogForFreshWorkspace(){clearTenantLocal();localStorage.setItem('sunBoxes','[]');localStorage.setItem('sunOrders','[]');} let signOutInProgress=false; function clearLocalSupabaseSessionFallback() { // Supabase normally clears its own persisted auth token. If the network call // is blocked or hangs, remove only this project's auth keys so the user can // still leave the account on the current device without touching app data. let projectRef=''; try{projectRef=new URL(config.url).hostname.split('.')[0]||'';}catch(_){} const prefixes=projectRef?[`sb-${projectRef}-auth-token`]:[]; for(const storage of [localStorage,sessionStorage]){ try{ const remove=[]; for(let i=0;ikey===prefix||key.startsWith(prefix+'.')||key.startsWith(prefix+'-')))remove.push(key); } remove.forEach(key=>storage.removeItem(key)); }catch(_){} } } function setSignOutUiBusy(busy) { const selectors=['#sunCloudSignOutV2','#sunLogoutBtn','#sunGateSignOutV3','#sunCloudUsePasswordV3','[data-signout]','[data-saas-signout]']; document.querySelectorAll(selectors.join(',')).forEach(btn=>{ if(!(btn instanceof HTMLButtonElement))return; if(busy){btn.dataset.sunOldText=btn.textContent||'';btn.disabled=true;btn.textContent='Выходим…';} else{btn.disabled=false;if(btn.dataset.sunOldText){btn.textContent=btn.dataset.sunOldText;delete btn.dataset.sunOldText;}} }); } async function signOutAndReload() { window.CateriumBranding?.resetSidebar(); if(signOutInProgress)return; signOutInProgress=true; setSignOutUiBusy(true); clearTimeout(syncTimer);clearTimeout(networkRetryTimer); setStatus('syncing','Выходим из аккаунта…'); try{ // Preserve the company working copy first. Do not block logout on a cloud // sync: the workspace snapshot remains available and will sync after the // next login if there were unsent changes. const snapshot=Promise.resolve().then(async()=>{if(supportMode){clearTenantLocal();supportMode=null;config.localWorkspaceId='';saveConfig();return {changed:true};}return switchTenantLocal('');}); await Promise.race([snapshot,sleep(2200).then(()=>{throw new Error('Tenant snapshot timeout');})]); }catch(error){ console.warn('[SunCloud] tenant snapshot before sign-out failed',error); try{clearTenantLocal();config.localWorkspaceId='';saveConfig();}catch(_){} } try{ if(client?.auth){ const result=await Promise.race([ client.auth.signOut({scope:'local'}), sleep(2500).then(()=>{throw new Error('Supabase local sign-out timeout');}) ]); if(result?.error)throw result.error; }else clearLocalSupabaseSessionFallback(); }catch(error){ console.warn('[SunCloud] Supabase sign-out fallback',error); try{client?.auth.stopAutoRefresh?.();}catch(_){} clearLocalSupabaseSessionFallback(); } session=null;workspace=null;memberships=[];membershipsLoading=false;membershipsLoaded=true;unsubscribeRealtime(); try{window.dispatchEvent(new CustomEvent('sun:cloud-permissions-changed'));}catch(_){} // Reload regardless of network state so a blocked logout endpoint can never // leave the user trapped in the previous account screen. location.reload(); } async function getBaseline() { if (!workspace?.id) return null; const row = await idbGet('meta', `baseline:${workspace.id}`); return row?.value || null; } async function setBaseline(row) { if (!workspace?.id || !row) return; await idbPut('meta', {key:`baseline:${workspace.id}`, value:{payload:clone(row.payload),revision:Number(row.revision || 0),updated_at:row.updated_at || '',client_id:row.client_id || ''}}); } function stable(value) { if (value === undefined) return '__undefined__'; if (value === null || typeof value !== 'object') return JSON.stringify(value); if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`; return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${stable(value[k])}`).join(',')}}`; } function equal(a, b) { return stable(a) === stable(b); } function isPlainObject(value) { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); } function idArray(value) { return Array.isArray(value) && value.every(item => isPlainObject(item) && item.id != null); } function mergeById(base, local, remote, conflicts, path) { const bm = new Map((base || []).map(x => [String(x.id), x])); const lm = new Map((local || []).map(x => [String(x.id), x])); const rm = new Map((remote || []).map(x => [String(x.id), x])); const order = []; [...(remote || []), ...(local || []), ...(base || [])].forEach(x => { const id=String(x.id); if (!order.includes(id)) order.push(id); }); const out = []; for (const id of order) { const b = bm.get(id), l = lm.get(id), r = rm.get(id); if (l === undefined && r === undefined) continue; if (l === undefined) { if (b !== undefined && !equal(r,b)) conflicts.push(`${path}[${id}]: удалено локально, изменено в облаке`); if (b === undefined || !equal(r,b)) out.push(clone(r)); continue; } if (r === undefined) { if (b !== undefined && !equal(l,b)) { conflicts.push(`${path}[${id}]: изменено локально, удалено в облаке`); out.push(clone(l)); } else if (b === undefined) out.push(clone(l)); continue; } out.push(mergeNode(b, l, r, conflicts, `${path}[${id}]`)); } return out; } function mergeNode(base, local, remote, conflicts, path='state') { if (equal(local, remote)) return clone(local); if (equal(local, base)) return clone(remote); if (equal(remote, base)) return clone(local); if (idArray(local) && idArray(remote) && (!base || idArray(base))) { return mergeById(Array.isArray(base)?base:[], local, remote, conflicts, path); } if (isPlainObject(local) && isPlainObject(remote) && (base == null || isPlainObject(base))) { const out = {}; const keys = new Set([...Object.keys(base || {}), ...Object.keys(local), ...Object.keys(remote)]); for (const key of keys) { const b = base?.[key], l = local[key], r = remote[key]; if (!(key in local) && !(key in remote)) continue; if (!(key in local)) { if (b !== undefined && !equal(r,b)) conflicts.push(`${path}.${key}: удалено локально, изменено в облаке`); if (b === undefined || !equal(r,b)) out[key] = clone(r); continue; } if (!(key in remote)) { if (b !== undefined && !equal(l,b)) { conflicts.push(`${path}.${key}: изменено локально, удалено в облаке`); out[key]=clone(l); } else if (b === undefined) out[key]=clone(l); continue; } out[key] = mergeNode(b, l, r, conflicts, `${path}.${key}`); } return out; } conflicts.push(path); return clone(local); } function mergePayloads(basePayload, localPayload, remotePayload) { const conflicts = []; const base = basePayload?.storage || {}; const local = localPayload?.storage || {}; const remote = remotePayload?.storage || {}; const merged = {}; const keys = new Set([...Object.keys(base), ...Object.keys(local), ...Object.keys(remote)]); for (const key of keys) { const b = base[key], l = local[key], r = remote[key]; if (l === undefined && r === undefined) continue; if (l === undefined) { if (b !== undefined && !equal(r,b)) conflicts.push(key); if (b === undefined || !equal(r,b)) merged[key] = clone(r); continue; } if (r === undefined) { if (b !== undefined && !equal(l,b)) { conflicts.push(key); merged[key] = clone(l); } else if (b === undefined) merged[key] = clone(l); continue; } merged[key] = mergeNode(b, l, r, conflicts, key); } return {payload:{format:'sun-cloud-v2',version:2,storage:merged}, conflicts:[...new Set(conflicts)]}; } function dataUrlToBlob(dataUrl) { const match = /^data:([^;,]+)?(;base64)?,(.*)$/s.exec(dataUrl || ''); if (!match) throw new Error('Invalid data URL'); const mime = match[1] || 'application/octet-stream'; const binary = match[2] ? atob(match[3]) : decodeURIComponent(match[3]); const bytes = new Uint8Array(binary.length); for (let i=0;i { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result || '')); reader.onerror = () => reject(reader.error || new Error('FileReader error')); reader.readAsDataURL(blob); }); } async function shrinkImageIfNeeded(dataUrl, blob) { if (blob.size <= 12 * 1024 * 1024) return blob; const image = new Image(); image.src = dataUrl; await new Promise((resolve, reject) => { image.onload=resolve; image.onerror=reject; }); const max = 2400; const scale = Math.min(1, max / Math.max(image.naturalWidth || 1, image.naturalHeight || 1)); const canvas = document.createElement('canvas'); canvas.width = Math.max(1, Math.round((image.naturalWidth || 1) * scale)); canvas.height = Math.max(1, Math.round((image.naturalHeight || 1) * scale)); const ctx = canvas.getContext('2d'); ctx.fillStyle = '#fff'; ctx.fillRect(0,0,canvas.width,canvas.height); ctx.drawImage(image,0,0,canvas.width,canvas.height); return await new Promise(resolve => canvas.toBlob(value => resolve(value || blob), 'image/jpeg', .9)); } async function sha256(blob) { const digest = await crypto.subtle.digest('SHA-256', await blob.arrayBuffer()); return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2,'0')).join(''); } function extensionFor(type) { return ({'image/jpeg':'jpg','image/png':'png','image/webp':'webp','image/gif':'gif'})[String(type||'').toLowerCase()] || 'bin'; } async function uploadDataUrl(dataUrl) { if (!client || !workspace?.id) return dataUrl; let blob = dataUrlToBlob(dataUrl); blob = await shrinkImageIfNeeded(dataUrl, blob); const hash = await sha256(blob); const ext = extensionFor(blob.type); const path = `${workspace.id}/media/${hash.slice(0,2)}/${hash}.${ext}`; const {error} = await client.storage.from(BUCKET).upload(path, blob, {upsert:false,contentType:blob.type || 'application/octet-stream',cacheControl:'31536000'}); if (error && !/exist|duplicate|409/i.test(`${error.message || ''} ${error.statusCode || ''}`)) throw error; await idbPut('media', {path, blob, cachedAt:new Date().toISOString()}); return MEDIA_PREFIX + path; } async function encodeMedia(value) { if (typeof value === 'string') { if (/^data:image\//i.test(value)) return await uploadDataUrl(value); return value; } if (Array.isArray(value)) { const out = []; for (const item of value) out.push(await encodeMedia(item)); return out; } if (isPlainObject(value)) { const out = {}; for (const [key,item] of Object.entries(value)) out[key] = await encodeMedia(item); return out; } return value; } async function downloadMedia(path) { const cached = await idbGet('media', path); if (cached?.blob) return await blobToDataUrl(cached.blob); const {data, error} = await client.storage.from(BUCKET).download(path); if (error) throw error; await idbPut('media', {path, blob:data, cachedAt:new Date().toISOString()}); return await blobToDataUrl(data); } async function decodeMedia(value) { if (typeof value === 'string' && value.startsWith(MEDIA_PREFIX)) return await downloadMedia(value.slice(MEDIA_PREFIX.length)); if (Array.isArray(value)) { const out=[]; for (const item of value) out.push(await decodeMedia(item)); return out; } if (isPlainObject(value)) { const out={}; for (const [key,item] of Object.entries(value)) out[key]=await decodeMedia(item); return out; } return value; } function quotaExceeded(error) {return error?.name==='QuotaExceededError'||/quota|exceeded/i.test(String(error?.message||error||''));} function compactLocalImageData(dataUrl,{maxSide=900,maxChars=130000,quality=.78,force=false}={}) { return new Promise(resolve => { if (!/^data:image\//i.test(String(dataUrl || ''))) return resolve(dataUrl); const image = new Image(); image.onerror = () => resolve(dataUrl); image.onload = () => { const iw=Math.max(1,image.naturalWidth||image.width||1), ih=Math.max(1,image.naturalHeight||image.height||1); if (!force && String(dataUrl).length<=maxChars && Math.max(iw,ih)<=maxSide) return resolve(dataUrl); let scale=Math.min(1,maxSide/Math.max(iw,ih)), q=quality, best=dataUrl; for (let attempt=0; attempt<5; attempt++) { const canvas=document.createElement('canvas'); canvas.width=Math.max(1,Math.round(iw*scale)); canvas.height=Math.max(1,Math.round(ih*scale)); const ctx=canvas.getContext('2d'); if(!ctx) break; ctx.fillStyle='#fff'; ctx.fillRect(0,0,canvas.width,canvas.height); ctx.drawImage(image,0,0,canvas.width,canvas.height); try { best=canvas.toDataURL('image/jpeg',q)||best; } catch (_) { break; } if (best.length<=maxChars) break; scale*=.78; q=Math.max(.52,q-.07); } resolve(best); }; image.src=dataUrl; }); } async function compactSunBoxesForStorage(value, options={}) { if (!Array.isArray(value)) return value; const out=[]; for (const item of value) { if (!isPlainObject(item)) { out.push(item); continue; } const copy={...item}; if (typeof copy.photo==='string' && /^data:image\//i.test(copy.photo)) copy.photo=await compactLocalImageData(copy.photo,options); out.push(copy); } return out; } async function collectLocalPayload() { const storage = {}; for (let i=0;itrue) { if(!current())throw new DOMException('Компания сменилась.','AbortError'); if (!payload?.storage || typeof payload.storage !== 'object') throw new Error('Некорректный формат облачной базы.'); suppressStorageTracking = true; try { if (replace) { const remoteKeys = new Set(Object.keys(payload.storage)); currentSyncableKeys().forEach(key => { if (!remoteKeys.has(key)) localStorage.removeItem(key); }); } for (const [key,entry] of Object.entries(payload.storage)) { if (!shouldSyncKey(key) || !entry) continue; let decoded = await decodeMedia(entry.v); if (key === 'sunBoxes' && entry.t === 'j') decoded = await compactSunBoxesForStorage(decoded,{maxSide:900,maxChars:130000,quality:.78}); if(!current())throw new DOMException('Компания сменилась.','AbortError'); let raw = entry.t === 'j' ? JSON.stringify(decoded) : String(decoded ?? ''); try { localStorage.setItem(key, raw); } catch (error) { if (key !== 'sunBoxes' || entry.t !== 'j' || !quotaExceeded(error)) throw error; decoded = await compactSunBoxesForStorage(decoded,{maxSide:640,maxChars:80000,quality:.66,force:true}); raw = JSON.stringify(decoded); try { localStorage.setItem(key, raw); } catch (retryError) { if (quotaExceeded(retryError)) throw new Error('Каталог с фотографиями все еще превышает локальный лимит браузера даже после оптимизации фото.'); throw retryError; } toast('Фотографии каталога оптимизированы, чтобы не превышать лимит браузера.','info',5200); } } } finally { suppressStorageTracking = false; } refreshRuntime(); } function refreshRuntime() { try { if (typeof boxes !== 'undefined') boxes = JSON.parse(localStorage.getItem('sunBoxes') || '[]'); } catch (_) {} try { if (typeof orders !== 'undefined') orders = JSON.parse(localStorage.getItem('sunOrders') || '[]'); } catch (_) {} for (const name of ['render','renderOrders','renderStats','renderClients','renderStock','renderManager']) { try { if (typeof window[name] === 'function') window[name](); } catch (_) {} } try { window.dispatchEvent(new CustomEvent('sun:cloud-state-applied')); } catch (_) {} } function payloadEmpty(payload) { return !payload?.storage || Object.keys(payload.storage).length === 0; } async function fetchRemoteRow() { if (!client || !workspace?.id) return null; const {data,error} = await sunCloudAwait(client.rpc('sun_fetch_app_state',{p_workspace:workspace.id}),'Загрузка облачной базы'); if (error) throw error; return Array.isArray(data) ? (data[0] || null) : (data || null); } async function upsertPayload(payload, expectedRevision=null) { const args={p_workspace:workspace.id,p_payload:payload,p_client_id:clientId(),p_expected_revision:expectedRevision==null?null:Number(expectedRevision)}; const {data,error} = await sunCloudAwait(client.rpc('sun_save_app_state_v17',args),'Сохранение облачной базы'); if (error) throw error; return Array.isArray(data) ? data[0] : data; } async function backupBeforeCloud(reason) { try { await window.SunEnterprise?.createBackup?.(reason); } catch (_) {} } async function migrateLocalToCloud() { if (!canWrite()) return toast('Для переноса данных нужны права на изменение рабочей базы.','warn'); if (!navigator.onLine) return toast('Нет подключения к интернету. Локальные данные сохранены и никуда не пропадут.','warn'); setStatus('syncing','Создаю первую облачную копию…'); try { await backupBeforeCloud('Перед первым переносом в Supabase'); const remote = await fetchRemoteRow(); if (remote && !payloadEmpty(remote.payload)) { if (!confirm('В этой рабочей базе уже есть облачные данные. Заменить их текущими данными с этого устройства?')) { setStatus('ready','Перенос отменён.'); return; } } const localPayload = await collectLocalPayload(); const row = await upsertPayload(localPayload, remote?.revision ?? null); await setBaseline(row); config.migrated[workspace.id] = true; config.lastSync = row.updated_at || new Date().toISOString(); config.autoSync = true; saveConfig(); dirty=false; setStatus('ready','Все текущие данные перенесены в облако.'); toast('Данные и загруженные фотографии перенесены в Supabase. Автосинхронизация включена.','success',6500); subscribeRealtime(); try{window.dispatchEvent(new CustomEvent('sun:cloud-sync-complete',{detail:{row:clone(row),payload:clone(localPayload),kind:'migrate'}}));}catch(_){} renderCloudUI(); } catch (error) { handleError(error, 'Не удалось перенести данные в облако.'); } } async function pullRemote({replace=false,quiet=false}={}) { if (signOutInProgress || !client || !session || !workspace?.id) return; const startedUser=session.user.id,startedWorkspace=workspace.id; const current=()=>!signOutInProgress&&session?.user?.id===startedUser&&workspace?.id===startedWorkspace; setStatus('syncing','Загружаю облачные данные…'); try { const row = await fetchRemoteRow(); if(!current())return; if (!row || payloadEmpty(row.payload)) { setStatus('ready','В облаке пока нет данных.'); return; } if (!quiet) await backupBeforeCloud('Перед загрузкой данных из Supabase'); await applyPayload(row.payload, replace, current); if(!current())return; await setBaseline(row); if(!current())return; config.migrated[workspace.id] = true; config.lastSync = row.updated_at || new Date().toISOString(); saveConfig(); dirty=false; setStatus('ready','Облачные данные загружены.'); if (!quiet) toast('Данные загружены из облака.','success'); try{window.dispatchEvent(new CustomEvent('sun:cloud-sync-complete',{detail:{row:clone(row),payload:clone(row.payload),kind:'pull'}}));}catch(_){} renderCloudUI(); } catch (error) { if(current())handleError(error,'Не удалось загрузить облачные данные.'); } } async function syncNow({quiet=false,retryCount=0,networkRetry=0}={}) { if (signOutInProgress || isSyncing || !client || !session || !workspace?.id || !workspaceMigrated()) return; if (!navigator.onLine) { setStatus('offline','Офлайн — изменения ждут подключения.'); return; } clearTimeout(networkRetryTimer); const startedUser=session.user.id,startedWorkspace=workspace.id; const current=()=>!signOutInProgress&&session?.user?.id===startedUser&&workspace?.id===startedWorkspace; isSyncing = true; setStatus('syncing','Синхронизация…'); try { const [baseline, remote, localPayload] = await Promise.all([getBaseline(), fetchRemoteRow(), collectLocalPayload()]); if(!current())return; if (!remote || payloadEmpty(remote.payload)) { if (!canWrite()) throw new Error('В облаке нет базы, а у пользователя нет прав на её создание.'); const row = await upsertPayload(localPayload, remote?.revision ?? null); if(!current())return; await setBaseline(row); if(!current())return; dirty=false; config.lastSync=row.updated_at||new Date().toISOString();saveConfig(); setStatus('ready','Синхронизировано.'); if(!quiet)toast('Синхронизация завершена.','success'); return; } if (!baseline) { await applyPayload(remote.payload, true, current); if(!current())return; await setBaseline(remote); if(!current())return; dirty=false; config.lastSync=remote.updated_at||new Date().toISOString();saveConfig(); setStatus('ready','Подключена существующая облачная база.'); if(!quiet)toast('Загружена актуальная облачная база.','success'); return; } const merged = mergePayloads(baseline.payload, localPayload, remote.payload); let finalRow = remote; const changedVsRemote = !equal(merged.payload, remote.payload); const changedVsLocal = !equal(merged.payload, localPayload); if (changedVsRemote) { if (!canWrite()) { await applyPayload(remote.payload, true, current); finalRow = remote; } else { finalRow = await upsertPayload(merged.payload, remote.revision); if (changedVsLocal) await applyPayload(merged.payload, true, current); } } else if (changedVsLocal) { await applyPayload(remote.payload, true, current); } if(!current())return; await setBaseline(finalRow); if(!current())return; dirty=false; config.lastSync=finalRow.updated_at||new Date().toISOString();saveConfig(); setStatus('ready', merged.conflicts.length ? `Синхронизировано, объединено конфликтов: ${merged.conflicts.length}.` : 'Синхронизировано.'); if (merged.conflicts.length) toast(`Изменения с двух устройств объединены. Проверено конфликтов: ${merged.conflicts.length}.`,'warn',6500); else if (!quiet) toast('Синхронизация завершена.','success'); try{window.dispatchEvent(new CustomEvent('sun:cloud-sync-complete',{detail:{row:clone(finalRow),payload:clone(finalRow.payload),kind:'sync',conflicts:merged.conflicts.length}}));}catch(_){} window.SunStabilityV17?.markSynced?.(finalRow?.revision); renderCloudUI(); } catch (error) { if(!current())return; const msg=String(error?.message||''); if(msg.includes('SUN_CONFLICT')){ const nextRetry=Number(retryCount||0)+1; try{window.SunStabilityV17?.recordConflict?.(msg);}catch(_){} if(nextRetry>CLOUD_CONFLICT_MAX_RETRIES){handleError(error,'Не удалось синхронизировать после нескольких безопасных повторов.');} else{const delay=Math.min(5000,400*Math.pow(2,nextRetry-1));setStatus('pending','Облачная версия изменилась. Повтор '+nextRetry+'/'+CLOUD_CONFLICT_MAX_RETRIES+' через '+(Math.round(delay/100)/10)+' сек.…');setTimeout(()=>syncNow({quiet:true,retryCount:nextRetry}),delay);} }else if(window.CateriumCloudTransport.isTransient(error)&&networkRetry<3){ const delay=[2000,5000,15000][networkRetry]; setStatus('pending','Соединение прервалось. Изменения сохранены на устройстве; повторяю синхронизацию…'); // Re-read the remote revision and merge before any write. A timed-out // save may already have committed; never blindly resend its request. networkRetryTimer=setTimeout(()=>{if(current())syncNow({quiet:true,networkRetry:networkRetry+1})},delay); }else handleError(error,'Ошибка синхронизации.'); } finally { isSyncing=false; } } async function handleRealtimeRow(row) { if (!row || row.client_id === clientId() || isSyncing || !workspaceMigrated()) return; try { const baseline = await getBaseline(); if (!baseline) { await applyPayload(row.payload,true);await setBaseline(row);setStatus('ready','Получены данные с другого устройства.');return; } const localPayload = await collectLocalPayload(); const merged = mergePayloads(baseline.payload, localPayload, row.payload); await applyPayload(merged.payload, true); if (!equal(merged.payload,row.payload) && canWrite()) { const saved=await upsertPayload(merged.payload,row.revision);await setBaseline(saved); } else await setBaseline(row); dirty=false;config.lastSync=row.updated_at||new Date().toISOString();saveConfig(); setStatus('ready','Получены изменения с другого устройства.'); toast(merged.conflicts.length?'Получены облачные изменения; параллельные правки объединены.':'Данные обновлены с другого устройства.','success',3500); renderCloudUI(); } catch (error) { handleError(error,'Не удалось применить изменения с другого устройства.'); } } function scheduleSync(reason='change') { dirty = true; try{window.SunStabilityV17?.markPending?.(reason);}catch(_){} if (!config.autoSync || !workspaceMigrated() || !session || !workspace || !canWrite()) { updatePill(); return; } clearTimeout(syncTimer); syncTimer = setTimeout(() => syncNow({quiet:true}), SYNC_DEBOUNCE); if (!navigator.onLine) setStatus('offline','Офлайн — изменения сохранены локально.'); else setStatus('pending',`Изменения ожидают синхронизации (${reason}).`); } function storageSignature() { const parts=[]; for(let i=0;i>>0}`); } return parts.sort().join('|'); } function installStorageTracking() { if (Storage.prototype.__sunCloudV2Tracked) return; const nativeSet = Storage.prototype.setItem; const nativeRemove = Storage.prototype.removeItem; Object.defineProperty(Storage.prototype,'__sunCloudV2Tracked',{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&&!suppressStorageTracking&&shouldSyncKey(key)&&old!==String(value))scheduleSync(key); return result; }; Storage.prototype.removeItem=function(key){ const had=this===localStorage&&this.getItem(key)!==null; const result=nativeRemove.call(this,key); if(this===localStorage&&!suppressStorageTracking&&had&&shouldSyncKey(key))scheduleSync(key); return result; }; observedSignature=storageSignature(); setInterval(()=>{ if(document.hidden||suppressStorageTracking)return; const next=storageSignature(); if(next!==observedSignature){observedSignature=next;scheduleSync('данные приложения');} },9000); ['sun:catalog-categories-changed','sun:client-offer-catalog-changed','sun:cloud-dirty'].forEach(name=>window.addEventListener(name,()=>scheduleSync(name))); } function setStatus(kind, text='') { lastStatus=kind;lastError=kind==='error'?text:''; const el=$('sunCloudV2Status'); if(el){el.textContent=text||statusText();el.dataset.kind=kind;} updatePill(); } function statusText() { if(lastStatus==='error')return lastError||'Ошибка облака'; if(!config.url||!config.key)return 'Облако не подключено'; if(!navigator.onLine)return 'Офлайн — работаем с локальной копией'; if(!session)return 'Нужно войти в облачный аккаунт'; if(supportMode)return `Режим поддержки · ${supportMode.name||'Компания'} · только просмотр`; if(!workspace)return 'Нужно выбрать или создать рабочую базу'; if(!workspaceMigrated())return 'Нужен первый перенос данных в облако'; if(lastStatus==='syncing')return 'Синхронизация…'; if(dirty)return 'Есть несинхронизированные изменения'; return config.lastSync?`Синхронизировано ${new Date(config.lastSync).toLocaleString('ru-RU')}`:'Облако готово'; } // Облачный статус больше не висит поверх всех разделов. // Управление облаком доступно только в «Настройки → Облако Supabase». function ensurePill() { const pill=$('sunCloudPillV2'); if(pill) pill.remove(); return null; } function updatePill() { ensurePill(); } function injectStyles() { if($('sunCloudV2Styles'))return; const style=document.createElement('style');style.id='sunCloudV2Styles';style.textContent=` .sun-cloud-v2-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.sun-cloud-v2-grid .wide{grid-column:1/-1}.sun-cloud-v2-box{border:1px solid #e0e4e6;border-radius:9px;padding:12px;background:#fbfcfc}.sun-cloud-v2-box h3{margin:0 0 8px;color:#15364c;font-size:14px}.sun-cloud-v2-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}.sun-cloud-v2-status{margin-top:10px;border-radius:8px;padding:9px 10px;background:#edf4f6;color:#365161;font-size:12px}.sun-cloud-v2-status[data-kind="error"]{background:#fff0ee;color:#943d3d}.sun-cloud-v2-status[data-kind="ready"]{background:#eef8f2;color:#347154}.sun-cloud-v2-status[data-kind="offline"]{background:#fff7e6;color:#8b6b25}.sun-cloud-v2-note{font-size:12px;color:#68717a;margin:6px 0}.sun-cloud-v2-user{font-weight:800;color:#15364c}.sun-cloud-v2-code{font:700 13px/1.2 ui-monospace,monospace;background:#f2f4f5;border-radius:7px;padding:8px;word-break:break-all}.sun-cloud-v2-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.sun-cloud-v2-row label{flex:1;min-width:170px}.sun-cloud-v2-small{font-size:11px;color:#7b8388} @media(max-width:760px){.sun-cloud-v2-grid{grid-template-columns:1fr}.sun-cloud-v2-grid .wide{grid-column:auto}} `;document.head.appendChild(style); } function findCloudCard() { const cards=[...document.querySelectorAll('#enterprise-settings .enterprise-card')]; return cards.find(card=>(card.querySelector('h2')?.textContent||'').trim()==='Облачная синхронизация') || $('sunCloudV2Card'); } function ensureCloudCard() { const card=findCloudCard();if(!card)return null; if(card.id!=='sunCloudV2Card'){card.id='sunCloudV2Card';card.classList.add('wide');renderCloudUI();} return card; } function renderCloudUI() { const card=findCloudCard();if(!card)return; card.id='sunCloudV2Card';card.classList.add('wide'); const connected=Boolean(config.url&&config.key); const signed=Boolean(session?.user); const selected=Boolean(workspace); card.innerHTML=`

    Профиль и аккаунт

    Вход, выход, рабочая компания и пользователи компании.

    ${connected&&!signed?`

    Войти в профиль

    `:''} ${!connected?`

    Подключение недоступно

    Техническое подключение настраивается только из кабинета разработчика.

    `:''} ${signed?`

    Профиль

    ${esc(session.user.email||session.user.id)}
    `:''} ${signed?`

    Пароль

    `:''} ${signed&&!selected?`

    Рабочая компания

    ${membershipsLoading?'Загружаю доступные рабочие базы…':'У этого аккаунта пока нет доступной рабочей базы.'}

    `:''} ${signed&&selected?`

    Рабочая компания

    ${esc(workspace.name)}${supportMode?'· поддержка, только просмотр':''}
    Роль: ${supportMode?'Разработчик · поддержка':esc(roleLabel(workspace.role))}${workspace.display_name?` · ${esc(workspace.display_name)}`:''}
    ${!supportMode&&memberships.length>1?``:''}
    `:''} ${signed&&selected&&!supportMode?`

    Синхронизация

    Принудительно загрузить актуальные заказы, клиентов и остальные данные с сервера, заменив локальную копию на этом устройстве. Перед загрузкой автоматически создаётся резервная копия.

    `:''} ${signed&&selected&&!supportMode&&hasPermission('users.manage')?`

    Пользователи

    Управление сотрудниками и правами внутри этой компании.

    `:''}
    ${esc(statusText())}
    `; bindCloudUI(); } function bindCloudUI() { $('sunCloudConnectV2')?.addEventListener('click', async()=>{ config.url=String($('sunCloudUrlV2')?.value||'').trim().replace(/\/$/,'');config.key=String($('sunCloudKeyV2')?.value||'').trim();saveConfig(); if(!config.url||!config.key)return toast('Введите Project URL и Publishable key.','warn'); client=null;session=null;workspace=null;memberships=[];await connectFromConfig(false);renderCloudUI(); }); $('sunCloudSignInV2')?.addEventListener('click',()=>signIn(false)); $('sunCloudSignUpV2')?.addEventListener('click',()=>signIn(true)); $('sunCloudSignOutV2')?.addEventListener('click',signOutAndReload); $('sunAccountChangePasswordV1')?.addEventListener('click',async()=>{ const p1i=$('sunAccountNewPasswordV1'),p2i=$('sunAccountNewPassword2V1'); const p1=String(p1i?.value||''),p2=String(p2i?.value||''); if(p1.length<8)return toast('Новый пароль должен содержать минимум 8 символов.','warn'); if(p1!==p2)return toast('Пароли не совпадают.','warn'); if(!client)return toast('Облачный сервис не подключён.','error'); try{const r=await client.auth.updateUser({password:p1});if(r.error)throw r.error;if(p1i)p1i.value='';if(p2i)p2i.value='';toast('Пароль изменён.','success');} catch(e){toast(String(e?.message||e||'Не удалось изменить пароль.'),'error');} }); $('sunCloudCreateWorkspaceV2')?.addEventListener('click',createWorkspace); $('sunCloudReloadMembershipsV2')?.addEventListener('click',async()=>{const tenantSwitched=await loadMemberships();if(tenantSwitched){location.reload();return;}renderCloudUI();if(workspace){subscribeRealtime();await pullRemote({replace:true,quiet:true});}}); $('sunCloudMigrateV2')?.addEventListener('click',migrateLocalToCloud); $('sunCloudSyncNowV2')?.addEventListener('click',()=>syncNow({quiet:false})); $('sunCloudPullV2')?.addEventListener('click',async()=>{if(confirm('Заменить локальную рабочую копию актуальными данными из облака? Перед загрузкой будет создана резервная копия.'))await pullRemote({replace:true,quiet:false});}); $('sunCloudAutoV2')?.addEventListener('change',e=>{config.autoSync=e.target.checked;saveConfig();if(config.autoSync)scheduleSync('автосинхронизация включена');renderCloudUI();}); $('sunCloudWorkspaceSelectV2')?.addEventListener('change',async e=>{const next=String(e.target.value||'');config.workspaceId=next;saveConfig();const switched=await switchTenantLocal(next);if(switched.changed){location.reload();return;}selectWorkspace();subscribeRealtime();renderCloudUI();if(workspaceMigrated())await syncNow({quiet:true});else await pullRemote({replace:true,quiet:true});}); $('sunCloudManageUsersV3')?.addEventListener('click',()=>window.dispatchEvent(new CustomEvent('sun:open-cloud-users'))); } async function loadSupabaseLibrary() { if(window.supabase?.createClient)return; const existing=$('sunSupabaseJsV2'); if(existing){for(let i=0;i<80&&!window.supabase?.createClient;i++)await sleep(100);if(window.supabase?.createClient)return;} await new Promise((resolve,reject)=>{ const script=document.createElement('script'),timer=setTimeout(()=>{script.remove();reject(new Error('Не удалось загрузить сервис входа. Обновите страницу и проверьте интернет.'))},12000);script.id='sunSupabaseJsV2';script.src=SUPABASE_JS;script.async=true;script.onload=()=>{clearTimeout(timer);resolve()};script.onerror=()=>{clearTimeout(timer);script.remove();reject(new Error('Не удалось загрузить библиотеку Supabase. Проверьте интернет.'))};document.head.appendChild(script); }); if(!window.supabase?.createClient)throw new Error('Supabase SDK не загрузился.'); } async function connectFromConfig(silent=true) { if(!config.url||!config.key){finishAuthRestore();setStatus('local','Облако не подключено.');return;} authLoading=true; setStatus('syncing','Подключаю Supabase…'); try{ await loadSupabaseLibrary(); const signature=`${config.url}|${config.key}`; if(!client||signature!==clientSignature){ client=window.supabase.createClient(config.url,config.key,{auth:{persistSession:true,autoRefreshToken:true,detectSessionInUrl:true},global:{fetch:supabaseProxyFetch}});clientSignature=signature; client.auth.onAuthStateChange((_event,next)=>{const changed=session?.user?.id!==next?.user?.id;if(changed)window.CateriumBranding?.resetSidebar();session=next;if(!changed&&(membershipsLoading||(membershipsLoaded&&!membershipError)))return;membershipsLoading=Boolean(next?.user);membershipsLoaded=!next?.user;setTimeout(async()=>{try{if(!next?.user){const sw=await switchTenantLocal('');if(sw.changed){location.reload();return;}}else{const sw=await loadMemberships();if(sw){location.reload();return;}}}finally{renderCloudUI();updatePill();}},0);}); } const {data,error}=await sunCloudAwait(client.auth.getSession(),'Проверка входа');if(error)throw error;session=data.session||null;finishAuthRestore(); const tenantSwitched=session?await loadMemberships():(await switchTenantLocal('')).changed; if(tenantSwitched){location.reload();return;} if(session&&workspace){ subscribeRealtime(); if(workspaceMigrated())await syncNow({quiet:true}); else { const remote=await fetchRemoteRow(); if(remote&&!payloadEmpty(remote.payload)){ await backupBeforeCloud('Перед автоматическим подключением к существующей облачной базе'); await applyPayload(remote.payload,true);await setBaseline(remote);config.migrated[workspace.id]=true;config.lastSync=remote.updated_at||new Date().toISOString();saveConfig();dirty=false;setStatus('ready','Подключена существующая облачная база.'); } else setStatus('setup','Готово к первому переносу данных.'); } } else if(session)setStatus('setup',membershipsLoading?'Загружаю рабочую базу…':'Для этого аккаунта рабочая база не найдена.'); else setStatus('auth','Подключено. Войдите в аккаунт.'); if(!silent)toast('Supabase подключён.','success'); }catch(error){handleError(error,'Не удалось подключиться к Supabase.');} finally{finishAuthRestore();renderCloudUI();} } function finishAuthRestore(){ if(!authLoading)return;authLoading=false; window.dispatchEvent(new CustomEvent('sun:cloud-permissions-changed')); } async function signIn(signUp) { const email=String($('sunCloudEmailV2')?.value||'').trim();const password=String($('sunCloudPasswordV2')?.value||''); if(!email||password.length<6)return toast('Введите email и пароль не короче 6 символов.','warn'); setStatus('syncing',signUp?'Создаю аккаунт…':'Выполняю вход…'); try{ const result=signUp?await client.auth.signUp({email,password,options:{data:{registration_source:'caterium_public_signup'}}}):await client.auth.signInWithPassword({email,password}); if(result.error)throw result.error;session=result.data.session||null; if(signUp&&!session){const retry=await client.auth.signInWithPassword({email,password});if(retry.error)throw retry.error;session=retry.data.session||null;} {const tenantSwitched=await loadMemberships();if(tenantSwitched){location.reload();return;}setStatus('setup','Вход выполнен.');toast('Вход выполнен.','success');} renderCloudUI(); }catch(error){handleError(error,signUp?'Не удалось создать аккаунт.':'Не удалось войти.');} } function loadMemberships() { const userId=session?.user?.id||''; if(membershipLoad?.userId===userId)return membershipLoad.promise; if(!client||!userId){memberships=[];workspace=null;membershipsLoading=false;membershipsLoaded=true;membershipError='';return Promise.resolve(false)} const task={userId,promise:null};membershipLoad=task; task.promise=runMembershipLoad(task).finally(()=>{if(membershipLoad!==task)return;membershipLoad=null;membershipsLoading=false;membershipsLoaded=true;try{window.dispatchEvent(new CustomEvent('sun:cloud-permissions-changed',{detail:{workspace:clone(workspace)}}))}catch(_){}}); return task.promise; } async function runMembershipLoad(task) { const activeClient=client,current=()=>membershipLoad===task&&client===activeClient&&session?.user?.id===task.userId; memberships=[];workspace=null;membershipsLoaded=false;membershipError=''; membershipsLoading=true; try{ let rows=[]; const rpc=await sunCloudAwait(client.rpc('sun_my_workspaces'),'Загрузка рабочей базы'); if(!rpc.error&&Array.isArray(rpc.data)){ rows=rpc.data.map(x=>({workspace_id:x.id,name:x.name,role:x.role,display_name:x.display_name,is_active:x.is_active,permissions:x.permissions})); }else if(rpc.error&&['PGRST202','42883'].includes(rpc.error.code)){ // Compatibility fallback for an older server schema. const direct=await sunCloudAwait(activeClient.from('sun_workspace_members').select('workspace_id,role,display_name,is_active,permissions').eq('user_id',task.userId),'Загрузка рабочей базы'); if(direct.error)throw rpc.error||direct.error; const active=(direct.data||[]).filter(x=>x.is_active!==false); if(active.length){ const ids=active.map(x=>x.workspace_id), names={}; const w=await sunCloudAwait(client.from('sun_workspaces').select('id,name').in('id',ids),'Загрузка рабочей базы');if(w.error)throw w.error;(w.data||[]).forEach(x=>names[x.id]=x.name); rows=active.map(x=>({...x,name:names[x.workspace_id]||'Рабочая база'})); } }else throw rpc.error||new Error('Не удалось прочитать список компаний. Повторите загрузку.'); if(!current())return false; memberships=rows.filter(x=>x.is_active!==false).map(x=>({id:x.workspace_id,name:x.name||'Рабочая база',role:x.role,display_name:x.display_name||'',is_active:x.is_active!==false,permissions:x.permissions||{}})); if(memberships.length&&!memberships.some(x=>x.id===config.workspaceId))config.workspaceId=memberships[0].id; if(!memberships.length)config.workspaceId=''; saveConfig();selectWorkspace(); const switched=await switchTenantLocal(workspace?.id||''); if(!current())return false; membershipsLoaded=true; if(switched.changed)return true; try{window.dispatchEvent(new CustomEvent('sun:cloud-permissions-changed',{detail:{workspace:clone(workspace),user:clone(session.user)}}));}catch(_){} return false; }catch(error){ if(!current())return false; membershipError=window.CateriumCloudTransport.errorMessage(error); setStatus('error',membershipError); return false; } } function selectWorkspace(){workspace=memberships.find(x=>x.id===config.workspaceId)||memberships[0]||null;if(workspace&&config.workspaceId!==workspace.id){config.workspaceId=workspace.id;saveConfig();}} async function createTrialWorkspace(companyName='Новая компания'){ if(!client||!session?.user)throw new Error('Сначала войдите в аккаунт.'); const name=String(companyName||'').trim()||'Новая компания'; await loadMemberships(); if(workspace)return clone(workspace); setStatus('syncing','Создаю компанию и включаю 14-дневный Полный тариф…'); const {data,error}=await client.rpc('sun_create_workspace',{p_name:name});if(error)throw error; const newId=String(data||'');if(!newId)throw new Error('Сервер не вернул ID новой компании.'); await switchTenantLocal(newId); await loadMemberships(); if(!workspace||String(workspace.id)!==newId){config.workspaceId=newId;saveConfig();selectWorkspace();} if(!workspace)throw new Error('Компания создана, но не появилась в списке доступных компаний.'); const remote=await fetchRemoteRow(); if(!remote?.payload?.storage)throw new Error('Компания создана, но её база не загрузилась. Повторите вход.'); // The server owns trial initialization. Pull it before saving the company // name, otherwise an empty browser catalog would erase the seeded recipes. await applyPayload(remote.payload,true); const profile=JSON.parse(localStorage.getItem('sunCompanyProfileV1')||'{}'); localStorage.setItem('sunCompanyProfileV1',JSON.stringify({...profile,name,createdAt:profile.createdAt||new Date().toISOString()})); await adoptCurrentLocalAsWorkspace(newId); const localPayload=await collectLocalPayload(); const row=await upsertPayload(localPayload,remote?.revision??null); await setBaseline(row); config.migrated[newId]=true;config.lastSync=row?.updated_at||new Date().toISOString();config.autoSync=true;saveConfig();dirty=false; try{await client.rpc('sun_v17_mirror_legacy',{p_workspace:newId,p_payload:localPayload,p_client_id:clientId()});}catch(_){} subscribeRealtime(); setStatus('ready','Компания создана · Полный тариф бесплатно 14 дней.'); renderCloudUI(); return clone(workspace); } async function createWorkspace(){ const name=String($('sunCloudWorkspaceNameV2')?.value||'Новая компания').trim()||'Новая компания'; try{await createTrialWorkspace(name);toast('Компания создана: Полный тариф на 14 дней, каталог готов, заказы и клиенты пустые.','success',7000);} catch(error){handleError(error,'Не удалось создать рабочую базу.');} } async function acceptInviteToken(rawToken){ const token=String(rawToken||'').trim();if(!token)throw new Error('Приглашение не найдено.'); if(!client||!session?.user)throw new Error('Сначала войдите в Caterium.'); setStatus('syncing','Подключаю компанию по приглашению…'); const {data,error}=await client.rpc('sun_accept_invite',{p_token:token});if(error)throw error; config.workspaceId=String(data||'');saveConfig(); await loadMemberships(); subscribeRealtime(); const remote=await fetchRemoteRow(); if(remote&&!payloadEmpty(remote.payload)){ await backupBeforeCloud('Перед подключением к компании по приглашению'); await applyPayload(remote.payload,true);await setBaseline(remote); config.migrated[workspace.id]=true;config.lastSync=remote.updated_at||new Date().toISOString();saveConfig(); } setStatus('ready','Вы присоединились к компании.');renderCloudUI(); try{window.dispatchEvent(new CustomEvent('sun:cloud-permissions-changed',{detail:{workspace:clone(workspace)}}));}catch(_){} return clone(workspace); } async function joinWorkspace(){ const token=String($('sunCloudInviteCodeV2')?.value||'').trim();if(!token)return toast('Введите код приглашения.','warn'); try{await acceptInviteToken(token);toast('Вы присоединились к компании.','success',6000);} catch(error){handleError(error,'Не удалось принять приглашение. Возможно, ссылка истекла или уже использована.');} } async function createInvite(){ const role=String($('sunCloudInviteRoleV2')?.value||'manager'); try{const {data,error}=await client.rpc('sun_create_invite',{p_workspace:workspace.id,p_role:role});if(error)throw error;const token=String(data||'');const host=$('sunCloudInviteResultV2');if(host)host.innerHTML=`

    Передайте сотруднику этот одноразовый код (действует 7 дней):

    ${esc(token)}
    `;$('sunCloudCopyInviteV2')?.addEventListener('click',async()=>{try{await navigator.clipboard.writeText(token);toast('Код скопирован.','success');}catch(_){toast('Скопируйте код вручную.','warn');}});} catch(error){handleError(error,'Не удалось создать приглашение.');} } function unsubscribeRealtime(){if(realtimeChannel&&client){try{client.removeChannel(realtimeChannel);}catch(_){}}realtimeChannel=null;} function subscribeRealtime(){ unsubscribeRealtime();if(!client||!session||!workspace?.id)return; realtimeChannel=client.channel(`sun-state-${workspace.id}-${clientId()}`).on('postgres_changes',{event:'INSERT',schema:'public',table:'sun_sync_events',filter:`workspace_id=eq.${workspace.id}`},async payload=>{ if(payload?.new?.client_id===clientId())return; try{const row=await fetchRemoteRow();if(row)await handleRealtimeRow(row);}catch(error){handleError(error,'Не удалось получить изменения с другого устройства.');} }).subscribe(status=>{if(status==='CHANNEL_ERROR')setStatus('error','Realtime не подключился. Обычная синхронизация продолжит работать.');}); } function handleError(error, message) { if(signOutInProgress)return; console.error('[SunCloudV2]',error);const detail=window.CateriumCloudTransport.errorMessage(error);setStatus('error',`${message} ${detail}`);toast(`${message} ${detail}`,'error',7500);renderCloudUI(); } function observeSettings() { const settingsRoot=$('enterprise-settings');if(settingsRoot){const observer=new MutationObserver(()=>{if(findCloudCard()&&!$('sunCloudV2Card'))ensureCloudCard();});observer.observe(settingsRoot,{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==='Настройки')setTimeout(()=>{ensureCloudCard();renderCloudUI();},80);},true); setTimeout(()=>{ensureCloudCard();renderCloudUI();},250); } function hookEnterpriseSchedule() { const timer=setInterval(()=>{if(window.SunEnterprise){window.SunEnterprise.scheduleCloudPush=()=>scheduleSync('сохранение приложения');clearInterval(timer);}},250); setTimeout(()=>clearInterval(timer),10000); } window.addEventListener('online',()=>{setStatus('pending','Интернет восстановлен. Синхронизирую…');if(workspaceMigrated())syncNow({quiet:true});}); window.addEventListener('offline',()=>setStatus('offline','Офлайн — изменения сохраняются на этом устройстве.')); async function enterSupportWorkspace(workspaceId,name='Компания') { const id=String(workspaceId||'').trim();if(!id)throw new Error('Не выбрана компания.'); if(!client||!session?.user)throw new Error('Сначала войдите в аккаунт разработчика.'); const admin=await sunCloudAwait(client.rpc('sun_is_platform_admin'),'Проверка доступа разработчика',8000);if(admin.error)throw admin.error;if(admin.data!==true)throw new Error('Требуется аккаунт разработчика.'); const previousWorkspaceId=String(config.workspaceId||''); const previousLocalWorkspaceId=String(config.localWorkspaceId||workspace?.id||''); if(previousLocalWorkspaceId)await captureTenantLocal(previousLocalWorkspaceId); clearTenantLocal();config.localWorkspaceId='';saveConfig();unsubscribeRealtime(); const result=await sunCloudAwait(client.rpc('sun_dev_support_snapshot',{p_workspace:id}),'Загрузка компании в режиме поддержки',12000);if(result.error)throw result.error; const row=Array.isArray(result.data)?result.data[0]:result.data;if(!row?.payload)throw new Error('У компании нет облачной рабочей копии.'); await applyPayload(row.payload,true); supportMode={workspaceId:id,name:String(name||'Компания'),previousWorkspaceId,previousLocalWorkspaceId,enteredAt:new Date().toISOString()}; workspace={id,name:String(name||'Компания'),role:'platform_support',display_name:'Разработчик · поддержка',is_active:true,permissions:{}}; dirty=false;setStatus('ready',`Режим поддержки · ${name||'Компания'} · только просмотр`); try{window.dispatchEvent(new CustomEvent('sun:cloud-permissions-changed',{detail:{workspace:clone(workspace),support:true}}));window.dispatchEvent(new CustomEvent('sun:cloud-state-applied',{detail:{workspaceId:id,support:true}}));}catch(_){} renderCloudUI();return clone(supportMode); } async function refreshSupportWorkspace(){ if(!supportMode||!client||!session?.user)return false; const id=String(supportMode.workspaceId||workspace?.id||'').trim();if(!id)return false; const result=await sunCloudAwait(client.rpc('sun_dev_support_snapshot',{p_workspace:id}),'Обновление данных компании',12000);if(result.error)throw result.error; const row=Array.isArray(result.data)?result.data[0]:result.data;if(!row?.payload)throw new Error('У компании нет облачной рабочей копии.'); await applyPayload(row.payload,true);dirty=false;config.lastSync=new Date().toISOString();saveConfig(); setStatus('ready','Режим поддержки · '+(supportMode.name||'Компания')+' · только просмотр'); try{window.dispatchEvent(new CustomEvent('sun:cloud-state-applied',{detail:{workspaceId:id,support:true,refresh:true}}));}catch(_){} renderCloudUI();return true; } async function exitSupportWorkspace() { if(!supportMode)return false; clearTenantLocal();supportMode=null;workspace=null;config.localWorkspaceId='';saveConfig(); const switched=await loadMemberships();renderCloudUI(); if(switched||workspace){location.reload();return true;} return true; } async function setTechnicalConnection(url,key) { const admin=client&&session?.user?await client.rpc('sun_is_platform_admin'):null; if(!admin||admin.error||admin.data!==true)throw new Error('Техническое подключение доступно только разработчику.'); config.url=String(url||'').trim().replace(/\/$/,'');config.key=String(key||'').trim();saveConfig(); if(!config.url||!config.key)throw new Error('Введите Project URL и Publishable key.'); client=null;clientSignature='';session=null;workspace=null;memberships=[];supportMode=null; await connectFromConfig(false);return true; } async function installTrialDemo(){ if(!client||!session||!workspace?.id||supportMode)throw new Error('Войдите в собственную рабочую компанию.'); if(isSyncing)throw new Error('Дождитесь завершения синхронизации и повторите.'); if(dirty){await syncNow({quiet:true});if(dirty)throw new Error('Сначала сохраните текущие изменения в облаке.');} const target=workspace.id; isSyncing=true;clearTimeout(syncTimer); try{ const result=await sunCloudAwait(client.rpc('caterium_install_trial_demo',{p_workspace:target}),'Загрузка демонстрационной базы'); if(result?.error)throw result.error; if(workspace?.id!==target)throw new Error('Компания сменилась. Откройте её заново.'); const row=await fetchRemoteRow(); if(row?.payload?.storage?.sunTrialDemoV1?.v?.version!==1)throw new Error('Не удалось получить демонстрационную базу. Повторите загрузку.'); await backupBeforeCloud('Перед загрузкой демонстрационной базы'); await applyPayload(row.payload,true);await setBaseline(row); config.migrated[target]=true;config.lastSync=row.updated_at||new Date().toISOString();saveConfig();dirty=false; setStatus('ready','Демонстрационная база загружена.'); window.dispatchEvent(new CustomEvent('sun:cloud-sync-complete',{detail:{row:clone(row),payload:clone(row.payload),kind:'pull'}})); return result.data; }finally{isSyncing=false;renderCloudUI();} } function setAutoSyncDeveloper(enabled){config.autoSync=enabled!==false;saveConfig();if(config.autoSync&&workspace&&!supportMode)scheduleSync('автосинхронизация включена');renderCloudUI();return config.autoSync;} window.SunCloudV2={ VERSION, signOut:signOutAndReload, adoptCurrentLocalAsWorkspace, createTrialWorkspace, installTrialDemo, acceptInvite:acceptInviteToken, syncNow:()=>syncNow({quiet:false}), pull:()=>pullRemote({replace:true,quiet:false}), migrate:migrateLocalToCloud, hasPermission, roleLabel, getClient:()=>client, getSession:()=>session?clone(session):null, getWorkspace:()=>workspace?clone(workspace):null, getClientId:()=>clientId(), enterSupportWorkspace, refreshSupportWorkspace, exitSupportWorkspace, isSupportMode:()=>Boolean(supportMode), getSupportMode:()=>supportMode?clone(supportMode):null, getTechnicalConfig:()=>({url:config.url,key:config.key,autoSync:config.autoSync,lastSync:config.lastSync,workspaceId:config.workspaceId,localWorkspaceId:config.localWorkspaceId}), setTechnicalConnection, setAutoSyncDeveloper, snapshotLocal:()=>collectLocalPayload(), reloadMemberships:async()=>{await loadMemberships();renderCloudUI();return workspace?clone(workspace):null;}, status:()=>({connected:Boolean(client),signedIn:Boolean(session),authLoading,supportMode:supportMode?clone(supportMode):null,workspace:workspace?clone(workspace):null,membershipsLoading,membershipsLoaded,membershipError,membershipCount:memberships.length,dirty,lastStatus,lastError}) }; async function boot(){ injectStyles();disableLegacyCloud();installStorageTracking();hookEnterpriseSchedule();observeSettings();updatePill(); if(config.url&&config.key)await connectFromConfig(true);else{finishAuthRestore();setStatus('local','Облако не подключено. Откройте Настройки → Облако Supabase.');} } if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot(); })(); ; /* ===== MODULE: admin-rbac-v3.js ===== */ (() => { 'use strict'; if (window.SunAdminRBACV3) return; const VERSION = '2026.09.06-admin-rbac-v7-granular-admins'; const $ = id => document.getElementById(id); const qa = (s,r=document) => [...r.querySelectorAll(s)]; const esc=window.SunSafe.escapeHTML; const toast = (text,type='info',timeout=4500) => { try { if (window.SunEnterprise?.toast) return window.SunEnterprise.toast(text,type,timeout); } catch (_) {} alert(text); }; const ROLE_LABELS = {admin:'Администратор',manager:'Менеджер',kitchen:'Кухня',courier:'Курьер',viewer:'Только просмотр'}; const GROUPS = [ {title:'Заказы', items:[['orders.view','Просмотр заказов'],['orders.create','Создание заказов'],['orders.edit','Изменение заказов'],['orders.delete','Удаление заказов'],['calendar.view','Календарь'],['map.view','Карта']]}, {title:'Клиенты и каталог', items:[['clients.view','Просмотр клиентов'],['clients.edit','Изменение клиентов'],['catalog.view','Просмотр каталога'],['catalog.edit','Изменение каталога']]}, {title:'Производство и доставка', items:[['production.view','Просмотр производства'],['production.edit','Изменение производства'],['shopping.view','Просмотр закупок'],['shopping.edit','Изменение закупок'],['stock.view','Просмотр склада'],['stock.edit','Изменение склада'],['routes.view','Просмотр маршрутов'],['routes.edit','Изменение маршрутов']]}, {title:'Управление', items:[['mailings.view','Просмотр рассылок'],['mailings.edit','Изменение рассылок'],['money.view','Просмотр денег'],['money.edit','Изменение денег'],['stats.view','Статистика'],['team.view','Просмотр команды'],['team.edit','Изменение команды'],['suppliers.view','Просмотр поставщиков'],['suppliers.edit','Изменение поставщиков'],['print.view','Печать']]}, {title:'Администрирование', items:[['settings.view','Просмотр настроек'],['settings.edit','Изменение настроек'],['users.manage','Пользователи и права'],['audit.view','История изменений'],['backups.manage','Резервные копии']]} ]; const PRESETS = { admin: Object.fromEntries(GROUPS.flatMap(g=>g.items).map(([k])=>[k,true])), manager: { 'orders.view':true,'orders.create':true,'orders.edit':true,'orders.delete':false,'calendar.view':true,'map.view':true, 'clients.view':true,'clients.edit':true,'catalog.view':true,'catalog.edit':false, 'production.view':false,'production.edit':false,'shopping.view':false,'shopping.edit':false,'stock.view':false,'stock.edit':false,'routes.view':true,'routes.edit':false, 'mailings.view':true,'mailings.edit':true,'money.view':false,'money.edit':false,'stats.view':true,'team.view':true,'team.edit':false,'suppliers.view':false,'suppliers.edit':false,'print.view':true, 'settings.view':false,'settings.edit':false,'users.manage':false,'audit.view':false,'backups.manage':false }, kitchen: { 'orders.view':true,'orders.create':false,'orders.edit':false,'orders.delete':false,'calendar.view':true,'map.view':false, 'clients.view':false,'clients.edit':false,'catalog.view':true,'catalog.edit':false, 'production.view':true,'production.edit':true,'shopping.view':true,'shopping.edit':false,'stock.view':true,'stock.edit':true,'routes.view':false,'routes.edit':false, 'mailings.view':false,'mailings.edit':false,'money.view':false,'money.edit':false,'stats.view':false,'team.view':true,'team.edit':false,'suppliers.view':false,'suppliers.edit':false,'print.view':true, 'settings.view':false,'settings.edit':false,'users.manage':false,'audit.view':false,'backups.manage':false }, courier: { 'orders.view':true,'orders.create':false,'orders.edit':false,'orders.delete':false,'calendar.view':true,'map.view':true, 'clients.view':false,'clients.edit':false,'catalog.view':false,'catalog.edit':false, 'production.view':false,'production.edit':false,'shopping.view':false,'shopping.edit':false,'stock.view':false,'stock.edit':false,'routes.view':true,'routes.edit':true, 'mailings.view':false,'mailings.edit':false,'money.view':false,'money.edit':false,'stats.view':false,'team.view':false,'team.edit':false,'suppliers.view':false,'suppliers.edit':false,'print.view':false, 'settings.view':false,'settings.edit':false,'users.manage':false,'audit.view':false,'backups.manage':false }, viewer: { 'orders.view':true,'orders.create':false,'orders.edit':false,'orders.delete':false,'calendar.view':true,'map.view':true, 'clients.view':true,'clients.edit':false,'catalog.view':true,'catalog.edit':false, 'production.view':false,'production.edit':false,'shopping.view':false,'shopping.edit':false,'stock.view':false,'stock.edit':false,'routes.view':false,'routes.edit':false, 'mailings.view':false,'mailings.edit':false,'money.view':false,'money.edit':false,'stats.view':true,'team.view':false,'team.edit':false,'suppliers.view':false,'suppliers.edit':false,'print.view':true, 'settings.view':false,'settings.edit':false,'users.manage':false,'audit.view':false,'backups.manage':false } }; let members = []; let editing = null; let inviting = false; let applying = false; let lastWorkspaceId = ''; function cloud(){ return window.SunCloudV2 || null; } function workspace(){ return cloud()?.getWorkspace?.() || null; } function session(){ return cloud()?.getSession?.() || null; } function client(){ return cloud()?.getClient?.() || null; } function has(p){ if(window.CateriumAccessPolicy?.emergencyLocalActive())return true; const c=cloud(); return c?.hasPermission ? c.hasPermission(p) : false; } function currentPermissions(){ return workspace()?.permissions || {}; } function normalizeLegacyLocalAuth(){ try{ const raw=localStorage.getItem('sunEnterpriseSettingsV1'); if(!raw)return; const s=JSON.parse(raw)||{}; if(s.rolesEnabled){s.rolesEnabled=false;localStorage.setItem('sunEnterpriseSettingsV1',JSON.stringify(s));} }catch(_){} } function installStyle(){ if($('sun-admin-rbac-style'))return; const style=document.createElement('style');style.id='sun-admin-rbac-style';style.textContent=` #sun-cloud-users-modal .dialog{max-width:980px;width:min(980px,calc(100vw - 24px));max-height:92vh;overflow:auto} .sun-rbac-toolbar{display:flex;gap:8px;align-items:center;justify-content:space-between;flex-wrap:wrap;margin:8px 0 12px} .sun-rbac-members{display:grid;gap:8px}.sun-rbac-member{display:grid;grid-template-columns:minmax(220px,1.4fr) minmax(150px,.7fr) auto;gap:10px;align-items:center;border:1px solid #dfe5e8;border-radius:10px;padding:10px 12px;background:#fff} .sun-rbac-member small{display:block;color:#75808a;margin-top:3px}.sun-rbac-off{opacity:.58}.sun-rbac-perm-groups{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:12px}.sun-rbac-group{border:1px solid #dfe5e8;border-radius:10px;padding:10px;background:#fafcfc}.sun-rbac-group h4{margin:0 0 8px;color:#17384d}.sun-rbac-check{display:flex!important;flex-direction:row!important;align-items:center!important;gap:8px!important;margin:6px 0!important}.sun-rbac-check input{width:17px!important;height:17px!important;flex:0 0 auto}.sun-rbac-role-line{display:grid;grid-template-columns:1.2fr 1fr auto;gap:10px;align-items:end}.sun-rbac-danger{margin-left:auto}.sun-rbac-section-title{display:flex;align-items:center;justify-content:space-between;gap:10px;margin:18px 0 8px}.sun-rbac-section-title h3{margin:0;color:#17384d;font-size:15px}.sun-rbac-invite{display:grid;grid-template-columns:minmax(190px,1.2fr) minmax(130px,.65fr) auto;gap:10px;align-items:center;border:1px dashed #cfd9df;border-radius:10px;padding:10px 12px;background:#fbfcfc}.sun-rbac-invite small{display:block;color:#75808a;margin-top:3px}.sun-rbac-invite-form{border:1px solid #dfe5e8;border-radius:12px;padding:14px;background:#fbfcfc}.sun-rbac-invite-grid{display:grid;grid-template-columns:1.2fr 1.5fr 1fr;gap:10px}.sun-rbac-linkbox{margin-top:12px;padding:10px;border-radius:9px;background:#f1f5f6;word-break:break-all;font:600 12px/1.45 ui-monospace,monospace}.sun-rbac-lock-note{font-size:12px;color:#6e7880;margin-top:6px}.sun-cloud-identity-v3{font-size:12px;color:#adc0cc;padding:3px 7px}.sun-cloud-pin-card{border-top:1px solid #e2e7e9;margin-top:10px;padding-top:10px} body.sun-cloud-auth-required .view,body.sun-cloud-auth-required header{filter:blur(2px);pointer-events:none;user-select:none}.sun-auth-password{position:relative}.sun-auth-password input{padding-right:46px}.sun-auth-eye{position:absolute;right:7px;bottom:6px;width:34px;height:34px;border:0;background:transparent;border-radius:8px;cursor:pointer;font-size:17px;color:#536674}.sun-auth-eye:hover{background:#eef2f3}.sun-auth-switch{margin:14px 0 0;text-align:center;font-size:13px}.sun-auth-switch button{border:0;background:transparent;color:#245c7c;font-weight:800;cursor:pointer;text-decoration:underline;text-underline-offset:3px}.sun-cloud-auth-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}.sun-cloud-auth-error{color:#9a3838;font-size:12px;margin-top:8px;min-height:18px}.sun-auth-mode-tabs{display:grid;grid-template-columns:1fr 1fr;gap:6px;background:#f2f4f5;border-radius:11px;padding:4px;margin:10px 0 14px}.sun-auth-mode-tabs button{border:0;border-radius:8px;padding:9px 10px;background:transparent;font-weight:800;cursor:pointer}.sun-auth-mode-tabs button.on{background:#fff;box-shadow:0 2px 10px #0001;color:#17384d}.sun-auth-trial{background:#eef8f2;color:#286447;border-radius:10px;padding:10px 11px;font-size:12px;font-weight:700;margin:10px 0}.sun-auth-success{background:#eef8f2;color:#286447;border-radius:10px;padding:10px 11px;font-size:12px;margin-top:10px} @media(max-width:760px){.sun-rbac-perm-groups{grid-template-columns:1fr}.sun-rbac-member,.sun-rbac-invite{grid-template-columns:1fr}.sun-rbac-role-line,.sun-rbac-invite-grid{grid-template-columns:1fr}} `;document.head.appendChild(style); } function navPermission(label){ const map={ 'Главная':'app.read','Новый заказ':'orders.create','Заказы':'orders.view','Календарь':'calendar.view','Карта':'map.view', 'Производство':'production.view','Закупки':'shopping.view','Склад':'stock.view','Клиенты':'clients.view','Рассылки':'mailings.view', 'Деньги':'money.view','Команда':'team.view','Поставщики':'suppliers.view','Статистика':'stats.view','Печать':'print.view','Маршруты':'routes.view','Настройки':'settings.view' }; return map[label] || null; } function canSeeSettings(){return has('settings.view')||has('users.manage')||has('backups.manage')||has('audit.view');} function applyNavPermissions(){ const ws=workspace(), ss=session(); if(!ws||!ss?.user)return; qa('header nav button').forEach(btn=>{ const label=String(btn.dataset.navLabel||btn.textContent||'').trim(); let allowed=true; if(label==='Настройки')allowed=canSeeSettings(); else {const perm=navPermission(label);if(perm)allowed=has(perm);} btn.hidden=!allowed;btn.style.display=allowed?'':'none'; }); const label=$('sunCurrentUserLabel'); if(label)label.textContent=`${ws.display_name||ss.user.email||'Пользователь'} · ${ROLE_LABELS[ws.role]||ws.role}`; const logout=$('sunLogoutBtn'); if(logout){logout.style.display='flex';logout.onclick=async()=>{if(window.SunCloudV2?.signOut)return window.SunCloudV2.signOut();try{await client()?.auth.signOut();}catch(_){}location.reload();};} applyActionPermissions(); purgeSensitiveLocalData(); } function applyActionPermissions(){ const del=$('deleteOrderBtn');if(del)del.style.display=(del.style.display==='none'&&!draftIdSafe())?'none':(has('orders.delete')?'inline-block':'none'); qa('#orders [data-sun-delete-order],#orders button').forEach(b=>{if(/удал/i.test(b.textContent||''))b.style.display=has('orders.delete')?'':'none';}); const save=qa('#new button').find(b=>(b.textContent||'').trim()==='Сохранить');if(save){const existing=draftIdSafe();save.disabled=existing?!has('orders.edit'):!has('orders.create');save.title=save.disabled?'Недостаточно прав':'';} qa('#new .catalog-head button').forEach(b=>{if((b.textContent||'').trim()==='Настроить')b.style.display=has('catalog.edit')?'':'none';}); qa('#manager .primary,#editor .primary,#editor .danger').forEach(b=>{b.disabled=!has('catalog.edit');}); } function draftIdSafe(){try{return Boolean(window.draft?.id||draft?.id);}catch(_){return false;}} function purgeSensitiveLocalData(){ const rules=[ ['sunFinanceRecordsV2',()=>has('money.view')||has('money.edit')], ['sunAuditLogV1',()=>has('audit.view')], ['sunSuppliers',()=>has('suppliers.view')||has('suppliers.edit')||has('shopping.view')], ['sunStockMoves',()=>has('stock.view')||has('stock.edit')||has('shopping.view')||has('production.view')], ['sunStock',()=>has('stock.view')||has('stock.edit')||has('shopping.view')||has('production.view')], ['sunEmployees',()=>has('team.view')||has('team.edit')||has('production.view')||has('routes.view')], ['sunClientCommunicationV1',()=>has('clients.view')||has('clients.edit')||has('orders.create')||has('orders.edit')], ['sunClientLoyaltyV1',()=>has('clients.view')||has('clients.edit')||has('orders.create')||has('orders.edit')] ]; for(const [key,ok] of rules){if(!ok())try{localStorage.removeItem(key);}catch(_){}} } function guardGlobal(name, permission, mode){ const fn=window[name];if(typeof fn!=='function'||fn.__sunRbacGuard)return; const wrapped=function(...args){ let perm=permission; if(typeof permission==='function')perm=permission(...args); if(perm&&!has(perm)){toast('У вас нет прав для этого действия.','warn');return;} return fn.apply(this,args); }; wrapped.__sunRbacGuard=true;wrapped.__sunOriginal=fn;window[name]=wrapped; } function installGuards(){ guardGlobal('saveOrder',()=>draftIdSafe()?'orders.edit':'orders.create'); guardGlobal('deleteOrder','orders.delete'); guardGlobal('deleteOrderById','orders.delete'); guardGlobal('saveBox','catalog.edit'); guardGlobal('removeBox','catalog.edit'); guardGlobal('editBox','catalog.edit'); guardGlobal('writeOffOrderStock','stock.edit'); guardGlobal('sunEditSupplier','suppliers.edit'); guardGlobal('editStockProduct','stock.edit'); } function hideLegacyRoleCard(){ const view=$('enterprise-settings');if(!view)return; qa('section',view).filter(s=>(s.querySelector(':scope > h2')?.textContent||'').trim()==='Пользователи и роли').forEach(s=>s.remove()); if(!has('backups.manage'))qa('section',view).filter(s=>(s.querySelector(':scope > h2')?.textContent||'').includes('Резервное копирование')).forEach(s=>s.style.display='none'); if(!has('audit.view'))qa('section',view).filter(s=>(s.querySelector(':scope > h2')?.textContent||'').includes('История изменений')).forEach(s=>s.style.display='none'); } function ensureModal(){ if($('sun-cloud-users-modal'))return; const modal=document.createElement('div');modal.id='sun-cloud-users-modal';modal.className='modal';modal.innerHTML=`

    Пользователи и права

    `;document.body.appendChild(modal); modal.addEventListener('click',e=>{if(e.target===modal||e.target.closest('[data-rbac-close]'))closeModal();}); } function openModal(){ensureModal();$('sun-cloud-users-modal').classList.add('on');loadMembers();} function closeModal(){$('sun-cloud-users-modal')?.classList.remove('on');editing=null;inviting=false;} async function loadMembers(){ const c=client(),ws=workspace(),body=$('sunRbacBody');if(!c||!ws||!body)return; body.innerHTML='

    Загружаю сотрудников…

    '; try{ const memberRes=await c.rpc('sun_list_workspace_members',{p_workspace:ws.id}); if(memberRes.error)throw memberRes.error; members=memberRes.data||[];renderMembers(); }catch(e){body.innerHTML=`

    ${esc(e.message||e)}

    `;} } async function copyText(value,success='Данные скопированы.'){ try{await navigator.clipboard.writeText(String(value||''));toast(success,'success');} catch(_){prompt('Скопируйте данные:',String(value||''));} } function renderMembers(){ const body=$('sunRbacBody');if(!body)return; if(editing){renderEditor();return;}if(inviting){renderEmployeeForm();return;} const active=members.map(m=>`
    ${esc(m.display_name||m.email||'Сотрудник')}${esc(m.email||'')} · ${m.is_active?'активен':'доступ отключён'}
    ${esc(ROLE_LABELS[m.role]||m.role)}
    `).join('')||'

    Сотрудников пока нет.

    '; body.innerHTML=`
    ${esc(workspace()?.name||'Рабочая база')}
    Создайте сотрудника здесь. Новый сотрудник получит временный пароль для первого входа.

    Сотрудники

    ${members.filter(m=>m.is_active).length}
    ${active}
    `; $('sunRbacAddUser').onclick=()=>{inviting=true;renderEmployeeForm();};$('sunRbacReload').onclick=loadMembers; qa('[data-rbac-edit]',body).forEach(b=>b.onclick=()=>{editing=members.find(m=>String(m.user_id)===String(b.dataset.rbacEdit));renderEditor();}); } function renderEmployeeForm(){ const body=$('sunRbacBody');if(!body)return; body.innerHTML=`
    Сотруднику не нужна отдельная ссылка-приглашение.

    Добавить сотрудника

    Если аккаунта ещё нет, Caterium создаст его и покажет временный пароль. Если аккаунт уже существует, он просто будет добавлен в эту компанию.

    `; const back=()=>{inviting=false;renderMembers();};$('sunRbacEmployeeBack').onclick=back;$('sunRbacEmployeeCancel').onclick=back;$('sunRbacCreateEmployee').onclick=createEmployeeV28; } async function createEmployeeV28(){ const c=client(),ws=workspace(),btn=$('sunRbacCreateEmployee');if(!c||!ws||!btn)return; const name=String($('sunRbacEmployeeName')?.value||'').trim(),email=String($('sunRbacEmployeeEmail')?.value||'').trim(),role=String($('sunRbacEmployeeRole')?.value||'manager'); if(!name)return toast('Введите имя сотрудника.','warn'); if(!email)return toast('Введите email сотрудника.','warn'); btn.disabled=true;btn.textContent='Создаю…'; try{ const prep=await c.rpc('sun_employee_prepare_v28',{p_workspace:ws.id,p_email:email,p_display_name:name,p_role:role});if(prep.error)throw prep.error; let result=prep.data||{}; if(result.status==='already_member'){ toast('Этот пользователь уже добавлен в компанию.','warn');btn.disabled=false;btn.textContent='Создать сотрудника';return; } if(result.status==='existing'){ const fin=await c.rpc('sun_employee_finalize_v28',{p_workspace:ws.id,p_user_id:result.user_id,p_display_name:name,p_role:role});if(fin.error)throw fin.error; result={...(fin.data||{}),created:false}; }else{ const call=await c.functions.invoke('caterium-create-employee',{body:{workspace_id:ws.id,email,display_name:name,role}});if(call.error)throw call.error; if(call.data?.error)throw new Error(call.data.error); result=call.data||{}; if(result.status==='existing'&&result.user_id){ const fin=await c.rpc('sun_employee_finalize_v28',{p_workspace:ws.id,p_user_id:result.user_id,p_display_name:name,p_role:role});if(fin.error)throw fin.error; result={...(fin.data||{}),created:false}; } } const host=$('sunRbacEmployeeResult'); if(result.created&&result.temporary_password){ const credentials=`Caterium\nEmail: ${email}\nВременный пароль: ${result.temporary_password}`; host.innerHTML=`
    Сотрудник создан.
    Передайте ему данные для первого входа:

    При первом входе Caterium попросит сотрудника придумать свой новый пароль.

    `; $('sunRbacCopyEmployee').onclick=()=>copyText(credentials,'Логин и временный пароль скопированы.'); }else{ host.innerHTML=`
    Сотрудник добавлен.
    Аккаунт ${esc(email)} уже существовал в Caterium. Сотрудник входит своим обычным паролем.
    `; } $('sunRbacEmployeeDone').onclick=async()=>{inviting=false;await loadMembers();}; toast('Сотрудник добавлен.','success'); }catch(e){toast(e.message||String(e),'error',7000);btn.disabled=false;btn.textContent='Создать сотрудника';} } function permissionHtml(perms, role){ return GROUPS.map(g=>`

    ${esc(g.title)}

    ${g.items.map(([key,label])=>{const checked=typeof perms?.[key]==='boolean'?perms[key]:(role==='admin');return ``;}).join('')}
    `).join(''); } function renderEditor(){ const body=$('sunRbacBody'),m=editing;if(!body||!m)return; const perms=Object.assign({},PRESETS[m.role]||PRESETS.viewer,m.permissions||{}); const adminCount=members.filter(x=>x.role==='admin'&&x.is_active).length; body.innerHTML=`
    ${esc(m.email||'')}
    ${permissionHtml(perms,m.role)}

    Активных администраторов: ${adminCount}. Ограничение срабатывает только если после изменения не останется ни одного активного администратора. Хотя бы у одного активного администратора должно оставаться право «Пользователи и права».

    `; $('sunRbacBack').onclick=()=>{editing=null;renderMembers();}; $('sunRbacRole').onchange=()=>{ const role=$('sunRbacRole').value; $('sunRbacPerms').innerHTML=permissionHtml(PRESETS[role]||PRESETS.viewer,role); }; $('sunRbacSave').onclick=saveMember; $('sunRbacRemove').onclick=removeMember; } function readEditorPermissions(){ const out={'app.read':true}; qa('[data-rbac-perm]',$('sunRbacPerms')).forEach(i=>out[i.dataset.rbacPerm]=i.checked); return out; } async function saveMember(){ const c=client(),ws=workspace(),m=editing;if(!c||!ws||!m)return; const btn=$('sunRbacSave');btn.disabled=true; try{ const {error}=await c.rpc('sun_admin_update_member',{p_workspace:ws.id,p_user:m.user_id,p_display_name:$('sunRbacName').value.trim(),p_role:$('sunRbacRole').value,p_is_active:$('sunRbacActive').checked,p_permissions:readEditorPermissions()}); if(error)throw error; toast('Права пользователя сохранены.','success');editing=null; await cloud()?.reloadMemberships?.(); applyNavPermissions(); if(has('users.manage'))await loadMembers();else closeModal(); }catch(e){toast(e.message||String(e),'error',7000);}finally{if(btn)btn.disabled=false;} } async function removeMember(){ const c=client(),ws=workspace(),m=editing;if(!c||!ws||!m)return; if(!confirm(`Удалить доступ пользователю ${m.display_name||m.email||''}?`))return; try{const {error}=await c.rpc('sun_admin_remove_member',{p_workspace:ws.id,p_user:m.user_id});if(error)throw error;toast('Доступ удалён.','success');editing=null;await cloud()?.reloadMemberships?.();applyNavPermissions();if(has('users.manage'))await loadMembers();else closeModal();} catch(e){toast(e.message||String(e),'error',7000);} } const hex=bytes=>[...bytes].map(x=>x.toString(16).padStart(2,'0')).join(''); const unhex=value=>new Uint8Array(String(value||'').match(/.{1,2}/g)?.map(x=>parseInt(x,16))||[]); async function pinDigest(pin,salt){ const raw=new TextEncoder().encode(String(pin||'')); if(crypto?.subtle){ const key=await crypto.subtle.importKey('raw',raw,'PBKDF2',false,['deriveBits']); const bits=await crypto.subtle.deriveBits({name:'PBKDF2',hash:'SHA-256',salt,iterations:120000},key,256); return hex(new Uint8Array(bits)); } let v=2166136261;for(let n=0;n<5000;n++)for(const b of [...raw,...salt]){v^=b;v=Math.imul(v,16777619);}return String(v>>>0); } async function makePinRecord(pin){const salt=crypto.getRandomValues(new Uint8Array(16));return `v1:${hex(salt)}:${await pinDigest(pin,salt)}`;} async function verifyPin(pin,record){const parts=String(record||'').split(':');if(parts.length!==3||parts[0]!=='v1')return false;const salt=unhex(parts[1]);return (await pinDigest(pin,salt))===parts[2];} function pinKey(){const id=session()?.user?.id;return id?`sunCloudQuickPinV3:${id}`:'';} function pinUnlockKey(){const id=session()?.user?.id;return id?`sunCloudQuickPinUnlockedV3:${id}`:'';} async function setQuickPin(){ const id=session()?.user?.id;if(!id)return; const p1=prompt('Введите новый PIN (4–8 цифр):','');if(p1===null)return;if(!/^\d{4,8}$/.test(p1))return toast('PIN должен содержать 4–8 цифр.','warn'); const p2=prompt('Повторите PIN:','');if(p1!==p2)return toast('PIN-коды не совпадают.','warn'); localStorage.setItem(pinKey(),await makePinRecord(p1));sessionStorage.setItem(pinUnlockKey(),'1');toast('Быстрый PIN включён на этом устройстве.','success');injectQuickPinCard(); } function removeQuickPin(){const key=pinKey();if(!key)return;localStorage.removeItem(key);sessionStorage.removeItem(pinUnlockKey());toast('Быстрый PIN отключён.','success');injectQuickPinCard();} function injectQuickPinCard(){ const card=$('sunCloudV2Card');if(!card||!session()?.user)return; let box=$('sunCloudQuickPinCardV3');if(!box){box=document.createElement('div');box.id='sunCloudQuickPinCardV3';box.className='sun-cloud-v2-box wide sun-cloud-pin-card';card.appendChild(box);} const enabled=Boolean(pinKey()&&localStorage.getItem(pinKey())); box.innerHTML=`

    Быстрый вход на этом устройстве

    Основной аккаунт — email и пароль. PIN только быстро разблокирует уже авторизованное устройство и не меняет облачные права.

    ${enabled?'':''}
    `; $('sunSetQuickPinV3').onclick=setQuickPin;$('sunRemoveQuickPinV3')?.addEventListener('click',removeQuickPin); } function showPinLockIfNeeded(){ const ss=session(),ws=workspace();if(!ss?.user||!ws)return; const key=pinKey(), unlock=pinUnlockKey();if(!key||!localStorage.getItem(key)||sessionStorage.getItem(unlock)==='1'||$('sunCloudPinLockV3'))return; const overlay=document.createElement('div');overlay.id='sunCloudPinLockV3';overlay.className='sun-cloud-auth-gate';overlay.innerHTML=`
    Caterium

    Caterium

    ${esc(ws.display_name||ss.user.email||'Пользователь')} · ${esc(ROLE_LABELS[ws.role]||ws.role)}
    `;document.body.appendChild(overlay); const unlockFn=async()=>{const v=$('sunCloudPinInputV3').value;if(await verifyPin(v,localStorage.getItem(key))){sessionStorage.setItem(unlock,'1');overlay.remove();applyNavPermissions();}else $('sunCloudPinErrorV3').textContent='Неверный PIN.';}; $('sunCloudPinUnlockV3').onclick=unlockFn;$('sunCloudPinInputV3').addEventListener('keydown',e=>{if(e.key==='Enter')unlockFn();});$('sunCloudUsePasswordV3').onclick=async()=>{if(window.SunCloudV2?.signOut)return window.SunCloudV2.signOut();try{await client()?.auth.signOut();}catch(_){}location.reload();};setTimeout(()=>$('sunCloudPinInputV3')?.focus(),50); } const PENDING_REGISTRATION_KEY='sunPendingRegistrationV23'; const LOCAL_ONLY_KEY='sunLocalOnlyModeV1'; let registrationProvisioning=false; function savePendingRegistration(email,companyName){try{localStorage.setItem(PENDING_REGISTRATION_KEY,JSON.stringify({email:String(email||'').trim().toLowerCase(),companyName:String(companyName||'').trim()||'Новая компания',createdAt:new Date().toISOString()}));}catch(_){}} function getPendingRegistration(email=''){try{const raw=JSON.parse(localStorage.getItem(PENDING_REGISTRATION_KEY)||'null');if(!raw?.email)return null;if(email&&String(raw.email).toLowerCase()!==String(email).trim().toLowerCase())return null;return raw}catch(_){return null}} function clearPendingRegistration(){try{localStorage.removeItem(PENDING_REGISTRATION_KEY)}catch(_){}} async function finishPendingRegistration(gate,email){ const pending=getPendingRegistration(email);if(!pending||registrationProvisioning)return false; registrationProvisioning=true;const err=gate?.querySelector('#sunGateErrorV3');if(err)err.textContent='Создаю вашу компанию…'; try{const ws=await cloud()?.createTrialWorkspace?.(pending.companyName||'Новая компания');if(!ws)throw new Error('Не удалось создать компанию.');clearPendingRegistration();if(err)err.textContent='Готово. Открываю Caterium…';setTimeout(async()=>{gate?.remove();document.body.classList.remove('sun-cloud-auth-required');try{await cloud()?.pull?.()}catch(_){}applyNavPermissions();},350);return true;} catch(e){if(err)err.textContent=errText(e,'Не удалось завершить регистрацию.');return false} finally{registrationProvisioning=false} } const INVITE_QUERY_KEY='invite'; let invitePreviewCache=null; let invitePreviewLoading=false; function currentInviteToken(){try{return String(new URL(location.href).searchParams.get(INVITE_QUERY_KEY)||'').trim()}catch(_){return ''}} function clearInviteFromUrl(){try{const u=new URL(location.href);u.searchParams.delete(INVITE_QUERY_KEY);history.replaceState(null,'',u.toString())}catch(_){}} function passwordField(id,label,autocomplete='current-password'){ return ``; } function bindPasswordEyes(root){qa('[data-toggle-password]',root).forEach(b=>b.onclick=()=>{const i=root.querySelector(`#${b.dataset.togglePassword}`);if(!i)return;i.type=i.type==='password'?'text':'password';b.textContent=i.type==='password'?'◉':'×';b.setAttribute('aria-label',i.type==='password'?'Показать пароль':'Скрыть пароль');});} async function getInvitePreview(token){ if(!token)return null;if(invitePreviewCache?.token===token)return invitePreviewCache.data;if(invitePreviewLoading)return null; invitePreviewLoading=true;try{const c=client();if(!c)return null;const {data,error}=await c.rpc('sun_invite_preview_v27',{p_token:token});if(error)throw error;const row=Array.isArray(data)?data[0]:data;invitePreviewCache={token,data:row||null};return row||null;}finally{invitePreviewLoading=false;} } async function finishInvite(token,gate){ const err=gate?.querySelector('#sunGateErrorV3');if(err)err.textContent='Добавляю вас в компанию…'; try{const ws=await cloud()?.acceptInvite?.(token);if(!ws)throw new Error('Не удалось присоединиться к компании.');clearInviteFromUrl();invitePreviewCache=null;gate?.remove();document.body.classList.remove('sun-cloud-auth-required');try{await cloud()?.pull?.()}catch(_){}applyNavPermissions();toast(`Вы присоединились к компании «${ws.name||'Компания'}».`,'success',6500);return true;} catch(e){if(err)err.textContent=String(e?.message||e||'Не удалось принять приглашение.');return false;} } async function renderInviteGate(gate,token){ let preview; try{preview=await getInvitePreview(token);}catch(e){gate.innerHTML=`
    Caterium

    Приглашение недоступно

    Caterium

    ${esc(e?.message||e||'Не удалось проверить приглашение.')}

    `;gate.querySelector('#sunInviteGoHome').onclick=()=>{clearInviteFromUrl();gate.remove();ensureAuthGate();};return;} if(!preview||!preview.is_valid){gate.innerHTML=`
    Caterium

    Приглашение недействительно

    Ссылка истекла, отменена или уже использована.
    `;gate.querySelector('#sunInviteGoHome').onclick=()=>{clearInviteFromUrl();gate.remove();ensureAuthGate();};return;} const ss=session(),signedEmail=String(ss?.user?.email||'').toLowerCase(),inviteEmail=String(preview.email||'').toLowerCase(); if(ss?.user){ if(signedEmail===inviteEmail){gate.innerHTML=`
    Caterium

    Приглашение в ${esc(preview.workspace_name||'компанию')}

    ${esc(preview.display_name||preview.email||'Сотрудник')} · ${esc(ROLE_LABELS[preview.role]||preview.role)}

    Вы вошли как ${esc(ss.user.email||'')}. Нажмите кнопку, чтобы добавить эту компанию в ваш аккаунт.

    `;gate.querySelector('#sunInviteAcceptSigned').onclick=()=>finishInvite(token,gate);gate.querySelector('#sunInviteSignOut').onclick=async()=>{try{await window.SunCloudV2?.signOut?.()}catch(_){}};return;} gate.innerHTML=`
    Caterium

    Приглашение в ${esc(preview.workspace_name||'компанию')}

    Для ${esc(preview.email||'другого email')}

    Сейчас вы вошли как ${esc(ss.user.email||'')}. Для принятия приглашения нужно войти под адресом ${esc(preview.email||'')}.

    `;gate.querySelector('#sunInviteSignOut').onclick=async()=>{try{await window.SunCloudV2?.signOut?.()}catch(_){}};return; } gate.innerHTML=`
    Caterium

    Вас пригласили в ${esc(preview.workspace_name||'компанию')}

    Роль: ${esc(ROLE_LABELS[preview.role]||preview.role)}
    ${passwordField('sunInvitePasswordV27','Пароль','new-password')}${passwordField('sunInvitePassword2V27','Подтвердите пароль','new-password')}

    Если аккаунт с этим email уже есть — введите его пароль. Если аккаунта ещё нет — придумайте новый пароль и повторите его.

    `; bindPasswordEyes(gate);const btn=gate.querySelector('#sunInviteJoinV27');const run=async()=>{const c=client(),email=String(preview.email||'').trim(),name=String(gate.querySelector('#sunInviteNameV27')?.value||'').trim(),password=String(gate.querySelector('#sunInvitePasswordV27')?.value||''),password2=String(gate.querySelector('#sunInvitePassword2V27')?.value||''),err=gate.querySelector('#sunGateErrorV3');if(password.length<6){err.textContent='Пароль должен содержать минимум 6 символов.';return;}if(password!==password2){err.textContent='Пароли не совпадают.';return;}btn.disabled=true;err.textContent='Проверяю аккаунт…';try{let r=await c.auth.signInWithPassword({email,password});if(r.error){r=await c.auth.signUp({email,password,options:{data:{name,registration_source:'caterium_invite_signup'}}});if(r.error)throw r.error;if(!r.data.session){const retry=await c.auth.signInWithPassword({email,password});if(retry.error)throw new Error('Этот email уже зарегистрирован. Введите пароль от существующего аккаунта.');r=retry;}}const userId=r.data.user?.id||r.data.session?.user?.id||'';if(userId)sessionStorage.setItem(`sunCloudQuickPinUnlockedV3:${userId}`,'1');err.textContent='Аккаунт готов. Подключаю компанию…';setTimeout(()=>finishInvite(token,gate),180);}catch(e){err.textContent=String(e?.message||e||'Не удалось войти или создать аккаунт.');btn.disabled=false;}};btn.onclick=run;gate.querySelector('#sunInvitePassword2V27').addEventListener('keydown',e=>{if(e.key==='Enter')run()}); } // Password changes now happen from account settings (see cloud-sync-v2.js's // renderCloudUI "Пароль" box) instead of a forced post-login gate: the old // gate raced with membership loading (loadMemberships finishing and // ensureAuthGate's periodic poll tearing the gate down as soon as // signedIn+workspace was true) and could disappear before the employee // ever saw it. function ensureAuthGate(){ const st=cloud()?.status?.()||{}; const ws=workspace(),ss=session(),inviteToken=currentInviteToken(); if(inviteToken){ let gate=$('sunCloudAuthGateV3');if(!gate){document.body.classList.add('sun-cloud-auth-required');gate=document.createElement('div');gate.id='sunCloudAuthGateV3';gate.className='sun-cloud-auth-gate';gate.innerHTML='

    Проверяю приглашение…

    ';document.body.appendChild(gate);renderInviteGate(gate,inviteToken);}return; } if(st.signedIn&&ws){$('sunCloudAuthGateV3')?.remove();document.body.classList.remove('sun-cloud-auth-required');try{localStorage.removeItem(LOCAL_ONLY_KEY)}catch(_){}return;} if(!st.signedIn&&window.CateriumAccessPolicy?.emergencyLocalActive()){$('sunCloudAuthGateV3')?.remove();document.body.classList.remove('sun-cloud-auth-required');return;} const gateState=st.signedIn?(st.membershipsLoading?'loading':st.membershipError?'error':'missing'):st.authLoading?'restoring':'login'; const currentGate=$('sunCloudAuthGateV3'); if(currentGate){if(currentGate.dataset.authState!==gateState)currentGate.remove();else{if(st.signedIn){const e=$('sunGateErrorV3');if(e)e.textContent=st.membershipsLoading?'Загружаю рабочую базу…':st.membershipError||e.textContent;}return;}} document.body.classList.add('sun-cloud-auth-required'); const gate=document.createElement('div');gate.id='sunCloudAuthGateV3';gate.className='sun-cloud-auth-gate';gate.dataset.authState=gateState; if(gateState==='restoring'){ gate.innerHTML='
    Caterium

    Открываю Caterium…

    Проверяю сохранённый вход

    '; document.body.appendChild(gate);return; } if(st.signedIn&&st.membershipError){ gate.innerHTML=`

    Не удалось загрузить рабочую базу

    ${esc(ss?.user?.email||'Аккаунт авторизован')}

    Вход выполнен, но сервер не вернул данные компании. Повторите загрузку.

    ${esc(st.membershipError)}
    `; document.body.appendChild(gate); gate.querySelector('#sunGateRetryWorkspaceV3').onclick=async()=>{const button=gate.querySelector('#sunGateRetryWorkspaceV3');button.disabled=true;try{const found=await cloud()?.reloadMemberships?.();if(found){location.reload();return;}}finally{gate.remove();ensureAuthGate();}}; gate.querySelector('#sunGateSignOutV3').onclick=()=>cloud()?.signOut?.();return; } if(st.signedIn&&ss?.user&&!ws){ const pending=getPendingRegistration(ss.user.email||''); gate.innerHTML=`
    Caterium

    ${pending?'Создаю компанию':st.membershipsLoading?'Загружаю рабочую базу':'Аккаунт готов'}

    ${esc(ss.user.email||'Аккаунт авторизован')}

    ${pending?'Регистрация завершена. Сейчас подготовим вашу рабочую компанию.':st.membershipsLoading?'Проверяю доступы этого аккаунта на сервере.':'У этого аккаунта пока нет компании. Укажите название, чтобы создать её.'}

    ${!pending&&!st.membershipsLoading?'':''}
    ${pending?'Подготавливаю компанию…':st.membershipsLoading?'Загружаю рабочую базу…':''}
    `; document.body.appendChild(gate);if(pending&&!st.membershipsLoading)setTimeout(()=>finishPendingRegistration(gate,ss.user.email||''),80); gate.querySelector('#sunGateRetryWorkspaceV3').onclick=async()=>{const e=gate.querySelector('#sunGateErrorV3');if(getPendingRegistration(ss.user.email||''))return finishPendingRegistration(gate,ss.user.email||'');if(!st.membershipsLoading){const companyName=String(gate.querySelector('#sunGateRecoveryCompanyV25')?.value||'').trim();if(!companyName){e.textContent='Введите название компании.';return;}savePendingRegistration(ss.user.email||'',companyName);return finishPendingRegistration(gate,ss.user.email||'');}e.textContent='Проверяю доступ…';try{const found=await cloud()?.reloadMemberships?.();if(found){gate.remove();document.body.classList.remove('sun-cloud-auth-required');await cloud()?.pull?.();setTimeout(applyNavPermissions,100);}else e.textContent='Рабочая база для этого аккаунта не найдена.';}catch(err){e.textContent=errText(err,'Не удалось проверить доступ.');}}; gate.querySelector('#sunGateSignOutV3').onclick=async()=>{if(window.SunCloudV2?.signOut)return window.SunCloudV2.signOut();try{await client()?.auth.signOut();}catch(_){}location.reload();};return; } gate.innerHTML=`
    Caterium

    Войти в рабочее пространство

    Ваши заказы. Ваша команда. Ваш результат.
    ${passwordField('sunGatePasswordV3','Пароль','current-password')}

    Нет аккаунта?

    `;document.body.appendChild(gate);bindPasswordEyes(gate); const localOnlyButton=gate.querySelector('#sunGateLocalOnlyV1'); if(window.CateriumAccessPolicy?.emergencyLocalEnabled){localOnlyButton.onclick=()=>{try{localStorage.setItem(LOCAL_ONLY_KEY,'1')}catch(_){}location.reload();};} else localOnlyButton.closest('p').remove(); const emailInput=gate.querySelector('#sunGateEmailV3'),passwordInput=gate.querySelector('#sunGatePasswordV3'),password2Input=gate.querySelector('#sunGatePassword2V27'),companyInput=gate.querySelector('#sunGateCompanyV3'),submitBtn=gate.querySelector('#sunGateSubmitV3'),gateError=gate.querySelector('#sunGateErrorV3');let registerMode=false; const setMode=(registration)=>{registerMode=Boolean(registration);gate.querySelector('#sunGateRegisterFieldsV27').hidden=!registerMode;gate.querySelector('#sunGateConfirmWrapV27').hidden=!registerMode;gate.querySelector('#sunGateTitleV3').textContent=registerMode?'Создать аккаунт Caterium':'Войти в рабочее пространство';gate.querySelector('#sunGateSubtitleV3').textContent=registerMode?'Заполните данные — после регистрации Caterium откроется автоматически':'Ваши заказы. Ваша команда. Ваш результат.';submitBtn.textContent=registerMode?'Создать аккаунт':'Войти';gate.querySelector('#sunGateSwitchTextV27').textContent=registerMode?'Уже есть аккаунт?':'Нет аккаунта?';gate.querySelector('#sunGateSwitchV27').textContent=registerMode?'Войти':'Создать аккаунт';passwordInput.autocomplete=registerMode?'new-password':'current-password';gateError.textContent='';if(registerMode)setTimeout(()=>companyInput.focus(),30);else setTimeout(()=>emailInput.focus(),30)}; async function auth(){const c=client(),email=emailInput.value.trim(),password=passwordInput.value,password2=password2Input.value,companyName=String(companyInput.value||'').trim();if(!c){gateError.textContent='Облачный сервис не подключён.';return;}if(registerMode&&!companyName){gateError.textContent='Введите название компании.';return;}if(!email||password.length<6){gateError.textContent='Введите email и пароль минимум из 6 символов.';return;}if(registerMode&&password!==password2){gateError.textContent='Пароли не совпадают.';return;}submitBtn.disabled=true;gateError.textContent=registerMode?'Создаю аккаунт…':'Выполняю вход…';try{let r;if(registerMode){savePendingRegistration(email,companyName);const existing=await c.auth.signInWithPassword({email,password});if(!existing.error&&existing.data.session){r=existing;}else{r=await c.auth.signUp({email,password,options:{data:{company_name:companyName,registration_source:'caterium_public_signup'}}});if(r.error)throw r.error;if(!r.data.session){const retry=await c.auth.signInWithPassword({email,password});if(retry.error){clearPendingRegistration();throw new Error('Этот email уже зарегистрирован. Войдите в аккаунт или восстановите пароль.');}r=retry;}}}else r=await c.auth.signInWithPassword({email,password});if(r.error)throw r.error;const authUser=r.data.user||r.data.session?.user||null;const userId=authUser?.id||'';if(userId)sessionStorage.setItem(`sunCloudQuickPinUnlockedV3:${userId}`,'1');gateError.textContent=registerMode?'Аккаунт готов. Создаю компанию…':'Вход выполнен. Загружаю рабочую базу…';setTimeout(async()=>{await cloud()?.reloadMemberships?.();if(registerMode&&workspace()){clearPendingRegistration();gate.remove();document.body.classList.remove('sun-cloud-auth-required');await cloud()?.pull?.();applyNavPermissions();return;}if(getPendingRegistration(email)&&!workspace()){await finishPendingRegistration(gate,email);return;}if(workspace()){gate.remove();document.body.classList.remove('sun-cloud-auth-required');await cloud()?.pull?.();applyNavPermissions();}else{gate.remove();ensureAuthGate();}},180);}catch(err){gateError.textContent=errText(err,'Не удалось выполнить операцию.')}finally{submitBtn.disabled=false}} gate.querySelector('#sunGateSwitchV27').onclick=()=>setMode(!registerMode);submitBtn.onclick=auth;passwordInput.addEventListener('keydown',e=>{if(e.key==='Enter'&&!registerMode)auth()});password2Input.addEventListener('keydown',e=>{if(e.key==='Enter'&®isterMode)auth()});setMode(false); } function onCloudChanged(){ if(applying)return;applying=true; try{ const ws=workspace();if(ws?.id!==lastWorkspaceId){lastWorkspaceId=ws?.id||'';} ensureAuthGate();normalizeLegacyLocalAuth();installGuards();applyNavPermissions();hideLegacyRoleCard();injectQuickPinCard();showPinLockIfNeeded(); }finally{applying=false;} } window.addEventListener('sun:open-cloud-users',openModal); window.addEventListener('sun:cloud-permissions-changed',()=>setTimeout(onCloudChanged,0)); document.addEventListener('click',e=>{const b=e.target.closest('header nav button');if(b&&String(b.dataset.navLabel||b.textContent||'').trim()==='Настройки')setTimeout(()=>{hideLegacyRoleCard();injectQuickPinCard();},120);},true); (()=>{let queued=false;const run=()=>{if(queued||applying)return;queued=true;setTimeout(()=>{queued=false;if(!applying){applyNavPermissions();hideLegacyRoleCard();}},60);};[document.querySelector('header'),document.getElementById('enterprise-settings')].filter(Boolean).forEach(root=>new MutationObserver(run).observe(root,{childList:true,subtree:true}));})(); installStyle();normalizeLegacyLocalAuth();ensureModal(); let tries=0;const boot=setInterval(()=>{tries++;if(window.SunCloudV2){clearInterval(boot);setTimeout(onCloudChanged,80);}else if(tries>80)clearInterval(boot);},100); setInterval(()=>{if(document.hidden)return;try{ensureAuthGate();if(workspace()&&session()?.user){applyNavPermissions();injectQuickPinCard();showPinLockIfNeeded();}}catch(_){}},5000); window.SunAdminRBACV3={VERSION,openUsers:openModal,apply:applyNavPermissions,hasPermission:has}; })(); ; /* ===== MODULE: order-payment-ui-v1.js ===== */ (()=>{ 'use strict'; if(window.__sunOrderPaymentUIV1)return; window.__sunOrderPaymentUIV1=true; const KEY='sunOrderPaymentColorsV1'; const DEFAULTS={ unpaid:'#d84a4a', prepaid:'#35a46b', paid:'#3b82f6', cancelled:'#9aa0aa' }; const LABELS={ unpaid:'Без предоплаты', prepaid:'Предоплата внесена', paid:'Оплачен полностью', cancelled:'Отменён' }; const $=id=>document.getElementById(id); const hex=value=>/^#[0-9a-f]{6}$/i.test(String(value||''))?String(value).toLowerCase():null; const readColors=()=>{ let saved={}; try{saved=JSON.parse(localStorage.getItem(KEY)||'{}')||{}}catch(_){saved={}} const next={}; Object.keys(DEFAULTS).forEach(key=>next[key]=hex(saved[key])||DEFAULTS[key]); return next; }; const saveColors=colors=>{ const safe={};Object.keys(DEFAULTS).forEach(key=>safe[key]=hex(colors[key])||DEFAULTS[key]); localStorage.setItem(KEY,JSON.stringify(safe)); decorateOrders(); return safe; }; const liveOrders=()=>{try{return Array.isArray(orders)?orders:JSON.parse(localStorage.getItem('sunOrders')||'[]')}catch(_){return[]}}; const byId=id=>liveOrders().find(order=>String(order?.id)===String(id)); const orderTotal=order=>{ try{if(typeof window.orderTotalValue==='function'){const value=Number(window.orderTotalValue(order));if(Number.isFinite(value))return Math.max(0,value)}}catch(_){} const direct=Number(order?.total);return Number.isFinite(direct)?Math.max(0,direct):0; }; function paymentState(order){ if(String(order?.status||'').trim()==='Отменён')return'cancelled'; const paid=Math.max(0,Number(order?.prepayment||0)); const total=orderTotal(order); if(total===0&&order?.telegramImport?.totalUnknown===true&&order?.paymentStatus==='paid'&&order?.paymentConfirmedAt)return'paid'; if(total>0&&paid>=total-0.5)return'paid'; if(paid>0)return'prepaid'; return'unpaid'; } window.sunOrderPaymentState=paymentState; window.sunOrderPaymentColors=()=>({...readColors()}); function readableText(color){ const value=hex(color)||'#000000'; const r=parseInt(value.slice(1,3),16),g=parseInt(value.slice(3,5),16),b=parseInt(value.slice(5,7),16); return ((r*299+g*587+b*114)/1000)>168?'#252a32':'#ffffff'; } function shadowColor(color,alpha=.24){ const value=hex(color)||'#000000'; const r=parseInt(value.slice(1,3),16),g=parseInt(value.slice(3,5),16),b=parseInt(value.slice(5,7),16); return `rgba(${r},${g},${b},${alpha})`; } function paintDateTile(tile,order){ if(!tile||!order)return; const state=paymentState(order),colors=readColors(),color=colors[state],text=readableText(color); tile.dataset.paymentState=state; tile.title=LABELS[state]; tile.setAttribute('aria-label',`${tile.textContent.trim()}: ${LABELS[state]}`); tile.style.setProperty('background',color,'important'); tile.style.setProperty('border-color',color,'important'); tile.style.setProperty('box-shadow',`0 5px 14px ${shadowColor(color)}`,'important'); tile.style.setProperty('color',text,'important'); tile.querySelectorAll('strong,span,small').forEach(node=>node.style.setProperty('color',text,'important')); } function decorateOrders(){ document.querySelectorAll('#ordersList .sun-order-payment-marker,#sunOrderCards .sun-order-payment-marker').forEach(marker=>marker.remove()); document.querySelectorAll('#ordersList .sun-order-number-cell').forEach(cell=>cell.classList.remove('sun-order-number-cell')); document.querySelectorAll('#sunOrderCards .sun-order-number-with-payment').forEach(host=>host.classList.remove('sun-order-number-with-payment')); document.querySelectorAll('#sunOrderCards .sun-order-card').forEach(card=>{ const order=byId(card.dataset.orderId),tile=card.querySelector('.sun-order-card-date'); if(order&&tile)paintDateTile(tile,order); }); } let decorateQueued=false; function queueDecorate(){if(decorateQueued)return;decorateQueued=true;requestAnimationFrame(()=>{decorateQueued=false;decorateOrders()})} const priorRenderOrders=window.renderOrders; if(typeof priorRenderOrders==='function')window.renderOrders=function(...args){const result=priorRenderOrders.apply(this,args);queueDecorate();return result}; // One authoritative deletion path for list, cards and the current order form. window.deleteOrderById=function(id){ const list=liveOrders(); const order=list.find(item=>String(item?.id)===String(id)); if(!order)return false; if(!confirm(`Удалить заказ №${order.id}${order.event?` «${order.event}»`:''}?`))return false; try{ if(Array.isArray(orders)){ const index=orders.findIndex(item=>String(item?.id)===String(id)); if(index>=0)orders.splice(index,1); }else{ localStorage.setItem('sunOrders',JSON.stringify(list.filter(item=>String(item?.id)!==String(id)))); } if(typeof persist==='function')persist(); else localStorage.setItem('sunOrders',JSON.stringify(Array.isArray(orders)?orders:list.filter(item=>String(item?.id)!==String(id)))); }catch(error){ console.error('Order deletion failed',error); window.SunEnterprise?.toast?.('Не удалось удалить заказ.','error',5000); return false; } try{window.SunEnterprise?.recordAudit?.('Удалён заказ',`№${order.id} · ${order.event||'Заказ'}`)}catch(_){} const currentId=(()=>{try{return draft?.id}catch(_){return null}})(); if(String(currentId)===String(id)&&typeof window.resetDraft==='function')window.resetDraft(); try{window.renderOrders?.()}catch(_){} try{window.renderStats?.()}catch(_){} try{window.sunRenderOrderCards?.()}catch(_){} window.dispatchEvent(new CustomEvent('sun:order-deleted',{detail:{id:order.id}})); window.SunEnterprise?.toast?.(`Заказ №${order.id} удалён.`,'success'); return true; }; window.deleteOrder=function(){ const id=(()=>{try{return draft?.id}catch(_){return null}})(); if(!id)return false; const deleted=window.deleteOrderById(id); if(!deleted)return false; const button=[...document.querySelectorAll('header nav button')].find(btn=>String(btn.dataset.navLabel||btn.textContent||'').trim()==='Заказы'); if(button)button.click(); else if(typeof window.show==='function')try{window.show('orders',document.querySelectorAll('header nav button')[1])}catch(_){} return true; }; function settingsCard(){ const view=$('enterprise-settings'),grid=view?.querySelector('.enterprise-grid'); if(!grid)return null; let card=$('sunOrderPaymentColorsCard'); if(card)return card; card=document.createElement('section'); card.className='enterprise-card';card.id='sunOrderPaymentColorsCard'; grid.appendChild(card); renderSettingsCard(card); return card; } function renderSettingsCard(card=settingsCard()){ if(!card)return; const colors=readColors(); card.innerHTML=`

    Цвета оплаты заказов

    Цвет плашки с датой в разделе «Заказы» показывает состояние оплаты. Цвет каждого состояния можно выбрать самостоятельно.

    ${Object.keys(DEFAULTS).map(key=>``).join('')}

    Правила: 0 ₽ предоплаты — «Без предоплаты»; предоплата меньше суммы — «Предоплата внесена»; оплачена вся сумма — «Оплачен полностью»; статус «Отменён» — отдельный цвет.

    `; card.querySelectorAll('[data-payment-color]').forEach(input=>input.addEventListener('input',()=>{ const next=readColors();next[input.dataset.paymentColor]=input.value;saveColors(next); const dot=input.closest('label')?.querySelector('.sun-payment-settings-dot');if(dot)dot.style.background=input.value; })); $('sunResetPaymentColors')?.addEventListener('click',()=>{localStorage.setItem(KEY,JSON.stringify(DEFAULTS));renderSettingsCard(card);decorateOrders();window.SunEnterprise?.toast?.('Стандартные цвета восстановлены.','success')}); } function ensureSettingsCard(){const card=settingsCard();if(card)renderSettingsCard(card)} const style=document.createElement('style');style.id='sun-order-payment-ui-v1-style';style.textContent=` #orderForm .sun-order-line-price input{width:100%!important;max-width:104px!important;min-width:78px!important;padding:2px 7px!important;font-variant-numeric:tabular-nums!important;letter-spacing:.01em!important} .sun-payment-color-settings{display:grid;gap:8px;margin-top:10px} .sun-payment-color-settings label{display:grid;grid-template-columns:minmax(0,1fr) 52px;gap:10px;align-items:center;padding:8px 9px;border:1px solid #e3e6e9;border-radius:9px;background:#fafbfc} .sun-payment-color-settings label>span{display:flex;align-items:center;gap:8px;color:#3d4652;font-weight:700;font-size:12.5px} .sun-payment-color-settings input[type="color"]{width:48px;height:32px;padding:2px;border:1px solid #cfd5da;border-radius:7px;background:#fff;cursor:pointer} .sun-payment-settings-dot{display:inline-block;width:13px;height:13px;border-radius:3px;box-shadow:inset 0 0 0 1px rgba(0,0,0,.12)} `;document.head.appendChild(style); document.addEventListener('click',event=>{ const nav=event.target.closest('header nav button'); if(nav&&String(nav.dataset.navLabel||nav.textContent||'').trim()==='Настройки')setTimeout(ensureSettingsCard,60); },true); const ordersHost=$('orders');if(ordersHost)new MutationObserver(queueDecorate).observe(ordersHost,{childList:true,subtree:true}); const settingsHost=$('enterprise-settings');if(settingsHost)new MutationObserver(()=>{if(settingsHost.classList.contains('on'))setTimeout(()=>{if(!$('sunOrderPaymentColorsCard'))ensureSettingsCard()},0)}).observe(settingsHost,{childList:true,subtree:true}); window.addEventListener('sun:cloud-state-applied',()=>{queueDecorate();if($('enterprise-settings')?.classList.contains('on'))setTimeout(ensureSettingsCard,0)}); window.addEventListener('storage',event=>{if(event.key===KEY)queueDecorate()}); queueDecorate(); setTimeout(()=>{queueDecorate();if($('enterprise-settings')?.classList.contains('on'))ensureSettingsCard()},140); })(); ; /* ===== MODULE: order-crm-v1.js ===== */ (()=>{ 'use strict'; if(window.__sunOrderCrmV1)return;window.__sunOrderCrmV1=true; const $=id=>document.getElementById(id),qa=(s,r=document)=>[...r.querySelectorAll(s)]; const esc=window.SunSafe.escapeHTML; const SOURCE_KEY='sunLeadSourcesV1'; const DEFAULT_SOURCES=['Заявка с сайта','CaterMe','Avito','Рекомендация','Повторный клиент','Соцсети','Другое']; const getDraft=()=>{try{return draft}catch(_){return null}}; const getOrders=()=>{try{return Array.isArray(orders)?orders:[]}catch(_){try{const x=JSON.parse(localStorage.getItem('sunOrders')||'[]');return Array.isArray(x)?x:[]}catch(__){return[]}}}; const money=v=>typeof window.money==='function'?window.money(v):`${Math.round(Number(v||0)).toLocaleString('ru-RU')} ₽`; const toast=(m,t='success')=>{try{if(window.SunEnterprise?.toast)return window.SunEnterprise.toast(m,t)}catch(_){} if(t==='warn'||t==='error')console.warn(m)}; const style=document.createElement('style');style.id='sun-order-crm-v1-style';style.textContent=` /* The precise-address fields existed in an older module but were hidden by a later polish layer. */ #details .sun-address-precision-title{display:block!important;grid-column:1/-1;margin:10px 0 0;font-size:13px;font-weight:850;color:#241d45} #details .sun-delivery-region,#details .sun-delivery-locality,#details .sun-delivery-district{display:flex!important} #details .sun-delivery-district{grid-column:1/-1!important} #details .sun-lead-source-field,#details .sun-known-addresses-field{grid-column:1/-1} #details .sun-client-match{grid-column:1/-1;display:none;padding:9px 11px;border:1px solid #d7d0e7;border-radius:11px;background:#f7f5fb;color:#514b68;font-size:12px;line-height:1.35} #details .sun-client-match.on{display:block} #details .sun-geo-confirm{display:none;margin-top:10px;padding:11px;border:1px solid #dfd9eb;border-radius:12px;background:#faf9fd} #details .sun-geo-confirm.on{display:block} #details .sun-geo-confirm strong{display:block;color:#241d45;font-size:12px;margin-bottom:5px} #details .sun-geo-confirm p{margin:0 0 9px;color:#666078;font-size:11px;line-height:1.4} #details .sun-geo-confirm .actions{display:flex;gap:7px;flex-wrap:wrap;margin:0} #details .sun-geo-confirm.ok{border-color:#b8dcc4;background:#f3fbf5} #details .sun-geo-confirm.ok strong{color:#28683d} .sun-crm-stats-table{width:100%;border-collapse:collapse;font-size:12px} .sun-crm-stats-table th,.sun-crm-stats-table td{padding:9px 8px;border-bottom:1px solid #ece9f2;text-align:right;white-space:nowrap} .sun-crm-stats-table th:first-child,.sun-crm-stats-table td:first-child{text-align:left;white-space:normal} .sun-crm-stats-table th{color:#817a94;font-size:10px;text-transform:uppercase;letter-spacing:.04em} .sun-crm-stats-table td b{color:#241d45} .sun-lead-settings-list{display:grid;gap:7px;margin:10px 0} .sun-lead-settings-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px} @media(max-width:700px){.sun-crm-stats-table{font-size:10px}.sun-crm-stats-table th,.sun-crm-stats-table td{padding:7px 4px}.sun-crm-hide-mobile{display:none}} `;document.head.appendChild(style); function readSources(){ try{const a=JSON.parse(localStorage.getItem(SOURCE_KEY)||'null');if(Array.isArray(a)){const clean=a.map(x=>String(x||'').trim()).filter(Boolean);if(clean.length)return [...new Set(clean)]}}catch(_){} return DEFAULT_SOURCES.slice(); } function saveSources(list){ const clean=[...new Set((list||[]).map(x=>String(x||'').trim()).filter(Boolean))]; localStorage.setItem(SOURCE_KEY,JSON.stringify(clean.length?clean:DEFAULT_SOURCES)); refreshSourceSelect();renderCrmStats(); } function setSelectOptions(select,values,current=''){ if(!select)return;const cur=String(current||select.value||''); const all=values.slice();if(cur&&!all.includes(cur))all.push(cur); select.innerHTML=``+all.map(v=>``).join('');select.value=cur; } function ensureSourceField(){ const grid=document.querySelector('#details .sun-delivery-details');if(!grid)return null; let select=$('orderLeadSource');if(!select){ const label=document.createElement('label');label.className='sun-lead-source-field';label.innerHTML='Узнали из'; const loyalty=$('orderLoyaltyCard')?.closest('label'),phone=$('phone')?.closest('label'); (loyalty||phone)?.insertAdjacentElement('afterend',label);if(!label.isConnected)window.SunSafe.insertBefore(grid,label,grid.firstChild); select=label.querySelector('select');select.addEventListener('change',()=>{const d=getDraft();if(d)d.leadSource=select.value}); } setSelectOptions(select,readSources(),getDraft()?.leadSource||select.value);return select; } function refreshSourceSelect(){const s=ensureSourceField();if(s)setSelectOptions(s,readSources(),getDraft()?.leadSource||s.value)} function ensureSettingsCard(){ const grid=document.querySelector('#enterprise-settings .enterprise-grid');if(!grid)return; let card=$('sunLeadSourcesSettingsCard'); if(!card){card=document.createElement('section');card.id='sunLeadSourcesSettingsCard';card.className='enterprise-card';card.innerHTML=`

    Источники клиентов

    Список для поля «Узнали из» в деталях заказа. Эти данные используются в статистике.

    `; const eventCard=$('sunEventTypesSettingsCard');if(eventCard)eventCard.insertAdjacentElement('afterend',card);else grid.appendChild(card); $('sunLeadSourceAdd').onclick=()=>addSourceSettingsRow('');$('sunLeadSourcesSave').onclick=()=>saveSources(qa('#sunLeadSourcesRows input').map(x=>x.value)); } const rows=$('sunLeadSourcesRows');if(rows&&!rows.dataset.ready){rows.dataset.ready='1';renderSourceSettingsRows()} } function addSourceSettingsRow(value=''){ const rows=$('sunLeadSourcesRows');if(!rows)return;const row=document.createElement('div');row.className='sun-lead-settings-row';row.innerHTML=``;row.querySelector('button').onclick=()=>row.remove();rows.appendChild(row);row.querySelector('input').focus(); } function renderSourceSettingsRows(){const rows=$('sunLeadSourcesRows');if(!rows)return;rows.innerHTML='';readSources().forEach(addSourceSettingsRow)} function normalizePhone(raw){ let d=String(raw||'').replace(/\D/g,'');if(d.length===11&&d[0]==='8')d='7'+d.slice(1);else if(d.length===10)d='7'+d;return d; } function orderStamp(o){return `${o?.date||''}T${o?.time||''}|${String(o?.id||'').padStart(12,'0')}`} function structuredAddress(o){ const keys=['deliveryRegion','deliveryLocality','deliveryDistrict','deliveryStreet','deliveryHouse','deliveryCorpus','deliveryEntrance','deliveryFloor','deliveryApartment','deliveryOffice']; const data={};keys.forEach(k=>data[k]=String(o?.[k]||'').trim());data.address=String(o?.address||'').trim(); data.structured=keys.some(k=>data[k]);return data; } function profileFromOrders(list){ const sorted=list.slice().sort((a,b)=>orderStamp(b).localeCompare(orderStamp(a)));if(!sorted.length)return null; const latest=sorted[0],seen=new Set(),addresses=[]; sorted.forEach(o=>{const a=structuredAddress(o),key=a.structured?JSON.stringify(a):a.address;if(key&&!seen.has(key)){seen.add(key);addresses.push(a)}}); return {name:String(latest.contact||''),phone:String(latest.phone||''),ordersCount:sorted.length,latest,addresses}; } function profileByPhone(raw){const n=normalizePhone(raw);if(n.length<10)return null;return profileFromOrders(getOrders().filter(o=>normalizePhone(o.phone)===n))} function readLoyaltyStore(){try{const v=JSON.parse(localStorage.getItem('sunClientLoyaltyV1')||'{}');return v&&typeof v==='object'?v:{}}catch(_){return{}}} function profileByCard(raw){ const card=String(raw||'').trim().toLowerCase();if(!card)return null; let direct=getOrders().filter(o=>String(o.loyaltyCardNumber||o.loyaltyCard||'').trim().toLowerCase()===card);if(direct.length)return profileFromOrders(direct); const store=readLoyaltyStore(),entry=Object.entries(store).find(([,v])=>String(v?.cardNumber||'').trim().toLowerCase()===card);if(!entry)return null; const key=entry[0];if(key.startsWith('p:'))return profileByPhone(key.slice(2)); if(key.startsWith('n:')){const name=key.slice(2).trim().toLowerCase();return profileFromOrders(getOrders().filter(o=>String(o.contact||'').trim().toLowerCase()===name))} return null; } const addrIds=['deliveryRegion','deliveryLocality','deliveryDistrict','deliveryStreet','deliveryHouse','deliveryCorpus','deliveryEntrance','deliveryFloor','deliveryApartment','deliveryOffice']; function setSelectValue(el,value){if(!el)return;const v=String(value||'');if(v&&el.tagName==='SELECT'&&![...el.options].some(o=>o.value===v))el.add(new Option(v,v));el.value=v} function composeFullAddress(){ const val=id=>String($(id)?.value||'').trim();const address=$('address');if(!address)return''; const parts=[val('deliveryRegion'),val('deliveryLocality'),val('deliveryDistrict'),val('deliveryStreet'),val('deliveryHouse')?`д. ${val('deliveryHouse')}`:'',val('deliveryCorpus')?`корп. ${val('deliveryCorpus')}`:'',val('deliveryEntrance')?`подъезд ${val('deliveryEntrance')}`:'',val('deliveryFloor')?`этаж ${val('deliveryFloor')}`:'',val('deliveryApartment')?`кв. ${val('deliveryApartment')}`:'',val('deliveryOffice')?`офис ${val('deliveryOffice')}`:''].filter(Boolean); const full=parts.join(', ');address.value=full;const d=getDraft();if(d){addrIds.forEach(id=>d[id]=val(id));d.address=full}return full; } window.sunComposePreciseDeliveryAddress=composeFullAddress; function fillAddress(a){ if(!a)return;const has=Boolean(a.structured); addrIds.forEach(id=>{const el=$(id);if(!el)return;const v=has?String(a[id]||''):(id==='deliveryStreet'?String(a.address||''):'');if(id==='deliveryRegion')setSelectValue(el,v);else el.value=v}); composeFullAddress();resetGeoConfirmation(true); } function ensureClientUi(){ const grid=document.querySelector('#details .sun-delivery-details');if(!grid)return; if(!$('sunClientMatch')){const box=document.createElement('div');box.id='sunClientMatch';box.className='sun-client-match';const lead=$('orderLeadSource')?.closest('label');(lead||grid.firstElementChild)?.insertAdjacentElement('afterend',box);if(!box.isConnected)window.SunSafe.insertBefore(grid,box,grid.firstChild)} if(!$('sunKnownAddresses')){const label=document.createElement('label');label.className='sun-known-addresses-field';label.style.display='none';label.innerHTML='Предыдущие адреса клиента';const office=$('deliveryOffice')?.closest('label');office?.insertAdjacentElement('afterend',label);if(!label.isConnected)grid.appendChild(label);label.querySelector('select').onchange=e=>{const a=e.target._addresses?.[Number(e.target.value)];if(a)fillAddress(a)}} } function showProfile(profile,reason){ ensureClientUi();const box=$('sunClientMatch'),known=$('sunKnownAddresses'),knownLabel=known?.closest('label');if(!profile){box?.classList.remove('on');if(knownLabel)knownLabel.style.display='none';return false} const contact=$('contact'),phone=$('phone');if(contact&&profile.name)contact.value=profile.name;if(phone&&profile.phone)phone.value=profile.phone; if(profile.addresses[0])fillAddress(profile.addresses[0]); if(known&&knownLabel){known._addresses=profile.addresses;known.innerHTML=profile.addresses.map((a,i)=>``).join('');knownLabel.style.display=profile.addresses.length>1?'flex':'none'} if(box){box.innerHTML=`Клиент найден по ${reason}: ${esc(profile.name||profile.phone||'клиент')} · заказов: ${profile.ordersCount}${profile.addresses.length>1?` · сохранено адресов: ${profile.addresses.length}`:''}. Данные заполнены автоматически, адрес можно изменить.`;box.classList.add('on')} const d=getDraft();if(d){d.contact=contact?.value||'';d.phone=phone?.value||''}return true; } let lookupTimer=0; function schedulePhoneLookup(){clearTimeout(lookupTimer);lookupTimer=setTimeout(()=>{const d=getDraft();if(d?.id)return;const p=profileByPhone($('phone')?.value);if(p)showProfile(p,'телефону')},260)} function lookupCard(){const d=getDraft();if(d?.id)return;const field=$('orderLoyaltyCard'),p=profileByCard(field?.value);if(p)showProfile(p,'карте лояльности')} function totalFor(o){if(Number.isFinite(Number(o?.total))&&Number(o.total)>0)return Number(o.total);try{return typeof window.orderTotalValue==='function'?Number(window.orderTotalValue(o)||0):0}catch(_){return 0}} function statsPeriodOrders(){ const period=$('statsPeriod')?.value||'year',now=new Date();now.setHours(12,0,0,0);let from=null,to=null; if(period==='month'){from=new Date(now.getFullYear(),now.getMonth(),1,12);to=new Date(now.getFullYear(),now.getMonth()+1,1,12)} else if(period==='90'){from=new Date(now);from.setDate(from.getDate()-89);to=new Date(now);to.setDate(to.getDate()+1)} else if(period==='year'){from=new Date(now.getFullYear(),now.getMonth()-11,1,12);to=new Date(now.getFullYear(),now.getMonth()+1,1,12)} return getOrders().filter(o=>{if(String(o.status||'Новый')==='Отменён')return false;if(!from)return true;const d=o.date?new Date(`${o.date}T12:00:00`):null;return d&&!Number.isNaN(d.getTime())&&d>=from&&d{const name=String(keyFn(o)||'').trim()||'Не указано',r=m.get(name)||{name,count:0,sum:0};r.count++;r.sum+=totalFor(o);m.set(name,r)});return [...m.values()].map(r=>({...r,avg:r.count?r.sum/r.count:0,share:list.length?r.count/list.length*100:0})).sort((a,b)=>b.count-a.count||b.sum-a.sum||a.name.localeCompare(b.name,'ru')); } function statTable(rows){return rows.length?`
    ${rows.map(r=>``).join('')}
    КатегорияЗаказовДоляВыручкаСредний чек
    ${esc(r.name)}${r.count}${r.share.toLocaleString('ru-RU',{maximumFractionDigits:1})}%${esc(money(r.sum))}${esc(money(r.avg))}
    `:'
    Данных пока нет.
    '} function renderCrmStats(){ const grid=$('statsList')?.querySelector('.stats-grid');if(!grid)return;grid.querySelectorAll('.sun-crm-stats-panel').forEach(x=>x.remove());const list=statsPeriodOrders(); const eventPanel=document.createElement('section');eventPanel.className='stats-panel wide sun-crm-stats-panel';eventPanel.innerHTML=`

    Типы мероприятий

    количество · доля · выручка · средний чек
    ${statTable(groupedStats(list,o=>o.event))}`; const sourcePanel=document.createElement('section');sourcePanel.className='stats-panel wide sun-crm-stats-panel';sourcePanel.innerHTML=`

    Откуда приходят заказы

    по полю «Узнали из»
    ${statTable(groupedStats(list,o=>o.leadSource))}`; grid.append(eventPanel,sourcePanel); } function resetGeoConfirmation(clear=false){const d=getDraft();if(d?.geo){if(clear)d.geo=null;else{delete d.geo.confirmed;delete d.geo.confirmedAt}}syncGeoConfirmUi()} function ensureGeoConfirmUi(){ const box=$('orderGeoBox');if(!box)return null;let ui=$('sunGeoConfirm');if(!ui){ui=document.createElement('div');ui.id='sunGeoConfirm';ui.className='sun-geo-confirm';ui.innerHTML='Найденный адрес

    ';box.appendChild(ui);$('sunGeoConfirmOk').onclick=()=>{const d=getDraft();if(!d?.geo)return;d.geo.confirmed=true;d.geo.confirmedAt=new Date().toISOString();if(d.id){const saved=getOrders().find(o=>String(o.id)===String(d.id));if(saved)saved.geo=structuredClone(d.geo);try{persist()}catch(_){}}syncGeoConfirmUi();toast('Адрес подтверждён.','success')};$('sunGeoConfirmEdit').onclick=()=>{const d=getDraft();if(d)d.geo=null;const locality=$('deliveryLocality')||$('deliveryStreet');locality?.focus();syncGeoConfirmUi()}} return ui; } function syncGeoConfirmUi(){ const ui=ensureGeoConfirmUi(),d=getDraft();if(!ui)return;const geo=d?.geo;if(!geo||!Number.isFinite(Number(geo.lat))||!Number.isFinite(Number(geo.lon))){ui.classList.remove('on','ok');return}ui.classList.add('on');ui.classList.toggle('ok',geo.confirmed===true);$('sunGeoFoundTitle').textContent=geo.confirmed?'Адрес подтверждён':'Проверьте найденный адрес';$('sunGeoFoundText').textContent=String(geo.formatted||d.address||'');$('sunGeoConfirmOk').style.display=geo.confirmed?'none':''; } function persistPreciseSaved(){ const d=getDraft();if(!d)return;const full=composeFullAddress();if(!d.id)return;const saved=getOrders().find(o=>String(o.id)===String(d.id));if(!saved)return;addrIds.forEach(id=>saved[id]=String($(id)?.value||'').trim());saved.address=full;saved.leadSource=$('orderLeadSource')?.value||'';if(d.geo)saved.geo=structuredClone(d.geo);try{persist()}catch(_){try{localStorage.setItem('sunOrders',JSON.stringify(getOrders()))}catch(__){}} } function init(){ ensureSourceField();ensureClientUi();ensureGeoConfirmUi(); const mapSensitive=new Set(['deliveryRegion','deliveryLocality','deliveryDistrict','deliveryStreet','deliveryHouse','deliveryCorpus']); addrIds.forEach(id=>$(id)?.addEventListener('input',()=>setTimeout(()=>{composeFullAddress();if(mapSensitive.has(id))resetGeoConfirmation(true)},0))); $('deliveryRegion')?.addEventListener('change',()=>setTimeout(()=>{composeFullAddress();resetGeoConfirmation(true)},0)); $('phone')?.addEventListener('input',schedulePhoneLookup);$('phone')?.addEventListener('blur',schedulePhoneLookup); $('orderLoyaltyCard')?.addEventListener('input',()=>{clearTimeout(lookupTimer);lookupTimer=setTimeout(lookupCard,260)});$('orderLoyaltyCard')?.addEventListener('blur',lookupCard); $('orderGeoCreate')?.addEventListener('click',()=>setTimeout(syncGeoConfirmUi,200)); const geoBox=$('orderGeoBox');if(geoBox)new MutationObserver(()=>setTimeout(syncGeoConfirmUi,0)).observe(geoBox,{childList:true,subtree:true,characterData:true}); const stats=$('statsList');if(stats)new MutationObserver(()=>setTimeout(renderCrmStats,0)).observe(stats,{childList:true,subtree:false}); $('statsPeriod')?.addEventListener('change',()=>setTimeout(renderCrmStats,0));renderCrmStats(); } const priorSave=window.saveOrder; if(typeof priorSave==='function')window.saveOrder=function(...args){ const d=getDraft(),source=ensureSourceField();if(d&&source)d.leadSource=source.value;composeFullAddress(); const street=String($('deliveryStreet')?.value||'').trim(),house=String($('deliveryHouse')?.value||'').trim(),locality=String($('deliveryLocality')?.value||'').trim(); if(street&&house&&!locality&&!confirm('Не указан город / населённый пункт. Похожие адреса могут существовать в разных местах. Сохранить заказ без уточнения?'))return false; if(street&&house&&d?.geo&&d.geo.confirmed!==true&&!confirm('Точка уже найдена на карте, но адрес не подтверждён. Сохранить без подтверждения?'))return false; const r=priorSave.apply(this,args),finish=()=>{persistPreciseSaved();renderCrmStats();syncGeoConfirmUi();return undefined};if(r&&typeof r.then==='function')return r.then(v=>{finish();return v});finish();return r; }; const priorOpen=window.openOrder; if(typeof priorOpen==='function')window.openOrder=function(...args){const r=priorOpen.apply(this,args);setTimeout(()=>{const s=ensureSourceField();if(s)s.value=String(getDraft()?.leadSource||'');composeFullAddress();ensureClientUi();syncGeoConfirmUi();$('sunClientMatch')?.classList.remove('on');const l=$('sunKnownAddresses')?.closest('label');if(l)l.style.display='none'},0);return r}; const priorReset=window.resetDraft; if(typeof priorReset==='function')window.resetDraft=function(...args){const r=priorReset.apply(this,args);setTimeout(()=>{const s=ensureSourceField();if(s)s.value='';$('sunClientMatch')?.classList.remove('on');const l=$('sunKnownAddresses')?.closest('label');if(l)l.style.display='none';syncGeoConfirmUi()},0);return r}; const priorStats=window.renderStats; if(typeof priorStats==='function')window.renderStats=function(...args){const r=priorStats.apply(this,args);setTimeout(renderCrmStats,0);return r}; document.addEventListener('click',e=>{const b=e.target.closest('header nav button');if(!b)return;const label=String(b.dataset.navLabel||b.textContent||'').trim();if(label==='Настройки')setTimeout(()=>{ensureSettingsCard();renderSourceSettingsRows()},90);if(label==='Статистика')setTimeout(renderCrmStats,80)},true); const settings=$('enterprise-settings');if(settings)new MutationObserver(()=>{if(settings.classList.contains('on'))setTimeout(ensureSettingsCard,0)}).observe(settings,{childList:true,subtree:true}); window.addEventListener('sun:cloud-state-applied',()=>setTimeout(()=>{refreshSourceSelect();renderCrmStats()},50)); init();setTimeout(()=>{if($('enterprise-settings')?.classList.contains('on'))ensureSettingsCard();syncGeoConfirmUi()},200); })(); ; /* Theme is loaded synchronously from core/brand-theme.js in the document head. */ /* ===== MODULE: offer-template-v1.js ===== */ /* Sun Catering: selectable client-offer templates */ (()=>{ 'use strict'; if(window.__sunOfferTemplateV1)return; window.__sunOfferTemplateV1=true; const KEY='sunOfferTemplateV1'; const DEFAULT_ID='cream-elegance'; const TEMPLATES=window.CateriumProposalPDF?.templates||[ {id:'cream-elegance',name:'Кремовая классика',desc:'Светлый премиальный дизайн: крупная обложка, круглые иконки категорий и аккуратная сетка меню.',thumb:''}, {id:'neon-menu',name:'Неоновое меню',desc:'Тёмный дизайн с неоновыми акцентами, нумерованной фотосеткой блюд и карточками категорий.',thumb:''}, {id:'emerald-circles',name:'Изумрудные круги',desc:'Тёмно-изумрудная презентация с круглыми фото блюд и списком категорий.',thumb:''}, {id:'midnight-checklist',name:'Тёмный чек-лист',desc:'Компактный тёмно-зелёный макет: нумерованное меню по категориям и фотополоса блюд.',thumb:''}, {id:'gourmet-hero',name:'Гастро-витрина',desc:'Крупный фото-хиро в духе лендинга, вкладки категорий и карточки блюд.',thumb:''}, {id:'diamond-gold',name:'Изумруд и золото',desc:'Тёмно-зелёный дизайн с золотыми акцентами, нумерованной фотосеткой и блоком скидки.',thumb:''}, {id:'premium-dark',name:'Премиум тёмный',desc:'Строгий тёмный дизайн с золотыми иконками-бейджами дат и гостей, минималистичным списком меню.',thumb:''}, {id:'premium-emerald',name:'Премиум изумрудный',desc:'Тот же строгий тёмный дизайн с иконками-бейджами, но в изумрудно-зелёной палитре.',thumb:''} ]; const esc=window.SunSafe.escapeHTML; function parse(){ try{ const raw=JSON.parse(localStorage.getItem(KEY)||'{}')||{}; const id=TEMPLATES.some(x=>x.id===raw.id)?raw.id:DEFAULT_ID; return {version:1,id}; }catch(_){return {version:1,id:DEFAULT_ID}} } let state=parse(); function selected(){return TEMPLATES.find(x=>x.id===state.id)||TEMPLATES[0]} function save(id){ if(!TEMPLATES.some(x=>x.id===id))id=DEFAULT_ID; state={version:1,id}; localStorage.setItem(KEY,JSON.stringify(state)); window.dispatchEvent(new CustomEvent('sunoffertemplatechange',{detail:{...state,template:selected()}})); } const style=document.createElement('style'); style.id='sun-offer-template-settings-style'; style.textContent=` .sun-offer-template-card{grid-column:1/-1!important} .sun-offer-template-card .hint{max-width:980px} .sun-offer-template-grid{display:grid;grid-template-columns:repeat(4,minmax(200px,1fr));gap:14px;margin-top:14px} .sun-offer-template-option{position:relative;border:1px solid #e3e6e9;border-radius:16px;background:#fff;padding:10px;cursor:pointer;transition:.16s ease;overflow:hidden;box-shadow:0 4px 16px rgba(30,35,40,.035)} .sun-offer-template-option:hover{transform:translateY(-1px);box-shadow:0 8px 22px rgba(30,35,40,.08)} .sun-offer-template-option.on{border-color:var(--sun-brand-sun,#FFC107);box-shadow:0 0 0 2px color-mix(in srgb,var(--sun-brand-sun,#FFC107) 30%,transparent),0 9px 24px rgba(30,35,40,.09)} .sun-offer-template-thumb{display:block;width:100%;height:245px;object-fit:cover;object-position:top center;border-radius:11px;background:#eef0f2;border:1px solid #eceef0} .sun-offer-template-info{padding:10px 3px 2px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px 10px;align-items:start} .sun-offer-template-info strong{font-size:14px;color:#24282d}.sun-offer-template-info p{grid-column:1/-1;margin:0;color:#6c737c;font-size:11px;line-height:1.42} .sun-offer-template-check{width:25px;height:25px;border-radius:50%;border:1px solid #d8dde2;display:grid;place-items:center;color:transparent;font-weight:900;background:#fff} .sun-offer-template-option.on .sun-offer-template-check{background:var(--sun-brand-sun,#FFC107);border-color:var(--sun-brand-sun,#FFC107);color:#1a1a1a} .sun-offer-template-toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:14px;padding-top:12px;border-top:1px solid #eceef1} .sun-offer-template-toolbar .current{font-size:12px;color:#66707a}.sun-offer-template-toolbar .current b{color:#263238} @media(max-width:1100px){.sun-offer-template-grid{grid-template-columns:repeat(2,minmax(210px,1fr))}} @media(max-width:680px){.sun-offer-template-grid{grid-template-columns:1fr}.sun-offer-template-thumb{height:290px}} `; document.head.appendChild(style); function offerTemplateMini(id){ if(window.CateriumProposalPDF?.IDS.includes(id))return ``; if(id==='editorial-grid')return ''; if(id==='midnight-glass')return ''; if(id==='emerald-gold')return ''; return ''; } function cardHtml(){ const cur=selected(); return `

    Шаблон предложения клиенту

    Выберите внешний вид, который будет использоваться в «Предложении клиенту» и в PDF. Реальные фотографии боксов и данные заказа остаются одинаковыми — меняется только дизайн.

    ${TEMPLATES.map(t=>``).join('')}
    Сейчас выбран: ${esc(cur.name)}
    `; } function bind(card){ const choose=(el)=>{ const id=el?.dataset?.offerTemplate;if(!id)return; save(id);card.innerHTML=cardHtml();bind(card); try{window.SunEnterprise?.toast?.(`Шаблон «${selected().name}» выбран.`)}catch(_){ } }; card.querySelectorAll('[data-offer-template]').forEach(el=>{ el.addEventListener('click',()=>choose(el)); el.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();choose(el)}}); }); card.querySelector('[data-offer-template-reset]')?.addEventListener('click',()=>{save(DEFAULT_ID);card.innerHTML=cardHtml();bind(card)}); } function ensureCard(){ const view=document.getElementById('enterprise-settings');if(!view)return; const grid=view.querySelector('.enterprise-grid');if(!grid)return; let card=document.getElementById('sunOfferTemplateSettingsCard'); if(card&&grid.contains(card))return; card=document.createElement('section');card.id='sunOfferTemplateSettingsCard';card.className='enterprise-card wide sun-offer-template-card';card.innerHTML=cardHtml(); const brand=document.getElementById('sunBrandThemeSettingsCard'); if(brand&&brand.parentElement===grid)brand.insertAdjacentElement('afterend',card);else{ const history=[...grid.children].find(el=>(el.querySelector(':scope > h2')?.textContent||'').trim()==='История изменений'); if(history)window.SunSafe.insertBefore(grid,card,history);else grid.appendChild(card); } bind(card); } function boot(){ ensureCard(); const settings=document.getElementById('enterprise-settings'); if(settings)new MutationObserver(()=>queueMicrotask(ensureCard)).observe(settings,{childList:true,subtree:true}); document.addEventListener('click',e=>{const b=e.target.closest('header nav button');if(!b)return;const label=(b.dataset.navLabel||b.textContent||'').trim();if(label==='Настройки')setTimeout(ensureCard,30)},true); window.addEventListener('storage',e=>{if(e.key===KEY){state=parse();ensureCard();window.dispatchEvent(new CustomEvent('sunoffertemplatechange',{detail:{...state,template:selected()}}));}}); window.addEventListener('suncloudsync',()=>{state=parse();setTimeout(ensureCard,25);window.dispatchEvent(new CustomEvent('sunoffertemplatechange',{detail:{...state,template:selected()}}));}); window.addEventListener('sun:cloud-state-applied',()=>{state=parse();setTimeout(ensureCard,25);window.dispatchEvent(new CustomEvent('sunoffertemplatechange',{detail:{...state,template:selected()}}));}); } if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot(); window.SunOfferTemplate={ key:KEY, list:()=>TEMPLATES.map(x=>({...x})), get:()=>({...state,template:{...selected()}}), set:id=>save(id) }; })(); ; /* ===== MODULE: stability-v17.js ===== */ (() => { 'use strict'; if (window.SunStabilityV17) return; const VERSION = '17.7.3'; const RELEASE = 'v17.7.3 Clients Server Read'; const DB_NAME = 'SunStabilityV17'; const DB_VERSION = 1; const DAILY_KEY = 'sunV17DailyBackup'; const ERROR_DEDUPE_MS=5*60*1000; const STABILITY_RPC_TIMEOUT_MS=10000; const NETWORK_BACKOFF_MAX_MS=5*60*1000; const errorSeen=new Map(); let flushBusy=false,mirrorBusy=false,backupBusy=false,initialBusy=false,networkFailures=0,nextNetworkAttemptAt=0; const $ = id => document.getElementById(id); const networkish=e=>/failed to fetch|networkerror|load failed|network|timeout|время ожидания/i.test(String(e?.message||e||'')); function noteNetworkFailure(){networkFailures=Math.min(networkFailures+1,8);nextNetworkAttemptAt=Date.now()+Math.min(NETWORK_BACKOFF_MAX_MS,5000*Math.pow(2,networkFailures-1));} function noteNetworkSuccess(){networkFailures=0;nextNetworkAttemptAt=0;} function networkReady(){return navigator.onLine&&Date.now()>=nextNetworkAttemptAt;} async function stabilityRpc(promise,label){let timer=0;try{return await Promise.race([Promise.resolve(promise),new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error(label+': timeout')),STABILITY_RPC_TIMEOUT_MS)})]);}finally{if(timer)clearTimeout(timer)}} const esc=window.SunSafe.escapeHTML; const toast=(t,k='info',ms=4500)=>{try{return window.SunEnterprise?.toast?.(t,k,ms)}catch(_){} console.log('[V17]',t)}; const cloud=()=>window.SunCloudV2; const client=()=>cloud()?.getClient?.()||null; const workspace=()=>cloud()?.getWorkspace?.()||null; const clientId=()=>cloud()?.getClientId?.()||'local'; let lastMirrorAt=''; let conflictCount=0; let booted=false; function openDb(){ return new Promise((resolve,reject)=>{ const r=indexedDB.open(DB_NAME,DB_VERSION); r.onupgradeneeded=()=>{ const db=r.result; if(!db.objectStoreNames.contains('outbox'))db.createObjectStore('outbox',{keyPath:'key'}); if(!db.objectStoreNames.contains('errors'))db.createObjectStore('errors',{keyPath:'id'}); if(!db.objectStoreNames.contains('meta'))db.createObjectStore('meta',{keyPath:'key'}); }; r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error); }); } async function idbPut(store,value){try{const db=await openDb();await new Promise((res,rej)=>{const tx=db.transaction(store,'readwrite');tx.objectStore(store).put(value);tx.oncomplete=res;tx.onerror=()=>rej(tx.error)});return true}catch(_){return false}} async function idbGet(store,key){try{const db=await openDb();return await new Promise((res,rej)=>{const r=db.transaction(store,'readonly').objectStore(store).get(key);r.onsuccess=()=>res(r.result||null);r.onerror=()=>rej(r.error)})}catch(_){return null}} async function idbDelete(store,key){try{const db=await openDb();await new Promise((res,rej)=>{const tx=db.transaction(store,'readwrite');tx.objectStore(store).delete(key);tx.oncomplete=res;tx.onerror=()=>rej(tx.error)});return true}catch(_){return false}} async function idbAll(store){try{const db=await openDb();return await new Promise((res,rej)=>{const r=db.transaction(store,'readonly').objectStore(store).getAll();r.onsuccess=()=>res(r.result||[]);r.onerror=()=>rej(r.error)})}catch(_){return[]}} async function markPending(reason='change'){ const ws=workspace()?.id||'local'; const old=await idbGet('outbox',`sync:${ws}`); await idbPut('outbox',{key:`sync:${ws}`,workspaceId:ws,reason:String(reason),createdAt:old?.createdAt||Date.now(),updatedAt:Date.now(),attempts:Number(old?.attempts||0)+1}); refreshCardSoon(); } async function markSynced(revision){ const ws=workspace()?.id||'local'; await idbDelete('outbox',`sync:${ws}`); await idbPut('meta',{key:`lastSync:${ws}`,at:Date.now(),revision:Number(revision||0)}); refreshCardSoon(); } async function recordConflict(message){ conflictCount++; await idbPut('meta',{key:'lastConflict',at:Date.now(),message:String(message||''),count:conflictCount}); refreshCardSoon(); } function context(){ return {url:location.href,online:navigator.onLine,userAgent:navigator.userAgent,release:RELEASE,cloudStatus:cloud()?.status?.()||null}; } async function queueError(message,stack='',level='error',extra={}){ const text=String(message||'Unknown error').slice(0,4000),wid=workspace()?.id||null,key=level+'|'+(wid||'')+'|'+text,now=Date.now(); const seen=errorSeen.get(key)||0;if(now-seen{if(e?.error?.__sunV17Reported)return;queueError(e?.message||e?.error?.message||'Window error',e?.error?.stack||'', 'error',{source:e?.filename||'',line:e?.lineno||0,column:e?.colno||0});}); window.addEventListener('unhandledrejection',e=>{const r=e.reason;queueError(`Unhandled promise: ${r?.message||r||'unknown'}`,r?.stack||'','error');}); } async function diagnostics(){ const ws=workspace(),c=client();const outbox=await idbGet('outbox',`sync:${ws?.id||'local'}`);const errors=(await idbAll('errors')).length; let normalized=null,backups=[]; if(c&&ws?.id&&navigator.onLine){ try{const r=await c.rpc('sun_v17_entity_snapshot',{p_workspace:ws.id});if(!r.error)normalized=r.data}catch(_){} try{const r=await c.rpc('sun_v17_list_backups',{p_workspace:ws.id,p_limit:30});if(!r.error)backups=r.data||[]}catch(_){} } let localOrders=0,localCatalog=0;try{localOrders=JSON.parse(localStorage.getItem('sunOrders')||'[]').length}catch(_){}try{localCatalog=JSON.parse(localStorage.getItem('sunBoxes')||'[]').length}catch(_){} return {ws,c,outbox,errors,normalized,backups,localOrders,localCatalog,cloudStatus:cloud()?.status?.()||{}}; } async function createManualBackup(){ const c=client(),ws=workspace();if(!c||!ws?.id)return toast('Сначала войдите в облачную рабочую базу.','warn'); try{const {error}=await c.rpc('sun_v17_create_backup',{p_workspace:ws.id,p_kind:'manual',p_label:`Ручная копия · ${new Date().toLocaleString('ru-RU')}`});if(error)throw error;toast('Резервная копия создана.','success');await renderCard()}catch(e){toast(e.message||String(e),'error')} } async function restoreBackup(id){ const c=client(),ws=workspace();if(!c||!ws?.id||!id)return; if(!confirm('Восстановить выбранную резервную копию? Перед восстановлением будет автоматически создана ещё одна копия текущего состояния.'))return; try{const {error}=await c.rpc('sun_v17_restore_backup',{p_workspace:ws.id,p_backup:id});if(error)throw error;toast('Копия восстановлена. Загружаю данные…','success');await cloud()?.pull?.();setTimeout(()=>location.reload(),500)}catch(e){toast(e.message||String(e),'error')} } function emergencyExport(){ const data={format:'sun-v17-emergency-local',version:17,createdAt:new Date().toISOString(),storage:{}}; for(let i=0;iURL.revokeObjectURL(a.href),1000); } function findSettingsGrid(){return document.querySelector('#enterprise-settings .enterprise-grid')} function installStyle(){if($('sunV17Style'))return;const s=document.createElement('style');s.id='sunV17Style';s.textContent=` .sun-v17-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:9px}.sun-v17-stat{border:1px solid #e1e6e8;border-radius:10px;padding:10px;background:#fbfcfc}.sun-v17-stat small{display:block;color:#77838b;margin-bottom:4px}.sun-v17-stat b{font-size:15px;color:#15364c}.sun-v17-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}.sun-v17-backups{margin-top:12px;border-top:1px solid #e4e8ea;padding-top:10px}.sun-v17-backup{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:center;padding:7px 0;border-bottom:1px dashed #e5e8ea}.sun-v17-backup small{display:block;color:#7a858c}.sun-v17-health{font-weight:800}.sun-v17-health.good{color:#347154}.sun-v17-health.warn{color:#9a7016}.sun-v17-health.bad{color:#a64040} @media(max-width:900px){.sun-v17-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:600px){.sun-v17-grid{grid-template-columns:1fr}} `;document.head.appendChild(s)} async function renderCard(){ // Backup/stability controls are intentionally hidden from ordinary Settings. $('sunV17StabilityCard')?.remove(); } let refreshTimer=null;function refreshCardSoon(){clearTimeout(refreshTimer);refreshTimer=setTimeout(()=>{if($('enterprise-settings')?.classList.contains('on'))renderCard()},250)} function observeSettings(){ document.addEventListener('click',e=>{const b=e.target.closest('header nav button');if(!b)return;if(String(b.dataset.navLabel||b.textContent||'').trim()==='Настройки')setTimeout(renderCard,120)},true); const root=$('enterprise-settings');if(root)new MutationObserver(()=>{if(root.classList.contains('on')&&!$('sunV17StabilityCard'))setTimeout(renderCard,80)}).observe(root,{childList:true,subtree:true,attributes:true,attributeFilter:['class']}); } async function initialMirrorIfPossible(){ const c=client(),ws=workspace();if(initialBusy||!c||!ws?.id||!networkReady())return;initialBusy=true; try{const payload=await cloud()?.snapshotLocal?.();if(payload)await mirror(payload);await ensureDailyBackup();await flushErrors()}catch(e){await queueError(e.message||String(e),e.stack||'','warn')} finally{initialBusy=false} } function boot(){if(booted)return;booted=true;installStyle();installErrorHooks();observeSettings();window.addEventListener('sun:cloud-sync-complete',onCloudSync);window.addEventListener('online',()=>{noteNetworkSuccess();flushErrors();setTimeout(initialMirrorIfPossible,900)});window.addEventListener('sun:cloud-permissions-changed',()=>setTimeout(initialMirrorIfPossible,900));setTimeout(initialMirrorIfPossible,2600);setInterval(()=>{if(networkReady())flushErrors()},60000)} window.SunStabilityV17={VERSION,RELEASE,markPending,markSynced,recordConflict,queueError,renderCard,diagnostics,createBackup:createManualBackup,emergencyExport}; if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot(); })(); ; /* ===== MODULE: saas-v16.js ===== */ /* Sun Catering SaaS subscriptions, plan entitlements and platform admin v16 */ (()=>{ 'use strict'; if(window.SunSaaSV16)return; const VERSION='2026.08.30-saas-v16'; const $=id=>document.getElementById(id); const qa=(s,r=document)=>[...r.querySelectorAll(s)]; const esc=window.SunSafe.escapeHTML; const cloud=()=>window.SunCloudV2||null; const client=()=>cloud()?.getClient?.()||null; const workspace=()=>cloud()?.getWorkspace?.()||null; const session=()=>cloud()?.getSession?.()||null; const toast=(text,type='info',timeout=4500)=>{try{return window.SunEnterprise?.toast?.(text,type,timeout)}catch(_){console.log('[SaaS]',text)}}; const PLAN_LABELS={basic:'Базовый',professional:'Профессиональный',full:'Полный'}; const FEATURE_LABELS={ orders:'Заказы',calendar:'Календарь',map:'Карта',clients:'Клиенты',catalog_view:'Каталог',catalog_edit:'Редактирование боксов', production:'Производство',shopping:'Закупки',stock:'Склад',routes:'Маршруты',mailings:'Рассылки',money:'Деньги', stats_basic:'Статистика',stats_advanced:'Расширенная статистика',team:'Команда',suppliers:'Поставщики',print:'Печать',settings:'Настройки', branding:'Фирменное оформление',client_offers:'Предложения клиенту',offer_templates:'Шаблоны предложений',backups:'Резервные копии',audit:'История изменений',users_manage:'Сотрудники и права' }; const NAV_FEATURE={ 'Главная':'orders','Новый заказ':'orders','Заказы':'orders','Календарь':'calendar','Карта':'map','Производство':'production','Закупки':'shopping','Закупка':'shopping', 'Склад':'stock','Клиенты':'clients','Рассылки':'mailings','Деньги':'money','Команда':'team','Поставщики':'suppliers','Статистика':'stats_basic','Печать':'print','Маршруты':'routes','Настройки':'settings' }; const WRITE_FN_FEATURE={ saveBox:'catalog_edit',removeBox:'catalog_edit',editBox:'catalog_edit',openManager:'catalog_edit', sunOpenClientOffer:'client_offers',sunOpenCurrentClientOffer:'client_offers' }; const PLATFORM_FEATURES=['client_offers','offer_templates','catalog_edit','branding','users_manage','money','stock','production','routes','mailings','suppliers','audit','backups']; let snapshot=null; let lastWorkspaceId=''; let loading=false; let guardedFns=new Map(); let adminRows=[]; function hasFeature(key){ if(!key)return true; if(!snapshot)return true; // do not flash-lock before the server snapshot arrives return snapshot.features?.[key]===true; } function isWritable(){if(cloud()?.isSupportMode?.())return false;return !snapshot||snapshot.access_mode==='full';} function accessLabel(mode){return mode==='full'?'Активен':mode==='read_only'?'Только просмотр':'Заблокирован';} function statusLabel(status){return ({trialing:'Пробный период',active:'Активна',past_due:'Оплата просрочена',canceled:'Отменена',expired:'Истекла'})[status]||status||'—';} function formatDate(v){if(!v)return 'без срока';const d=new Date(v);if(Number.isNaN(d.getTime()))return '—';return d.toLocaleDateString('ru-RU');} function endDate(){return snapshot?.status==='trialing'?snapshot?.trial_ends_at:snapshot?.current_period_end;} function daysLeft(){const end=endDate();if(!end)return null;return Math.ceil((new Date(end).getTime()-Date.now())/86400000);} async function refreshSnapshot(force=false){ const c=client(),ws=workspace(); if(!c||!ws?.id){snapshot=null;lastWorkspaceId='';removeSubscriptionUi();return null;} if(loading)return snapshot; if(!force&&lastWorkspaceId===ws.id&&snapshot)return snapshot; loading=true; try{ const {data,error}=await c.rpc('sun_subscription_snapshot',{p_workspace:ws.id}); if(error)throw error; snapshot=Array.isArray(data)?(data[0]||null):data; lastWorkspaceId=ws.id; applyAll(); window.dispatchEvent(new CustomEvent('sun:subscription-changed',{detail:snapshot})); return snapshot; }catch(err){console.error('[SaaS] subscription snapshot',err);snapshot=null;} finally{loading=false;} return snapshot; } function installStyle(){ if($('sunSaaSV16Style'))return; const s=document.createElement('style');s.id='sunSaaSV16Style';s.textContent=` .sun-plan-locked{position:relative!important;opacity:.62!important;filter:saturate(.65)} header nav button.sun-plan-locked::after{content:'🔒';font-size:10px;margin-left:auto;padding-left:6px} .sun-side-collapsed #sunSaaSBadgeV16{display:none!important} .sun-saas-plan-card-locked{position:relative;opacity:.62} .sun-saas-plan-card-locked::after{content:'Доступно на другом тарифе';position:absolute;inset:auto 12px 12px auto;background:#241f3b;color:#fff;border-radius:999px;padding:5px 9px;font-size:11px;font-weight:800;z-index:4} .sun-saas-badge{margin:8px 10px 0;border:1px solid #ffffff24;border-radius:11px;padding:8px 9px;color:#fff;background:#ffffff10;font-size:11px;line-height:1.35} .sun-saas-badge b{display:block;font-size:12px}.sun-saas-badge .warn{color:#ffd36b} .sun-saas-readonly-banner{position:fixed;top:10px;left:50%;transform:translateX(-50%);z-index:9800;background:#fff6df;border:1px solid #e2bd55;color:#6a5216;border-radius:12px;padding:9px 14px;box-shadow:0 8px 30px #0002;font-weight:800;max-width:min(680px,calc(100vw - 24px));text-align:center} .sun-saas-blocked{position:fixed;inset:0;z-index:12000;background:#122b3cf2;display:grid;place-items:center;padding:20px}.sun-saas-blocked-card{width:min(520px,100%);background:#fff;border-radius:18px;padding:24px;box-shadow:0 30px 90px #0007;text-align:center}.sun-saas-blocked-card img{width:72px;height:72px;object-fit:contain}.sun-saas-blocked-card h2{margin:8px 0;color:#17384d}.sun-saas-blocked-card p{color:#596773;line-height:1.5} .sun-saas-modal{position:fixed;inset:0;z-index:11500;background:#0d2130c9;display:grid;place-items:center;padding:18px}.sun-saas-modal-card{width:min(900px,100%);max-height:92vh;overflow:auto;background:#fff;border-radius:18px;padding:20px;box-shadow:0 30px 90px #0007}.sun-saas-modal-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.sun-saas-modal-head h2{margin:0;color:#17384d}.sun-saas-modal-head button{border:0;background:#eef1f2;border-radius:50%;width:34px;height:34px;font-size:20px}.sun-saas-plans{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:16px}.sun-saas-plan{border:1px solid #dfe5e8;border-radius:14px;padding:15px;background:#fafcfc}.sun-saas-plan.current{border:2px solid #ffb400;background:#fffaf0}.sun-saas-plan h3{margin:0 0 5px;color:#18384b}.sun-saas-plan ul{padding-left:18px;color:#4f5b64;font-size:12px;line-height:1.55}.sun-saas-plan .muted{color:#89939a}.sun-saas-upgrade{margin-top:12px;background:#eef4f6;border-radius:12px;padding:12px;color:#52606a} .sun-saas-settings-card .sun-saas-status-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:9px;margin-top:12px}.sun-saas-kpi{border:1px solid #e2e7ea;border-radius:11px;padding:10px;background:#fafcfc}.sun-saas-kpi small{display:block;color:#7a858d;margin-bottom:4px}.sun-saas-kpi b{color:#203846} #sun-saas-admin{padding:20px}.sun-saas-admin-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap}.sun-saas-admin-table{display:grid;gap:10px;margin-top:16px}.sun-saas-company{border:1px solid #dfe5e8;border-radius:14px;padding:14px;background:#fff}.sun-saas-company-top{display:flex;justify-content:space-between;gap:10px;align-items:flex-start;flex-wrap:wrap}.sun-saas-company h3{margin:0;color:#18384b}.sun-saas-company small{color:#78828a}.sun-saas-company-actions{display:grid;grid-template-columns:1fr 110px auto auto;gap:8px;margin-top:11px;align-items:end}.sun-saas-company-actions label{font-size:11px;color:#64717a}.sun-saas-company-actions select{width:100%}.sun-saas-company-meta{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}.sun-saas-pill{border-radius:999px;padding:5px 8px;background:#eef3f5;font-size:11px}.sun-saas-pill.good{background:#eaf7ef;color:#347153}.sun-saas-pill.warn{background:#fff5dc;color:#8a681b}.sun-saas-pill.bad{background:#ffebe9;color:#993f3d} .sun-saas-onboarding-tabs{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:10px}.sun-saas-onboarding-choice{border:1px solid #dfe5e8;background:#fafcfc;border-radius:12px;padding:12px;text-align:left}.sun-saas-onboarding-choice b{display:block;color:#17384d;margin-bottom:3px}.sun-saas-onboarding-choice small{color:#6f7980;line-height:1.35} @media(max-width:760px){.sun-saas-plans{grid-template-columns:1fr}.sun-saas-settings-card .sun-saas-status-grid{grid-template-columns:1fr 1fr}.sun-saas-company-actions{grid-template-columns:1fr 1fr}.sun-saas-company-actions button{width:100%}} `;document.head.appendChild(s); } function removeSubscriptionUi(){ $('sunSaaSBadgeV16')?.remove();$('sunSaaSReadonlyV16')?.remove();$('sunSaaSBlockedV16')?.remove();$('sunSaaSSettingsCardV16')?.remove(); qa('[data-sun-plan-locked]').forEach(el=>{el.removeAttribute('data-sun-plan-locked');el.classList.remove('sun-plan-locked')}); } function featureForNav(btn){return NAV_FEATURE[String(btn.dataset.navLabel||btn.textContent||'').trim()]||null;} function markElement(el,feature){ if(!el)return; if(feature&&!hasFeature(feature)){el.dataset.sunPlanLocked=feature;el.classList.add('sun-plan-locked');el.title=`${FEATURE_LABELS[feature]||feature} недоступно на тарифе «${snapshot?.plan_name||''}»`;} else{el.removeAttribute('data-sun-plan-locked');el.classList.remove('sun-plan-locked');} } function applyFeatureGates(){ if(!snapshot)return; qa('header nav button').forEach(btn=>{if(btn.id==='sunSaaSAdminNavV16')return;markElement(btn,featureForNav(btn));}); markElement($('sunClientOfferButton'),'client_offers');markElement($('sunAutoOfferButton'),'client_offers');markElement($('sunCloudManageUsersV3'),'users_manage');markElement($('sunCloudCreateInviteV2'),'users_manage'); qa('#new .catalog-head button,#new .tile.add').forEach(el=>markElement(el,'catalog_edit')); const cards=[ ['sunClientOfferSettingsCard','client_offers'],['sunOfferTemplateSettingsCard','offer_templates'],['sunBrandThemeSettingsCard','branding'] ]; cards.forEach(([id,feature])=>{const el=$(id);if(!el)return;el.dataset.sunFeature=feature;el.classList.toggle('sun-saas-plan-card-locked',!hasFeature(feature));}); qa('#enterprise-settings section').forEach(card=>{ const h=(card.querySelector(':scope > h2')?.textContent||'').trim();let f=null; if(/Резервн/i.test(h))f='backups';else if(/История изменений/i.test(h))f='audit'; if(f){card.dataset.sunFeature=f;card.classList.toggle('sun-saas-plan-card-locked',!hasFeature(f));} }); } function guardFunctions(){ Object.entries(WRITE_FN_FEATURE).forEach(([name,feature])=>{ const fn=window[name];if(typeof fn!=='function')return; if(fn.__sunSaasGuard)return; const wrapped=function(...args){ if(!isWritable())return showUpgrade(feature,'Подписка сейчас работает в режиме просмотра.'); if(!hasFeature(feature))return showUpgrade(feature); return fn.apply(this,args); }; wrapped.__sunSaasGuard=true;wrapped.__sunSaasOriginal=fn;window[name]=wrapped;guardedFns.set(name,wrapped); }); } function injectBadge(){ if(!snapshot)return; let badge=$('sunSaaSBadgeV16'); const brand=document.querySelector('header .brand'); if(!brand)return; if(!badge){badge=document.createElement('div');badge.id='sunSaaSBadgeV16';badge.className='sun-saas-badge';brand.insertAdjacentElement('afterend',badge);} const d=daysLeft(), trial=snapshot.status==='trialing'; badge.innerHTML=`${esc(snapshot.plan_name||PLAN_LABELS[snapshot.plan_id]||'Тариф')}${trial?`Пробный период${d!=null?` · ${Math.max(0,d)} дн.`:''}`:accessLabel(snapshot.access_mode)}`; badge.onclick=()=>showPlans();badge.title='Тариф и подписка';badge.style.cursor='pointer'; } function applyAccessMode(){ $('sunSaaSReadonlyV16')?.remove();$('sunSaaSBlockedV16')?.remove(); if(!snapshot)return; applyReadOnlyControls(); if(snapshot.access_mode==='read_only'){ const b=document.createElement('div');b.id='sunSaaSReadonlyV16';b.className='sun-saas-readonly-banner';b.textContent=`Подписка закончилась. До ${formatDate(snapshot.grace_until)} база доступна только для просмотра.`;document.body.appendChild(b); }else if(snapshot.access_mode==='blocked'){ const x=document.createElement('div');x.id='sunSaaSBlockedV16';x.className='sun-saas-blocked';x.innerHTML=`

    Доступ к компании приостановлен

    Подписка закончилась. Все данные сохранены и будут доступны сразу после продления.

    `;document.body.appendChild(x); x.querySelector('[data-saas-show-plans]').onclick=()=>showPlans();x.querySelector('[data-saas-signout]').onclick=async()=>{if(window.SunCloudV2?.signOut)return window.SunCloudV2.signOut();try{await client()?.auth.signOut()}catch(_){}location.reload();}; } } function applyReadOnlyControls(){ qa('[data-saas-readonly-disabled]').forEach(el=>{el.disabled=false;el.removeAttribute('data-saas-readonly-disabled')}); if(snapshot?.access_mode!=='read_only'&&!cloud()?.isSupportMode?.())return; qa('#new input,#new textarea,#new select,#manager input,#manager textarea,#manager select,#editor input,#editor textarea,#editor select,#orders select,#enterprise-settings input,#enterprise-settings textarea,#enterprise-settings select').forEach(el=>{if(!el.disabled){el.disabled=true;el.dataset.saasReadonlyDisabled='1';}}); } function showPlans(feature=null,note=''){ $('sunSaaSPlansModalV16')?.remove(); const m=document.createElement('div');m.id='sunSaaSPlansModalV16';m.className='sun-saas-modal'; const cards=[ {id:'basic',name:'Базовый',members:'1 пользователь',inc:['Заказы, календарь и клиенты','Каталог для работы с заказом','Базовая статистика и печать'],out:['Редактирование боксов','Предложения клиенту','Финансы и склад']}, {id:'professional',name:'Профессиональный',members:'до 3 пользователей',inc:['Всё из Базового','Производство, склад и закупки','Финансы, поставщики и маршруты','Фирменное оформление'],out:['Редактирование боксов','Предложения клиенту']}, {id:'full',name:'Полный',members:'без лимита',inc:['Все функции приложения','Редактирование каталога и боксов','Предложения клиенту и шаблоны','Команда, права и расширенные настройки'],out:[]} ]; m.innerHTML=`

    Тарифы

    ${feature?`Для функции «${esc(FEATURE_LABELS[feature]||feature)}» нужен другой тариф.`:'Выберите набор функций для компании.'}
    ${note?`
    ${esc(note)}
    `:''}
    ${cards.map(p=>`

    ${p.name}

    ${p.members}
      ${p.inc.map(x=>`
    • ${esc(x)}
    • `).join('')}${p.out.map(x=>`
    • — ${esc(x)}
    • `).join('')}
    ${snapshot?.plan_id===p.id?'Текущий тариф':'Подключение оплаты добавим на следующем этапе.'}
    `).join('')}
    Сейчас тариф можно изменить из вашего SaaS-кабинета. Автоматическую оплату подключим отдельным этапом.
    `; document.body.appendChild(m);m.onclick=e=>{if(e.target===m||e.target.closest('[data-close]'))m.remove();}; } function injectSettingsCard(){ if(!snapshot)return; const settings=$('enterprise-settings');if(!settings)return; let card=$('sunSaaSSettingsCardV16');if(!card){card=document.createElement('section');card.id='sunSaaSSettingsCardV16';card.className='enterprise-card wide sun-saas-settings-card';const first=settings.querySelector('.enterprise-grid')||settings;first.prepend(card);} const end=endDate(),d=daysLeft(); card.innerHTML=`

    Тариф и подписка

    Подписка относится ко всей компании. Данные этой рабочей базы изолированы от других компаний.

    Тариф${esc(snapshot.plan_name||PLAN_LABELS[snapshot.plan_id])}
    Статус${esc(statusLabel(snapshot.status))}
    ${snapshot.status==='trialing'?'Пробный период до':'Оплачено до'}${formatDate(end)}${d!=null&&d>=0?` · ${d} дн.`:''}
    Сотрудники${Number(snapshot.member_count||0)} / ${snapshot.max_members==null?'∞':snapshot.max_members}
    ${snapshot.platform_admin?'':''}
    `; card.querySelector('[data-saas-plans]').onclick=()=>showPlans();card.querySelector('[data-saas-admin]')?.addEventListener('click',openPlatformAdmin); } function ensureAdminView(){ if($('sun-saas-admin'))return $('sun-saas-admin'); const v=document.createElement('section');v.id='sun-saas-admin';v.className='view page';document.body.appendChild(v);return v; } function ensureAdminNav(){ const nav=document.querySelector('header nav');if(!nav||!snapshot?.platform_admin)return; if(window.SunDeveloperV22){$('sunSaaSAdminNavV16')?.remove();window.SunDeveloperV22.ensureNav?.();return;} let b=$('sunSaaSAdminNavV16');if(!b){b=document.createElement('button');b.id='sunSaaSAdminNavV16';b.type='button';b.dataset.navLabel='SaaS';b.textContent='SaaS';nav.appendChild(b);b.onclick=()=>openPlatformAdmin(b);} b.hidden=false;b.style.display=''; } async function openPlatformAdmin(btn=null){ if(window.SunDeveloperV22?.open)return window.SunDeveloperV22.open(btn); if(!snapshot?.platform_admin)return; const v=ensureAdminView();qa('.view').forEach(x=>x.classList.remove('on'));v.classList.add('on');qa('header nav button').forEach(x=>x.classList.remove('on'));(btn||$('sunSaaSAdminNavV16'))?.classList.add('on'); v.innerHTML='

    SaaS · компании

    Тарифы, сроки и доступы всех компаний.

    Загружаю компании…

    '; v.querySelector('[data-reload]').onclick=()=>loadAdminRows();await loadAdminRows(); } async function loadAdminRows(){ const v=ensureAdminView(),c=client();if(!c)return; const keepHead=v.querySelector('.sun-saas-admin-head')?.outerHTML||'';v.innerHTML=keepHead+'

    Загружаю компании…

    ';v.querySelector('[data-reload]')?.addEventListener('click',loadAdminRows); const {data,error}=await c.rpc('sun_dev_list_companies');if(error){v.insertAdjacentHTML('beforeend',`

    ${esc(error.message)}

    `);return;} adminRows=data||[]; const list=document.createElement('div');list.className='sun-saas-admin-table';list.innerHTML=adminRows.map(r=>adminRowHtml(r)).join('')||'

    Компаний пока нет.

    ';v.appendChild(list);bindAdminRows(list); } function adminRowHtml(r){ const access=r.access_mode,cls=access==='full'?'good':access==='read_only'?'warn':'bad'; return `

    ${esc(r.workspace_name)}

    ${esc(r.workspace_id)}
    ${esc(accessLabel(access))}${esc(PLAN_LABELS[r.plan_id]||r.plan_name||'—')}${Number(r.member_count||0)} / ${r.max_members==null?'∞':r.max_members} сотрудниковдо ${formatDate(r.trial_ends_at||r.current_period_end)}
    Индивидуальная функция
    `; } function bindAdminRows(root){ qa('.sun-saas-company',root).forEach(row=>{ const ws=row.dataset.ws; row.querySelector('[data-activate]').onclick=async()=>{const plan=row.querySelector('[data-plan]').value,days=Number(row.querySelector('[data-days]').value);await setSubscription(ws,plan,days,'active');}; row.querySelector('[data-expire]').onclick=async()=>{if(!confirm('Заблокировать доступ этой компании? Данные не удалятся.'))return;await setSubscription(ws,row.querySelector('[data-plan]').value,0,'expired');}; row.querySelector('[data-feature-enable]').onclick=async()=>{const feature=row.querySelector('[data-feature]').value,days=Number(row.querySelector('[data-feature-days]').value);const r=await client().rpc('sun_dev_set_feature_override',{p_workspace:ws,p_feature:feature,p_enabled:true,p_days:days,p_note:'Выдано из SaaS-кабинета'});if(r.error)return toast(r.error.message,'error');toast('Индивидуальный доступ выдан.','success');}; }); } async function setSubscription(ws,plan,days,status){ const {error}=await client().rpc('sun_dev_set_subscription',{p_workspace:ws,p_plan:plan,p_days:days,p_status:status});if(error)return toast(error.message,'error');toast('Подписка обновлена.','success');await loadAdminRows();if(workspace()?.id===ws)await refreshSnapshot(true); } function clearWorkspaceLocalData(){ const preserve=new Set(['sunCloudV2Config','sunCloudClientIdV2','sunStaticMapGeocodeCacheV2']); window.dispatchEvent(new CustomEvent('sun:cloud-tenant-changing')); try{if(typeof orders!=='undefined')orders=[];if(typeof boxes!=='undefined')boxes=[];}catch(_){} const keys=[];for(let i=0;ilocalStorage.removeItem(k)); } function seedWorkspace(){clearWorkspaceLocalData();localStorage.setItem('sunBoxes','[]');localStorage.setItem('sunOrders','[]');} function enhanceOnboardingGate(){ const gate=$('sunCloudAuthGateV3'),ss=session(),ws=workspace(),st=cloud()?.status?.();if(window.SunDeveloperV22?.isPlatformAdmin?.())return;if(!gate||!ss?.user||ws||st?.membershipsLoading||st?.membershipError||st?.membershipsLoaded===false||gate.dataset.saasEnhanced==='1')return; gate.dataset.saasEnhanced='1';const card=gate.querySelector('.sun-cloud-auth-card');if(!card)return; card.innerHTML=`

    Создайте компанию

    ${esc(ss.user.email||'Аккаунт авторизован')} · 14 дней Полного тарифа бесплатно

    У этого аккаунта пока нет рабочей базы. Приглашения временно отключены на период локального тестирования.

    `; const err=$('sunSaaSGateErrorV16'); qa('[data-create]',card).forEach(b=>b.onclick=async()=>{ const name=String($('sunSaaSCompanyNameV16')?.value||'').trim();if(!name){err.textContent='Введите название компании.';return;}b.disabled=true;err.textContent='Создаю рабочую базу…'; try{ const r=await client().rpc('sun_create_workspace',{p_name:name});if(r.error)throw r.error; const workspaceId=String(r.data||''); seedWorkspace(b.dataset.create);await cloud().adoptCurrentLocalAsWorkspace?.(workspaceId);await cloud().reloadMemberships();err.textContent='Загружаю стартовую базу…';await cloud().migrate();location.reload(); }catch(e){err.textContent=e?.message||String(e);b.disabled=false;} }); card.querySelector('[data-signout]').onclick=async()=>{if(window.SunCloudV2?.signOut)return window.SunCloudV2.signOut();try{await client()?.auth.signOut()}catch(_){}location.reload();}; } function applyAll(){installStyle();injectBadge();applyAccessMode();applyFeatureGates();guardFunctions();injectSettingsCard();ensureAdminNav();} document.addEventListener('click',e=>{ const locked=e.target.closest('[data-sun-plan-locked]');if(locked){e.preventDefault();e.stopImmediatePropagation();return showUpgrade(locked.dataset.sunPlanLocked);} const card=e.target.closest('.sun-saas-plan-card-locked');if(card){e.preventDefault();e.stopImmediatePropagation();return showUpgrade(card.dataset.sunFeature);} if(snapshot?.access_mode==='read_only'||cloud()?.isSupportMode?.()){ const t=e.target.closest('button');if(!t)return; const text=(t.textContent||'').trim();const safe=/^(Закрыть|Обновить|Предварительный просмотр|Печать|Скачать|Сравнить тарифы|Выйти|Назад|Фильтр|Поиск)/i.test(text); const mutating=/Сохран|Удал|Добав|Создать|Измен|Настро|Списать|Отправ|Активир|Заблокир|Выдать|Примен/i.test(text); if(mutating&&!safe){e.preventDefault();e.stopImmediatePropagation();showUpgrade(null,cloud()?.isSupportMode?.()?'Режим поддержки: данные компании доступны только для просмотра.':'Льготный период: база доступна только для просмотра.');} } },true); function showUpgrade(feature,note=''){showPlans(feature,note);} function boot(){ installStyle(); const timer=setInterval(async()=>{ if(document.hidden)return; // v17.5.27: onboarding is handled by the unified Caterium auth gate. const ws=workspace(); if(ws?.id&&(ws.id!==lastWorkspaceId||!snapshot))await refreshSnapshot(true); else if(ws?.id&&snapshot){applyFeatureGates();guardFunctions();injectSettingsCard();ensureAdminNav();} else if(!ws){snapshot=null;lastWorkspaceId='';} },5000); window.addEventListener('sun:cloud-state-applied',()=>refreshSnapshot(true)); window.addEventListener('sun:cloud-permissions-changed',()=>setTimeout(()=>{applyFeatureGates();guardFunctions();},50)); document.addEventListener('click',e=>{const b=e.target.closest('header nav button');if(b&&String(b.dataset.navLabel||b.textContent||'').trim()==='Настройки')setTimeout(()=>{injectSettingsCard();applyFeatureGates();},100);},true); setTimeout(()=>refreshSnapshot(true),1200); } window.SunSaaSV16={VERSION,getSnapshot:()=>snapshot?structuredClone(snapshot):null,refresh:()=>refreshSnapshot(true),hasFeature,isWritable,showPlans,openAdmin:openPlatformAdmin}; if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot(); })(); ; /* ===== MODULE: settings-polish-v1.js ===== */ /* Sun Catering v17.5.4: settings layout polish */ (()=>{ 'use strict'; if(window.__sunSettingsPolishV1)return; window.__sunSettingsPolishV1=true; const $=id=>document.getElementById(id); const qa=(sel,root=document)=>[...root.querySelectorAll(sel)]; const style=document.createElement('style'); style.id='sun-settings-polish-v1-style'; style.textContent=` #enterprise-settings>.catalog-head{ position:sticky;top:0;z-index:45; margin:0 -4px 14px;padding:11px 4px 12px; background:linear-gradient(180deg,rgba(255,255,255,.99) 0%,rgba(255,255,255,.96) 80%,rgba(255,255,255,0) 100%); backdrop-filter:blur(9px); } #enterprise-settings .sun-settings-cloud-tail{grid-column:1/-1!important} `; document.head.appendChild(style); function settingsNavButton(){ return qa('header nav button').find(b=>String(b.dataset.navLabel||b.textContent||'').trim()==='Настройки')||null; } function scrollSettingsTop(){ const view=$('enterprise-settings'); try{window.scrollTo({top:0,left:0,behavior:'auto'})}catch(_){window.scrollTo(0,0)} try{document.scrollingElement&&(document.scrollingElement.scrollTop=0)}catch(_){} try{view&&(view.scrollTop=0)}catch(_){} view?.querySelector('.catalog-head')?.scrollIntoView?.({block:'start',inline:'nearest'}); } function auditCard(grid){ return qa(':scope > .enterprise-card',grid).find(card=>(card.querySelector(':scope > h2')?.textContent||'').trim()==='История изменений')||null; } function cloudCard(grid){ return $('sunCloudV2Card')||qa(':scope > .enterprise-card',grid).find(card=>/^(Облачная синхронизация|Облако Supabase)$/.test((card.querySelector(':scope > h2')?.textContent||'').trim()))||null; } function moveBefore(node,anchor){ if(!node||!anchor||node===anchor||node.parentElement!==anchor.parentElement)return false; if(node.nextElementSibling===anchor)return false; window.SunSafe.insertBefore(anchor.parentElement,node,anchor);return true; } let ordering=false; function orderSettingsCards(){ if(ordering)return; const grid=document.querySelector('#enterprise-settings .enterprise-grid');if(!grid)return; ordering=true; try{ const audit=auditCard(grid); if(!audit)return; const requisites=$('sunReceiptSettingsCard'); const qr=$('sunDefaultQrCard'); const blank=$('sunBlankSettingsCardV3'); const cloud=cloudCard(grid); if(cloud)cloud.classList.add('sun-settings-cloud-tail','wide'); // Bottom operational block: company requisites -> QR -> order blank -> cloud -> history. // Reinsert in the desired order so dynamically injected cards settle into one stable tail block. [requisites,qr,blank,cloud].forEach(card=>{if(card&&grid.contains(card))moveBefore(card,audit)}); }finally{ordering=false;} } let timer=0; function scheduleOrder(delay=40){clearTimeout(timer);timer=setTimeout(orderSettingsCards,delay)} function boot(){ const btn=settingsNavButton(); btn?.addEventListener('click',()=>{ scrollSettingsTop(); setTimeout(scrollSettingsTop,0); setTimeout(scrollSettingsTop,100); scheduleOrder(160); scheduleOrder(420); },true); const view=$('enterprise-settings'); if(view){ new MutationObserver(()=>scheduleOrder(30)).observe(view,{childList:true,subtree:true}); if(view.classList.contains('on')){scrollSettingsTop();scheduleOrder(120)} } window.addEventListener('sun:cloud-state-applied',()=>scheduleOrder(80)); window.addEventListener('suncloudsync',()=>scheduleOrder(80)); } if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot(); })(); ; /* ===== MODULE: address-verify-v1.js ===== */ /* Sun Catering v17.5.4: explicit precise-address verification */ (()=>{ 'use strict'; if(window.__sunAddressVerifyV1)return; window.__sunAddressVerifyV1=true; const $=id=>document.getElementById(id); const esc=window.SunSafe.escapeHTML; const val=id=>String($(id)?.value||'').trim(); const CACHE_KEY='sunPreciseAddressSearchCacheV1'; let lastRequest=0,selected=null,searchSeq=0,applying=false; const style=document.createElement('style'); style.id='sun-address-verify-v1-style'; style.textContent=` .sun-address-verify{margin-top:10px;border-top:1px solid #e5e8eb;padding-top:10px} .sun-address-verify-note{font-size:11px;line-height:1.45;color:#6d7782;margin:0 0 9px} .sun-address-candidates{display:grid;gap:7px} .sun-address-candidate{width:100%;text-align:left;border:1px solid #dfe4e8;border-radius:10px;background:#fff;padding:9px 10px;cursor:pointer;color:#2a3541} .sun-address-candidate:hover{border-color:#b9c7d0;background:#fbfcfd} .sun-address-candidate.on{border-color:#d2a438;box-shadow:0 0 0 2px rgba(210,164,56,.15);background:#fffdf7} .sun-address-candidate b{display:block;font-size:12px;line-height:1.35}.sun-address-candidate small{display:block;margin-top:3px;color:#75808a;line-height:1.35} .sun-address-candidate .sun-address-chips{display:flex;gap:5px;flex-wrap:wrap;margin-top:6px}.sun-address-candidate .sun-address-chips span{font-size:9px;font-weight:800;padding:3px 6px;border-radius:999px;background:#eef2f4;color:#53616c} .sun-address-selected{margin-top:9px;padding:9px 10px;border:1px solid #e4e7e9;border-radius:10px;background:#fafbfc;font-size:11px;color:#59656f;line-height:1.45} #orderGeoConfirm{display:none;margin-top:9px;width:100%;justify-content:center}.sun-address-verify.busy{opacity:.7;pointer-events:none} `; document.head.appendChild(style); function readCache(){try{return JSON.parse(localStorage.getItem(CACHE_KEY)||'{}')||{}}catch(_){return{}}} function writeCache(cache){try{localStorage.setItem(CACHE_KEY,JSON.stringify(cache))}catch(_){} } function cleanAddressParts(a={}){ return { region:String(a.state||a.region||'').trim(), locality:String(a.city||a.town||a.village||a.municipality||a.hamlet||a.isolated_dwelling||'').trim(), district:String(a.city_district||a.state_district||a.county||a.borough||a.suburb||'').trim(), street:String(a.road||a.pedestrian||a.residential||a.footway||a.path||'').trim(), house:String(a.house_number||'').trim(), postcode:String(a.postcode||'').trim() }; } function queryParts(){ const region=val('deliveryRegion')==='Другой регион'?'':val('deliveryRegion'); return [region,val('deliveryLocality'),val('deliveryDistrict'),val('deliveryStreet'),val('deliveryHouse')?`д. ${val('deliveryHouse')}`:'',val('deliveryCorpus')?`корп. ${val('deliveryCorpus')}`:''].filter(Boolean); } function queryText(){return queryParts().join(', ')} function mapAddress(){return queryText()} async function fetchJson(url,timeout=6500){ const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),timeout); try{const r=await fetch(url,{method:'GET',mode:'cors',credentials:'omit',cache:'default',signal:controller.signal});if(!r.ok)throw new Error(`HTTP ${r.status}`);return await r.json();} finally{clearTimeout(timer)} } async function searchNominatim(query){ const wait=Math.max(0,1100-(Date.now()-lastRequest));if(wait)await new Promise(r=>setTimeout(r,wait));lastRequest=Date.now(); const region=val('deliveryRegion'); const params=new URLSearchParams({format:'jsonv2',addressdetails:'1',limit:'5',countrycodes:'ru','accept-language':'ru',q:query}); if(region==='Санкт-Петербург'||region==='Ленинградская область'){ params.set('viewbox','27.3,61.4,34.5,58.0');params.set('bounded','1'); }else if(!region){params.set('viewbox','27.3,61.4,34.5,58.0');} const data=await fetchJson(`https://nominatim.openstreetmap.org/search?${params.toString()}`); return (Array.isArray(data)?data:[]).filter(r=>Number.isFinite(Number(r.lat))&&Number.isFinite(Number(r.lon))).map(r=>({ lat:Number(r.lat),lon:Number(r.lon),formatted:String(r.display_name||query),provider:'nominatim',address:r.address||{},parts:cleanAddressParts(r.address||{}),importance:Number(r.importance||0) })); } async function searchPhoton(query){ const data=await fetchJson(`https://photon.komoot.io/api/?limit=5&lang=ru&q=${encodeURIComponent(query)}`); return (Array.isArray(data?.features)?data.features:[]).map(f=>{ const c=f?.geometry?.coordinates,p=f?.properties||{};if(!Array.isArray(c)||!Number.isFinite(Number(c[0]))||!Number.isFinite(Number(c[1])))return null; const address={state:p.state||'',city:p.city||'',town:p.city||'',county:p.county||'',road:p.street||p.name||'',house_number:p.housenumber||'',postcode:p.postcode||''}; return {lat:Number(c[1]),lon:Number(c[0]),formatted:[p.name,p.housenumber,p.street,p.district,p.city,p.county,p.state,p.postcode].filter(Boolean).join(', ')||query,provider:'photon',address,parts:cleanAddressParts(address),importance:0}; }).filter(Boolean); } async function searchCandidates(query){ const key=query.toLowerCase(),cache=readCache(); if(Array.isArray(cache[key])&&cache[key].length)return cache[key]; let rows=[];try{rows=await searchNominatim(query)}catch(_){rows=[]} if(!rows.length){try{rows=await searchPhoton(query)}catch(_){rows=[]}} if(rows.length){cache[key]=rows.slice(0,5);const keys=Object.keys(cache);if(keys.length>80)keys.slice(0,keys.length-80).forEach(k=>delete cache[k]);writeCache(cache)} return rows.slice(0,5); } function candidateTitle(c){ const p=c.parts||{}; return [p.street,p.house?`д. ${p.house}`:'',p.locality].filter(Boolean).join(', ')||c.formatted; } function candidateMeta(c){ const p=c.parts||{}; return [p.district,p.region,p.postcode].filter(Boolean).join(' · ')||c.formatted; } function renderCandidates(rows){ const host=$('sunAddressCandidates');if(!host)return; if(!rows.length){host.innerHTML='
    Точный вариант не найден. Добавьте населённый пункт или район и попробуйте ещё раз.
    ';return} host.innerHTML=rows.map((c,i)=>``).join(''); host.querySelectorAll('[data-address-candidate]').forEach(btn=>btn.addEventListener('click',()=>selectCandidate(rows[Number(btn.dataset.addressCandidate)],btn))); } function setExistingStatus(text,kind='warn'){ const s=$('orderGeoStatus');if(s){s.className=`geo-order-status ${kind}`;const span=s.querySelector('span');if(span)span.textContent=text} } function selectCandidate(candidate,button){ selected=candidate; document.querySelectorAll('.sun-address-candidate').forEach(x=>x.classList.toggle('on',x===button)); const confirm=$('orderGeoConfirm');if(confirm){confirm.style.display='inline-flex';confirm.disabled=false} const selectedBox=$('sunAddressSelected');if(selectedBox)selectedBox.innerHTML=`Выбран вариант:
    ${esc(candidate.formatted)}
    Проверьте точку ниже и нажмите «Адрес верный — использовать».`; const mini=$('orderGeoMini');if(mini&&window.SunStaticMap?.mediaHtml)mini.innerHTML=window.SunStaticMap.mediaHtml(candidate,'Проверка точного адреса')+'
    © OpenStreetMap contributors
    '; const precision=$('orderGeoPrecision');if(precision)precision.textContent=`${candidate.lat.toFixed(5)}, ${candidate.lon.toFixed(5)}`; setExistingStatus('Проверьте выбранную точку','warn'); } function setField(id,value,{onlyIf=false}={}){const el=$(id);if(!el||!value)return;if(onlyIf&&String(el.value||'').trim())return;el.value=value;el.dispatchEvent(new Event(el.tagName==='SELECT'?'change':'input',{bubbles:true}))} function normalizeRegion(region){ if(/санкт[- ]?петербург/i.test(region))return 'Санкт-Петербург'; if(/ленинград/i.test(region))return 'Ленинградская область'; return region||''; } function composeFullAddress(){ if(typeof window.sunComposeDeliveryAddress==='function')return window.sunComposeDeliveryAddress(); const parts=[val('deliveryRegion'),val('deliveryLocality'),val('deliveryDistrict'),val('deliveryStreet'),val('deliveryHouse')?`д. ${val('deliveryHouse')}`:'',val('deliveryCorpus')?`корп. ${val('deliveryCorpus')}`:'',val('deliveryEntrance')?`подъезд ${val('deliveryEntrance')}`:'',val('deliveryFloor')?`этаж ${val('deliveryFloor')}`:'',val('deliveryApartment')?`кв. ${val('deliveryApartment')}`:'',val('deliveryOffice')?`офис ${val('deliveryOffice')}`:''].filter(Boolean); const a=parts.join(', ');if($('address'))$('address').value=a;return a; } function getDraft(){try{return draft}catch(_){return null}} function getOrders(){try{return orders}catch(_){return[]}} function confirmCandidate(){ if(!selected)return; const candidate=selected,p=candidate.parts||{}; applying=true; const region=normalizeRegion(p.region); if(region){ const regionSelect=$('deliveryRegion'); if(regionSelect&&[...regionSelect.options].some(o=>o.value===region||o.text===region)){regionSelect.value=region;regionSelect.dispatchEvent(new Event('change',{bubbles:true}))} else if(regionSelect){const option=new Option(region,region);regionSelect.add(option);regionSelect.value=region;regionSelect.dispatchEvent(new Event('change',{bubbles:true}))} } const localityValue=(region==='Санкт-Петербург'&&/санкт[- ]?петербург/i.test(p.locality||''))?'':(p.locality||''); const localityEl=$('deliveryLocality');if(localityEl){localityEl.value=localityValue;localityEl.dispatchEvent(new Event('input',{bubbles:true}))} setField('deliveryDistrict',p.district||'',{onlyIf:false}); setField('deliveryStreet',p.street||val('deliveryStreet'),{onlyIf:false}); setField('deliveryHouse',p.house||val('deliveryHouse'),{onlyIf:false}); const full=composeFullAddress(); const d=getDraft(); if(d){ d.deliveryRegion=val('deliveryRegion');d.deliveryLocality=val('deliveryLocality');d.deliveryDistrict=val('deliveryDistrict');d.deliveryStreet=val('deliveryStreet');d.deliveryHouse=val('deliveryHouse');d.deliveryCorpus=val('deliveryCorpus');d.address=full; d.geo={lat:candidate.lat,lon:candidate.lon,formatted:candidate.formatted,sourceMapAddress:mapAddress(),sourceAddress:full,verifiedAt:new Date().toISOString(),confirmedAt:new Date().toISOString(),confirmed:true,provider:candidate.provider||'nominatim',geoVersion:3}; if(d.id){const saved=getOrders().find(o=>String(o.id)===String(d.id));if(saved){Object.assign(saved,{deliveryRegion:d.deliveryRegion,deliveryLocality:d.deliveryLocality,deliveryDistrict:d.deliveryDistrict,deliveryStreet:d.deliveryStreet,deliveryHouse:d.deliveryHouse,deliveryCorpus:d.deliveryCorpus,address:full,geo:structuredClone(d.geo)});try{persist()}catch(_){}}} } applying=false; if($('address'))$('address').dispatchEvent(new Event('input',{bubbles:true})); setTimeout(()=>{ setExistingStatus('Адрес подтверждён','ok'); const precision=$('orderGeoPrecision');if(precision)precision.textContent=candidate.formatted; const confirm=$('orderGeoConfirm');if(confirm){confirm.textContent='Адрес подтверждён ✓';confirm.disabled=true} const create=$('orderGeoCreate');if(create)create.textContent='Проверить другой адрес'; try{window.SunEnterprise?.toast?.('Точка доставки подтверждена.','success')}catch(_){} },20); } async function runSearch(){ const query=queryText(); if(!val('deliveryStreet')||!val('deliveryHouse')){try{window.SunEnterprise?.toast?.('Для точной проверки укажите улицу и дом.','warn')}catch(_){alert('Для точной проверки укажите улицу и дом.')}return} const root=$('sunAddressVerify');if(root)root.classList.add('busy'); const seq=++searchSeq;selected=null; const confirm=$('orderGeoConfirm');if(confirm){confirm.style.display='none';confirm.disabled=false;confirm.textContent='Адрес верный — использовать'} const host=$('sunAddressCandidates');if(host)host.innerHTML='
    Ищу подходящие адреса…
    '; setExistingStatus('Идёт поиск точного адреса','warn'); try{ const rows=await searchCandidates(query);if(seq!==searchSeq)return;renderCandidates(rows);setExistingStatus(rows.length?'Выберите точный вариант':'Адрес не найден',rows.length?'warn':'warn'); }finally{root?.classList.remove('busy')} } function resetVerification(){ if(applying)return; selected=null;searchSeq++; const host=$('sunAddressCandidates');if(host)host.innerHTML=''; const box=$('sunAddressSelected');if(box)box.innerHTML=''; const confirm=$('orderGeoConfirm');if(confirm){confirm.style.display='none';confirm.disabled=false;confirm.textContent='Адрес верный — использовать'} const d=getDraft();if(d?.geo?.confirmed){d.geo=null} } function boot(){ const geo=$('orderGeoBox'),create=$('orderGeoCreate');if(!geo||!create)return; let root=$('sunAddressVerify'); if(!root){ root=document.createElement('div');root.id='sunAddressVerify';root.className='sun-address-verify'; root.innerHTML='

    Чтобы не перепутать одинаковые улицы в разных населённых пунктах, нажмите «Проверить адрес», выберите полный вариант и подтвердите точку на карте.

    Поиск адреса: OpenStreetMap/Nominatim. © OpenStreetMap contributors.
    '; const mini=$('orderGeoMini');if(mini)window.SunSafe.insertBefore(geo,root,mini);else geo.appendChild(root); const selectedBox=$('sunAddressSelected');new MutationObserver(()=>{selectedBox.style.display=selectedBox.textContent.trim()?'block':'none'}).observe(selectedBox,{childList:true,subtree:true,characterData:true}); } let confirm=$('orderGeoConfirm'); if(!confirm){confirm=document.createElement('button');confirm.id='orderGeoConfirm';confirm.type='button';confirm.className='primary';confirm.textContent='Адрес верный — использовать';$('orderGeoMini')?.insertAdjacentElement('afterend',confirm);confirm.addEventListener('click',confirmCandidate)} create.textContent='Проверить адрес'; // Capture before the legacy handler so the app no longer silently accepts the first geocoder result. create.addEventListener('click',e=>{e.preventDefault();e.stopImmediatePropagation();runSearch()},true); ['deliveryRegion','deliveryLocality','deliveryDistrict','deliveryStreet','deliveryHouse','deliveryCorpus'].forEach(id=>$(id)?.addEventListener(id==='deliveryRegion'?'change':'input',resetVerification)); $('address')?.addEventListener('input',()=>setTimeout(()=>{const d=getDraft();if(create&&!d?.geo?.confirmed)create.textContent='Проверить адрес'},0)); } if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',()=>setTimeout(boot,50),{once:true});else setTimeout(boot,50); })(); ; /* ===== MODULE: catalog-photo-resolver-v17.5.14.js ===== */ (function(){ 'use strict'; if(window.__sunCatalogPhotoResolver17514)return;window.__sunCatalogPhotoResolver17514=true; const SOURCE_URLS=["https://static.tildacdn.com/stor3131-6532-4762-a534-353638663730/11f4ce48f250257ec824a12e1c541150.jpg","https://static.tildacdn.com/stor6634-3466-4635-a164-386637346334/e94521aba11d1824218728cee4312c03.jpg","https://static.tildacdn.com/stor3034-3036-4334-b233-303363326236/d72f45ce6d6a044d8d892a0718f29d89.jpg","https://static.tildacdn.com/stor3662-3435-4561-b031-663437646233/efd5e71d2a8654b0afa26a6dd7a0595a.jpg","https://static.tildacdn.com/stor6133-6537-4533-a166-666430373166/014bff38be088e524c0a4fd2ee1daab3.jpg","https://static.tildacdn.com/stor6161-3163-4032-b338-363935363839/7b9abb2bc27c854236e234383f044c20.jpg","https://static.tildacdn.com/stor3065-6462-4666-b134-376564613831/527188ad5747060eaa88cdd6b033e1b7.jpg","https://static.tildacdn.com/stor6238-3635-4936-a361-343062343130/6c2841340110a32dee16481cf5776858.jpg","https://static.tildacdn.com/stor6231-3837-4666-b564-656661306534/a9b0efe3696fa0cd6530e3100cce2447.jpg","https://static.tildacdn.com/stor3162-6131-4337-b731-376266363535/6998754c49e80fca1df239d01e91b239.jpg","https://static.tildacdn.com/stor6264-3139-4939-b331-623963306665/c62d84a2c9de7fd115f9878617709a99.jpg","https://static.tildacdn.com/stor3134-6638-4635-b032-623233383830/413674a63540f26bbcded51a2f37c3a7.jpg","https://static.tildacdn.com/stor6438-6236-4831-b038-346437643037/b7305f8e759e161407a9996e0218cf83.jpg","https://static.tildacdn.com/stor3134-6638-4635-b032-623233383830/413674a63540f26bbcded51a2f37c3a7.jpg","https://static.tildacdn.com/stor3536-6463-4535-a135-653763366634/3b6eec9a6310eea531c8cb70b00955a9.jpg","https://static.tildacdn.com/stor3339-6634-4634-b537-363366353636/7ef8a4de7cfc055f633ab29ad7e5fca4.jpg","https://static.tildacdn.com/stor3030-3766-4661-b364-313536363731/f7a5bb4329e864b12eb5aed656c13c2b.jpg","https://static.tildacdn.com/stor3865-3463-4832-a531-326663623661/0ab2f6a1959adeb68f6c64e21fc52e6c.jpg","https://static.tildacdn.com/stor3834-6339-4234-a638-656236303531/d70c6ff9606b8f29f4d0a334643c6fa5.jpg","https://static.tildacdn.com/stor3038-3736-4564-a635-333961643763/ae631a3930ee087c993f0eb5ae477388.jpg","https://static.tildacdn.com/stor3638-3139-4562-b430-356363633431/a0eae79a99b4f1ca59be8a445d975f94.jpg","https://static.tildacdn.com/stor3536-3766-4033-b235-643062373262/1e521931b938e17b0ebe397a447067bb.jpg","https://static.tildacdn.com/stor3966-6635-4139-b965-666363393466/df421e8c29457bb6a660810c14d4dbca.jpg","https://static.tildacdn.com/stor3133-6661-4839-b565-376433313839/471052a775dfa8fae813f1b043171e21.jpg","https://static.tildacdn.com/stor3539-3831-4031-b532-323963396137/235a566973dc035faa18ef9a57b4bef6.jpg","https://static.tildacdn.com/stor6362-6234-4063-b636-373636373465/6c868c53823c2c2799bacbef8b7ec5dc.jpg","https://static.tildacdn.com/stor3439-6237-4364-a566-666531346532/a368a54d5dd3ef381bdb587f81dd3953.jpg","https://static.tildacdn.com/stor6664-6363-4635-b632-383362383935/f5779eef4eaff67dec0cb8a9868fe912.jpg","https://static.tildacdn.com/stor6166-3066-4535-b333-356263356363/25951fa13b57555854635d8fe1a1ecc9.jpg","https://static.tildacdn.com/stor3131-3637-4837-b039-386331346435/c4fd5306cc629e81fe974d0e1238eb06.jpg","https://static.tildacdn.com/stor3235-3535-4639-b164-663664323936/c3dc9dc2931b0f950034495dd8cc6b46.jpg","https://static.tildacdn.com/stor3235-3661-4038-b035-613032303361/6f2c3ac522ad9d7e73d14f3fc4c47630.jpg","https://static.tildacdn.com/stor6166-3066-4535-b333-356263356363/25951fa13b57555854635d8fe1a1ecc9.jpg","https://static.tildacdn.com/stor3836-3438-4835-a132-663962396637/fac8bc386bd98959d75c6b71cd83471b.jpg","https://static.tildacdn.com/stor3334-6361-4633-a266-643132323266/74b63ae226e1ebfbd116a822be4f642e.jpg","https://static.tildacdn.com/stor3136-3834-4562-b037-396264363263/e8c1bbdff0574f52a2184a33d6f2115c.jpg","https://static.tildacdn.com/stor6334-3534-4266-a664-356162323565/c13d0896543e32d12ccad0c0a108dac5.jpg","https://static.tildacdn.com/stor3364-6237-4561-b432-326230363433/3233d197bebae6174651a7732ec8ca3e.jpg","https://static.tildacdn.com/stor3239-6231-4130-a363-643732356464/1a72939cda10b9fc447157193a2c69cf.jpg","https://static.tildacdn.com/stor3832-3732-4635-b732-636135393265/3e414b433c0aab18c0a1131d256c92e5.jpg","https://static.tildacdn.com/stor6431-3634-4935-a638-323064343638/8d7c3b547b6e5abd09fe498c2e0b3853.jpg","https://static.tildacdn.com/stor3362-3331-4631-b738-646138333763/b3932c942568e83ab0babc98b77ee5c3.jpg","https://static.tildacdn.com/stor3565-6565-4739-b638-343639336130/ce325e928513a5c7ad2a5c9328927bd0.jpg","https://static.tildacdn.com/stor3131-3335-4135-b962-356338643761/dabf8e59f8b405c2998cc725627a832a.jpg","https://static.tildacdn.com/stor6365-6464-4263-b066-653333643438/eba899d7b96c0e9f077467bb4076cf56.jpg","https://static.tildacdn.com/stor3064-6631-4061-b231-393166393931/2e792a2bd15012a2c134a17213d64706.jpg","https://static.tildacdn.com/stor3936-6435-4438-a564-643362376266/c0d42f2444a396f1b836f4f5e2d2a2b8.jpg","https://static.tildacdn.com/stor6635-6661-4361-b031-303465633437/9c824de9c9ad08c435f64d62876ca8f2.jpg","https://static.tildacdn.com/stor6530-6533-4235-b631-613831393533/2651153cdc6a396acfe9c7c1f1ffa6e3.jpg","https://static.tildacdn.com/stor3566-6437-4361-b137-343366323736/8f1f58eb858f669ca4acea85ad8bbbce.jpg","https://static.tildacdn.com/stor3363-3539-4532-b731-343434323163/3d114d5bd96795183d342a37ef5bacb7.jpg","https://static.tildacdn.com/stor3234-6630-4664-b230-616165303866/a350de68077dc437a4a7c0d1f07db97b.jpg","https://static.tildacdn.com/stor6566-6236-4038-b038-326331333761/acfb6d855c780c92c4cd6e667229a9d7.jpg","https://static.tildacdn.com/stor3662-3963-4031-b065-646235373965/f8f3bd5b10fe9142e453766305684126.jpg","https://static.tildacdn.com/stor3461-3363-4063-a530-336136633338/951f5c4bcaa35b878fd8f0d4cdf5812a.jpg","https://static.tildacdn.com/stor6335-3638-4432-b230-393163396334/d08deb92d2102c1e81002f0ff5b1cd18.jpg","https://static.tildacdn.com/stor3163-3035-4466-b435-303832663162/2a85461ba4c7132af333c481f6974334.jpg","https://static.tildacdn.com/stor3132-6233-4466-b164-376530633465/a5159b1de5f6bd3d2dde7609c1853f82.jpg","https://static.tildacdn.com/stor3165-6138-4663-b433-393039653034/4ad56410ecf6a163824061d08cb297da.jpg","https://static.tildacdn.com/stor6437-6361-4833-a537-623832376136/29ff2e5084a5f816954ea5621004b0c0.jpg","https://static.tildacdn.com/stor3765-3765-4663-a333-623738326235/02eaafb2bae5cef3edaa761aa7cea142.jpg","https://static.tildacdn.com/stor6365-3039-4931-a333-623061333865/ceb789e801d358ff78f2a4800f79f78c.jpg","https://static.tildacdn.com/stor6262-6636-4563-b332-383166313034/73a699e67040500f90edd197152cb0b9.jpg","https://static.tildacdn.com/stor3366-3663-4664-a136-633432326530/8a056937be29f8f531767a565fc2fb45.jpg","https://static.tildacdn.com/stor3566-3862-4334-b465-376164633737/7ad059a9bf198827dfd142008f81bb28.jpg","https://static.tildacdn.com/stor3532-6333-4432-b037-373031393364/3f233fc73a9932953876810c2530779b.jpg","https://static.tildacdn.com/stor6461-6235-4134-a630-393261313034/f00b5038885f425bf4463b0f070416b2.jpg","https://static.tildacdn.com/stor3963-3634-4262-b363-393536623037/7f3160b562bdb81243fcca8b07ea73ca.jpg","https://static.tildacdn.com/stor6134-6534-4032-b863-326334303439/b6299036af0105e24b66b92fc149fceb.jpg","https://static.tildacdn.com/stor6330-3331-4534-a431-616637323265/cc72f08366e2b3bb3b2cec181fcfb6b2.jpg","https://static.tildacdn.com/stor3864-6138-4939-a263-323633343831/e41074621630fb64c6551fde54a0491a.jpg","https://static.tildacdn.com/stor3530-3337-4465-a331-393235636139/6bed2df48d65e7dfdf673cc6ab1d66f0.jpg","https://static.tildacdn.com/stor3132-6432-4637-b930-353763333565/0e6665f2fddcdc6a2538ff0e4d6714e3.jpg","https://static.tildacdn.com/stor3063-6361-4562-b864-386439643866/5a0a9e8680146e4af16b0b3a71e5e344.jpg","https://static.tildacdn.com/stor3864-3766-4132-a362-336565363634/331d7a3cb423efad3851318ebc243503.jpg","https://static.tildacdn.com/stor3764-3435-4336-a638-386530643039/b66c7fb8fba795f74358798f008ff284.jpg","https://static.tildacdn.com/stor3764-3435-4336-a638-386530643039/b66c7fb8fba795f74358798f008ff284.jpg","https://static.tildacdn.com/stor3764-3435-4336-a638-386530643039/b66c7fb8fba795f74358798f008ff284.jpg","https://static.tildacdn.com/stor3764-3435-4336-a638-386530643039/b66c7fb8fba795f74358798f008ff284.jpg","https://static.tildacdn.com/stor3661-6362-4365-a166-396432366336/b3461ed10ad1d21ba6f327e14f87561a.jpg","https://static.tildacdn.com/stor6136-3763-4363-b632-613035666139/b89d5f88da96f52526ccba83cfb1e631.jpg","https://static.tildacdn.com/stor3764-3435-4336-a638-386530643039/b66c7fb8fba795f74358798f008ff284.jpg","https://static.tildacdn.com/stor3764-3435-4336-a638-386530643039/b66c7fb8fba795f74358798f008ff284.jpg","https://static.tildacdn.com/stor3530-3337-4465-a331-393235636139/6bed2df48d65e7dfdf673cc6ab1d66f0.jpg","https://static.tildacdn.com/stor3535-3432-4638-b763-636162616233/f392e1f738f4ec51be28ac85ff7f34d1.jpg","https://static.tildacdn.com/stor6263-6138-4433-a639-386465356138/915334ed7ec0a973fc528d5b1bcddeb9.jpg","https://static.tildacdn.com/stor6161-3666-4430-b437-303230613863/b6c27133c86b74e669c4d8d14cddaa98.jpg","https://static.tildacdn.com/stor6433-6530-4938-a264-366133396635/26c04c584ac3ce62893f86e3f21ef6a4.jpg","https://static.tildacdn.com/stor3438-3336-4038-b563-636464663765/26624b9790a69468292e752308984af1.jpg","https://static.tildacdn.com/stor3430-3734-4935-a666-616161393566/92e1260daf92944a35c6ce5a64ca38f3.jpg","https://static.tildacdn.com/stor3937-3731-4331-a333-333266353435/018e8f56c6b0053168f0d0cf73872966.jpg","https://static.tildacdn.com/stor3939-3434-4061-a231-636661616561/607dc1f31bc1ca66fb1a611d49407aa5.jpg","https://static.tildacdn.com/stor3836-3436-4239-a536-353332396264/232fcd6e93bf0abc918ef73529cded4b.jpg","https://static.tildacdn.com/stor3065-3632-4533-b333-636566613663/7979f348481e979905836febdf7933a6.jpg","https://static.tildacdn.com/stor3761-3162-4461-b966-323139613763/08d95a9c71b402415f9479b3f9520892.jpg","https://static.tildacdn.com/stor3033-3137-4930-b264-666532373134/69925db7e39c25877ac58869c15037a6.jpg","https://static.tildacdn.com/stor3333-3839-4537-a236-623030313665/4130c8f6bd2b58b41c4edd83eee922a9.jpg","https://static.tildacdn.com/stor6230-3133-4564-b237-646535613637/a2a99a4eeb22cb33b5b5cea2a79a17cb.jpg","https://static.tildacdn.com/stor3137-6538-4132-b739-653932333233/15a1c8db984c5e68f357b2ee12a18c2a.jpg","https://static.tildacdn.com/stor3031-6132-4334-b330-633933383437/7e711a925bef78f54c6417cfed424f7b.jpg","https://static.tildacdn.com/stor6565-3862-4536-a531-396336343530/94e933aa7cb2ca8cc2f9a6975508c69b.jpg","https://static.tildacdn.com/stor3130-6665-4166-a562-346464663365/1ace83e57a93e4bd15c4117e6ebd10f4.jpg","https://static.tildacdn.com/stor6666-6336-4235-b437-663731323432/f733be8a64d1186ac1153c3eba271d59.jpg","https://static.tildacdn.com/stor6561-6664-4163-a232-323236383637/1b4aa616a89592851de475a7633530b2.jpg","https://static.tildacdn.com/stor3364-3366-4961-a230-393136633735/bd16ccb4240c13b0408fcd3cdb263c7f.jpg","https://static.tildacdn.com/stor3133-3966-4466-a633-353062663934/19cfdd0803e1252edb0a229fe8cc10e1.jpg","https://static.tildacdn.com/stor3862-3737-4631-a637-656265333935/be0ca89c31666c8d357814c69f0c1527.jpg","https://static.tildacdn.com/stor3730-3136-4137-b861-393762656436/9b8faf6a48cff9bc8b16cef36396d144.jpg","https://static.tildacdn.com/stor6137-3965-4334-a432-316165313635/7e2aebaffa009dfc1b7a580ceac8a640.jpg","https://static.tildacdn.com/stor3937-3432-4537-a233-646339376631/d0ca8846eba6810423c5421e7060c567.jpg","https://static.tildacdn.com/stor3437-6365-4139-b134-326232643133/8d0a33ea4f3ace73aa73679aa8ac3b4d.jpg","https://static.tildacdn.com/stor6334-3463-4166-b330-643438393235/49f88576b7fb7573735b20dc4161de96.jpg","https://static.tildacdn.com/stor3139-3866-4633-a234-663139643733/5c7060058669060670ed95fb719078c3.jpg"]; const pad=n=>String(Math.max(1,Math.min(113,Number(n)||0))).padStart(3,'0'); const indexFrom=value=>{ const s=String(value||''); let m=s.match(/current-(\d{3})\.jpg/i);if(m)return Number(m[1]); m=s.match(/catalog-20260902-(\d{3})/i);if(m)return Number(m[1]); const clean=s.split(/[?#]/)[0],remoteIndex=SOURCE_URLS.findIndex(url=>url===clean); return remoteIndex>=0?remoteIndex+1:0; }; function indexFor(itemOrPath){ if(itemOrPath&&typeof itemOrPath==='object'){ const byPhoto=indexFrom(itemOrPath.photo);if(byPhoto)return byPhoto; const byId=indexFrom(itemOrPath.id);if(byId)return byId; const byOrder=Number(itemOrPath.catalogOrder||0);if(byOrder>=1&&byOrder<=113)return byOrder; } return indexFrom(itemOrPath); } function candidates(itemOrPath){ const idx=indexFor(itemOrPath); if(!idx)return [String(itemOrPath&&typeof itemOrPath==='object'?itemOrPath.photo||'':itemOrPath||'')].filter(Boolean); const local=`catalog/current-${pad(idx)}.jpg`; const api=(location.protocol==='http:'||location.protocol==='https:')?`/api/catalog-photo?i=${idx}`:''; const remote=SOURCE_URLS[idx-1]||''; const requested=String(itemOrPath&&typeof itemOrPath==='object'?itemOrPath.photo||'':itemOrPath||''); return [...new Set([requested,local,api,remote,(window.CateriumBranding.documentImage())].filter(Boolean))]; } function fallbackImage(img){ if(!(img instanceof HTMLImageElement))return; let idx=Number(img.dataset.sunCatalogPhotoIndex||0)||indexFor(img.getAttribute('src')||img.src); if(!idx)return; img.dataset.sunCatalogPhotoIndex=String(idx); const list=candidates(`catalog/current-${pad(idx)}.jpg`); let step=Number(img.dataset.sunCatalogPhotoStep||0); const currentAbs=(()=>{try{return new URL(img.getAttribute('src')||img.src,document.baseURI).href}catch(_){return String(img.src||'')}})(); while(step{const img=event.target;if(img instanceof HTMLImageElement)fallbackImage(img)},true); function repairExisting(){document.querySelectorAll('img').forEach(img=>{if(indexFor(img.getAttribute('src')||'')&&img.complete&&img.naturalWidth===0)fallbackImage(img)})} setTimeout(repairExisting,60);setTimeout(repairExisting,650); window.SunCatalogPhoto={sourceUrls:SOURCE_URLS,indexFor,candidates,fallbackImage,repairExisting}; })(); /* ===== MODULE: settings-tabs-v21.js ===== */ /* Sun Catering v17.5.21: grouped Settings tabs without rewriting existing settings cards. */ (()=>{ 'use strict'; if(window.__sunSettingsTabsV21)return; window.__sunSettingsTabsV21=true; const $=id=>document.getElementById(id); const qa=(sel,root=document)=>[...root.querySelectorAll(sel)]; const TAB_KEY='sunSettingsActiveTabV1'; const TABS=[ {id:'account',label:'Аккаунт',hint:'Профиль, компания, тариф, пользователи и история изменений.'}, {id:'appearance',label:'Оформление',hint:'Цвета интерфейса, боковой панели и готовые цветовые варианты.'}, {id:'offer',label:'Предложение',hint:'Шаблон и содержимое предложения клиенту.'}, {id:'orders',label:'Заказы',hint:'Статусы заказов и цвета состояния оплаты.'}, {id:'references',label:'Справочники',hint:'Типы мероприятий, источники клиентов и вкладки каталога.'}, {id:'documents',label:'Документы',hint:'Реквизиты чека, QR-код и бланк заказа.'} ]; const VALID=new Set(TABS.map(x=>x.id)); let active=(()=>{try{const v=localStorage.getItem(TAB_KEY);return VALID.has(v)?v:'account'}catch(_){return'account'}})(); let scheduled=0; const style=document.createElement('style'); style.id='sun-settings-tabs-v21-style'; style.textContent=` #enterprise-settings .sun-settings-tabs-wrap{margin:0 0 14px;min-width:0} #enterprise-settings .sun-settings-tabs{display:flex;gap:6px;overflow-x:auto;overscroll-behavior-inline:contain;scrollbar-width:thin;padding:3px 2px 7px;white-space:nowrap} #enterprise-settings .sun-settings-tab{flex:1 1 0;min-width:118px;border:1px solid #dce2e6;border-radius:10px;background:#fff;color:#53616c;padding:10px 12px;font-size:13px;font-weight:800;cursor:pointer;transition:background .15s ease,border-color .15s ease,color .15s ease,box-shadow .15s ease} #enterprise-settings .sun-settings-tab:hover{background:#f7f9fa;border-color:#c8d1d7} #enterprise-settings .sun-settings-tab.on{background:#15364c;color:#fff;border-color:#15364c;box-shadow:0 5px 14px rgba(21,54,76,.13)} #enterprise-settings .sun-settings-tab:focus-visible{outline:3px solid rgba(212,154,42,.28);outline-offset:2px} #enterprise-settings .sun-settings-tab-context{display:flex;gap:7px;align-items:baseline;flex-wrap:wrap;padding:2px 4px 0;color:#6a7680;font-size:12px;line-height:1.4} #enterprise-settings .sun-settings-tab-context b{color:#15364c;font-size:13px} #enterprise-settings .enterprise-card{order:var(--sun-settings-order,50)} #enterprise-settings .enterprise-card[hidden]{display:none!important} #enterprise-settings.on #sunBlankSettingsCardV3[hidden]{display:none!important} #enterprise-settings .enterprise-grid{align-items:start} #enterprise-settings #sunCatalogTabsSettingsCardV21 .sun-settings-catalog-summary{display:flex;gap:7px;flex-wrap:wrap;margin:10px 0 2px} #enterprise-settings #sunCatalogTabsSettingsCardV21 .sun-settings-catalog-chip{display:inline-flex;align-items:center;border-radius:999px;background:#f2f5f6;color:#52616c;padding:5px 8px;font-size:11px;font-weight:800} @media(max-width:900px){ #enterprise-settings .sun-settings-tabs{margin-right:-4px;padding-right:16px} #enterprise-settings .sun-settings-tab{flex:0 0 auto;min-width:126px} #enterprise-settings .sun-settings-tab-context{padding-right:8px} } @media(max-width:520px){ #enterprise-settings .sun-settings-tabs-wrap{margin-bottom:11px} #enterprise-settings .sun-settings-tab{min-width:116px;padding:9px 11px;font-size:12px} #enterprise-settings .sun-settings-tab-context span{display:block;width:100%} } `; document.head.appendChild(style); function heading(card){return String(card?.querySelector(':scope > h2')?.textContent||card?.querySelector('h2')?.textContent||'').trim()} function classify(card){ const id=String(card?.id||''),h=heading(card); if(id==='sunCloudV2Card'||id==='sunSaaSSettingsCardV16'||/^(Профиль и аккаунт|Облачная синхронизация|Тариф и подписка|Пользователи и роли|История изменений)$/.test(h)){ const order=id==='sunCloudV2Card'||/^(Профиль и аккаунт|Облачная синхронизация)$/.test(h)?10:id==='sunSaaSSettingsCardV16'||h==='Тариф и подписка'?20:h==='Пользователи и роли'?30:90; return['account',order]; } if(id==='sunBrandThemeSettingsCard'||/^(Цвета интерфейса|Настройки интерфейса|Оформление интерфейса)$/.test(h))return['appearance',10]; if(id==='sunOfferTemplateSettingsCard'||h==='Шаблон предложения клиенту')return['offer',10]; if(id==='sunClientOfferSettingsCard'||h==='Настройки предложения клиенту')return['offer',20]; if(id==='sunOrderPaymentColorsCard'||h==='Цвета оплаты заказов')return['orders',10]; if(id==='sunOrderStatusFeatureCard'||h==='Статусы заказов')return['orders',20]; if(id==='sunEventTypesSettingsCard'||h==='Типы мероприятий')return['references',10]; if(id==='sunLeadSourcesSettingsCard'||h==='Источники клиентов')return['references',20]; if(id==='sunCatalogTabsSettingsCardV21'||h==='Вкладки каталога')return['references',30]; if(id==='sunPdfBrandCard')return['documents',5]; if(id==='sunReceiptSettingsCard'||h==='Настройки реквизитов товарного чека')return['documents',10]; if(id==='sunDefaultQrCard'||h==='QR-код для бланка заказа')return['documents',20]; if(id==='sunBlankSettingsCardV3'||h==='Настройки бланка')return['documents',30]; // Any future/unrecognised settings remain reachable instead of disappearing. return['account',60]; } function ensureCatalogTabsCard(){ const view=$('enterprise-settings'),grid=view?.querySelector('.enterprise-grid'); if(!grid||typeof window.sunOpenCategoryManager!=='function')return; let card=$('sunCatalogTabsSettingsCardV21'); if(!card){ card=document.createElement('section'); card.id='sunCatalogTabsSettingsCardV21'; card.className='enterprise-card wide'; grid.appendChild(card); } const cats=typeof window.sunCatalogCategories==='function'?window.sunCatalogCategories():[]; const total=Array.isArray(cats)?cats.length:0,visible=Array.isArray(cats)?cats.filter(x=>!x.hidden).length:0; card.innerHTML=`

    Вкладки каталога

    Название, порядок, цвет и видимость разделов каталога. Скрытые разделы и их товары не удаляются.

    Всего: ${total}Видимых: ${visible}
    `; $('sunSettingsCatalogTabsBtnV21')?.addEventListener('click',()=>window.sunOpenCategoryManager?.()); } function ensureBar(){ const view=$('enterprise-settings');if(!view)return null; let wrap=$('sunSettingsTabsWrapV21'); if(!wrap){ wrap=document.createElement('div');wrap.id='sunSettingsTabsWrapV21';wrap.className='sun-settings-tabs-wrap'; wrap.innerHTML=`
    ${TABS.map(t=>``).join('')}
    `; const head=view.querySelector(':scope > .catalog-head'); if(head)head.insertAdjacentElement('afterend',wrap);else view.prepend(wrap); qa('[data-settings-tab-target]',wrap).forEach(btn=>btn.addEventListener('click',()=>activate(btn.dataset.settingsTabTarget,true))); } return wrap; } function applyCards(){ const view=$('enterprise-settings'),grid=view?.querySelector('.enterprise-grid');if(!grid)return; qa(':scope > .enterprise-card',grid).forEach(card=>{ const [tab,order]=classify(card); card.dataset.settingsTab=tab; card.style.setProperty('--sun-settings-order',String(order)); card.hidden=tab!==active; card.setAttribute('aria-hidden',tab===active?'false':'true'); }); } function renderBar(){ const wrap=ensureBar();if(!wrap)return; const meta=TABS.find(x=>x.id===active)||TABS[0]; qa('[data-settings-tab-target]',wrap).forEach(btn=>{ const on=btn.dataset.settingsTabTarget===active; btn.classList.toggle('on',on);btn.setAttribute('aria-selected',on?'true':'false');btn.tabIndex=on?0:-1; if(on&&wrap.dataset.lastActive!==active)setTimeout(()=>btn.scrollIntoView?.({block:'nearest',inline:'nearest',behavior:'smooth'}),0); }); wrap.dataset.lastActive=active; const context=$('sunSettingsTabContextV21');if(context)context.innerHTML=`${meta.label}${meta.hint}`; } function activate(tab,persist=false){ if(!VALID.has(tab))tab='account';active=tab; if(persist){try{localStorage.setItem(TAB_KEY,active)}catch(_){}} renderBar();applyCards(); const view=$('enterprise-settings');if(view?.classList.contains('on')){ try{view.querySelector('.sun-settings-tabs-wrap')?.scrollIntoView({block:'nearest',inline:'nearest'})}catch(_){} } window.dispatchEvent(new CustomEvent('sun:settings-tab-changed',{detail:{tab:active}})); } function refresh(){ const view=$('enterprise-settings');if(!view)return; ensureCatalogTabsCard();ensureBar();applyCards();renderBar(); } function schedule(delay=30){clearTimeout(scheduled);scheduled=setTimeout(refresh,delay)} function boot(){ const view=$('enterprise-settings');if(!view)return; const observer=new MutationObserver(()=>schedule(20));observer.observe(view,{childList:true,subtree:true}); document.addEventListener('click',e=>{const b=e.target.closest('header nav button');if(b&&String(b.dataset.navLabel||b.textContent||'').trim()==='Настройки'){schedule(0);schedule(90);schedule(240)}},true); window.addEventListener('sun:catalog-categories-changed',()=>schedule(20)); window.addEventListener('sun:cloud-state-applied',()=>schedule(40)); window.addEventListener('suncloudsync',()=>schedule(40)); refresh(); } if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot(); })(); /* ===== MODULE: developer-console-v22.js ===== */ (()=>{ 'use strict'; if(window.SunDeveloperV22)return; const VERSION='2026.09.06-developer-console-v25-auth-mfa-logo'; const $=id=>String(id||'').startsWith('#')?document.querySelector(id):document.getElementById(id); const qa=(sel,root=document)=>[...root.querySelectorAll(sel)]; const esc=window.SunSafe.escapeHTML; const clone=v=>v==null?v:(typeof structuredClone==='function'?structuredClone(v):JSON.parse(JSON.stringify(v))); const cloud=()=>window.SunCloudV2||null; const client=()=>cloud()?.getClient?.()||null; const session=()=>cloud()?.getSession?.()||null; const toast=(t,k='info',ms=4600)=>{try{return window.SunEnterprise?.toast?.(t,k,ms)}catch(_){};alert(t)}; const fmtDate=v=>{if(!v)return '—';try{return new Date(v).toLocaleString('ru-RU',{dateStyle:'short',timeStyle:'short'})}catch(_){return String(v)}}; const fmtDay=v=>{if(!v)return '—';try{return new Date(v).toLocaleDateString('ru-RU')}catch(_){return String(v)}}; const TAB_KEY='sunDeveloperActiveTabV22'; const TABS=[['overview','Обзор'],['companies','Компании'],['accounts','Аккаунты'],['plans','Тарифы'],['features','Функции'],['system','Система'],['backups','Резервные копии'],['journal','Журнал']]; const FEATURES={orders:'Заказы',calendar:'Календарь',map:'Карта',clients:'Клиенты',catalog_view:'Каталог · просмотр',catalog_edit:'Каталог · редактирование',production:'Производство',shopping:'Закупки',stock:'Склад',routes:'Маршруты',mailings:'Рассылки',money:'Финансы',stats_basic:'Статистика',stats_advanced:'Расширенная статистика',team:'Команда',suppliers:'Поставщики',print:'Печать',settings:'Настройки',branding:'Фирменное оформление',client_offers:'Предложение клиенту',offer_templates:'Шаблоны предложения',backups:'Резервные копии',audit:'Журнал',users_manage:'Пользователи и права'}; const PLANS={basic:'Базовый',professional:'Профессиональный',full:'Полный'}; let platformAdmin=false,checking=false,activeTab=localStorage.getItem(TAB_KEY)||'overview',lastCompanies=[],lastAdminCheckAt=0; const DEV_ADMIN_TTL_MS=30000,DEV_ADMIN_TIMEOUT_MS=8000; function installStyle(){if($('sunDeveloperV22Style'))return;const st=document.createElement('style');st.id='sunDeveloperV22Style';st.textContent=` #sunDeveloperNavV22{font-weight:800}.sun-dev-view{max-width:1460px;margin:0 auto;padding:24px}.sun-dev-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px}.sun-dev-head h1{margin:0}.sun-dev-head-actions{display:flex;gap:8px;flex-wrap:wrap}.sun-dev-tabs{display:flex;gap:7px;overflow-x:auto;padding:4px 2px 12px;scrollbar-width:thin;border-bottom:1px solid #e7e8ec;margin-bottom:18px}.sun-dev-tabs button{white-space:nowrap;border:1px solid #d8dbe2;background:#fff;border-radius:999px;padding:9px 14px;font-weight:750;cursor:pointer}.sun-dev-tabs button.on{background:#171815;color:#fff;border-color:#171815}.sun-dev-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.sun-dev-kpi,.sun-dev-card{background:#fff;border:1px solid #e2e4e9;border-radius:16px;padding:16px;box-shadow:0 8px 28px rgba(30,32,36,.04)}.sun-dev-kpi small{color:#6f7480;display:block;margin-bottom:7px}.sun-dev-kpi b{font-size:24px}.sun-dev-card h2,.sun-dev-card h3{margin:0 0 10px}.sun-dev-toolbar{display:flex;gap:8px;align-items:end;flex-wrap:wrap;margin-bottom:12px}.sun-dev-toolbar label{min-width:180px}.sun-dev-toolbar input,.sun-dev-toolbar select,.sun-dev-card input,.sun-dev-card select,.sun-dev-card textarea{width:100%;box-sizing:border-box;padding:9px 10px;border:1px solid #d6d9df;border-radius:9px;background:#fff}.sun-dev-table-wrap{overflow:auto;border:1px solid #e2e4e9;border-radius:14px;background:#fff}.sun-dev-table{width:100%;border-collapse:collapse;min-width:900px}.sun-dev-table th,.sun-dev-table td{text-align:left;padding:11px 12px;border-bottom:1px solid #eceef2;vertical-align:top;font-size:13px}.sun-dev-table th{background:#f7f8fa;position:sticky;top:0;z-index:1}.sun-dev-actions{display:flex;gap:6px;flex-wrap:wrap}.sun-dev-pill{display:inline-flex;padding:4px 8px;border-radius:999px;background:#f0f2f5;font-size:11px;font-weight:750;margin:2px}.sun-dev-pill.ok{background:#e8f7ef;color:#14653b}.sun-dev-pill.warn{background:#fff3d9;color:#7d5300}.sun-dev-pill.bad{background:#fde8e8;color:#9d2424}.sun-dev-muted{color:#777d88;font-size:12px}.sun-dev-section-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.sun-dev-modal{position:fixed;inset:0;background:rgba(20,22,26,.52);z-index:100000;display:flex;align-items:center;justify-content:center;padding:18px}.sun-dev-modal-card{background:#fff;border-radius:18px;max-width:620px;width:min(620px,100%);max-height:90vh;overflow:auto;padding:20px;box-shadow:0 24px 80px rgba(0,0,0,.28)}.sun-dev-modal-head{display:flex;justify-content:space-between;gap:12px;align-items:center}.sun-dev-modal-head h2{margin:0}.sun-dev-modal-close{border:0;background:#f1f2f4;border-radius:10px;width:36px;height:36px;font-size:22px;cursor:pointer}.sun-dev-mfa-qr{max-width:220px;width:100%;display:block;margin:12px auto;border:10px solid #fff}.sun-dev-support-banner{position:fixed;left:50%;transform:translateX(-50%);bottom:16px;z-index:99995;background:#171815;color:#fff;border-radius:14px;padding:10px 12px;box-shadow:0 12px 36px rgba(0,0,0,.28);display:flex;align-items:center;gap:10px;max-width:calc(100vw - 30px)}.sun-dev-support-banner b{white-space:nowrap}.sun-dev-support-banner button{background:#fff;color:#171815;border:0;border-radius:9px;padding:7px 10px;font-weight:800;cursor:pointer}.sun-dev-danger{border:1px solid #efc8c8!important;color:#9f2424!important;background:#fff!important}.sun-dev-code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;background:#f5f6f8;border-radius:8px;padding:7px;overflow-wrap:anywhere}.sun-dev-empty{padding:26px;text-align:center;color:#747a84}.sun-dev-check{display:flex;align-items:center;gap:8px}.sun-dev-check input{width:18px!important;height:18px}.sun-dev-pre{white-space:pre-wrap;word-break:break-word;font:12px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace;background:#f7f8fa;padding:12px;border-radius:10px;max-height:340px;overflow:auto} @media(max-width:950px){.sun-dev-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.sun-dev-section-grid{grid-template-columns:1fr}.sun-dev-view{padding:15px}.sun-dev-head{flex-direction:column}.sun-dev-head-actions{width:100%}} @media(max-width:540px){.sun-dev-grid{grid-template-columns:1fr}.sun-dev-support-banner{left:15px;right:15px;transform:none;flex-wrap:wrap}.sun-dev-support-banner b{white-space:normal}} `;document.head.appendChild(st)} async function rpc(name,args={}){const c=client();if(!c)throw new Error('Supabase не подключён.');const r=await c.rpc(name,args);if(r.error)throw r.error;return r.data} async function log(action,workspace=null,user=null,details={}){try{await rpc('sun_dev_log_event',{p_action:action,p_workspace:workspace,p_user:user,p_details:details})}catch(_){}} async function checkPlatformAdmin(force=false){ if(checking)return platformAdmin;const now=Date.now();if(!force&&lastAdminCheckAt&&now-lastAdminCheckAt{timer=setTimeout(()=>reject(new Error('Developer access timeout')),DEV_ADMIN_TIMEOUT_MS)})]);platformAdmin=!r.error&&r.data===true;} catch(_){platformAdmin=false}finally{clearTimeout(timer);lastAdminCheckAt=Date.now();checking=false;syncChrome()}return platformAdmin } function isPlatformAdmin(){return platformAdmin===true} function ensureNav(){installStyle();const nav=document.querySelector('header nav');if(!nav||!platformAdmin)return null;$('sunSaaSAdminNavV16')?.remove();let b=$('sunDeveloperNavV22');if(!b){b=document.createElement('button');b.id='sunDeveloperNavV22';b.type='button';b.dataset.navLabel='Разработчик';b.textContent='Разработчик';nav.appendChild(b);b.addEventListener('click',()=>open(b))}b.hidden=false;b.style.display='';return b} function ensureView(){let v=$('sun-developer-console-v22');if(!v){v=document.createElement('section');v.id='sun-developer-console-v22';v.className='view page sun-dev-view';document.body.appendChild(v)}return v} function showDevView(btn=null){const v=ensureView();qa('.view').forEach(x=>x.classList.remove('on'));v.classList.add('on');qa('header nav button').forEach(x=>x.classList.remove('on'));(btn||$('sunDeveloperNavV22'))?.classList.add('on');window.scrollTo({top:0,behavior:'instant'});return v} function shell(){const v=ensureView();v.innerHTML=`

    Кабинет разработчика

    Управление платформой, компаниями и техническим состоянием приложения.

    Developer${esc(session()?.user?.email||'')}
    ${TABS.map(([id,label])=>``).join('')}
    Загрузка…
    `;qa('[data-dev-tab]',v).forEach(b=>b.onclick=()=>activateTab(b.dataset.devTab));$('sunDevRefresh').onclick=()=>activateTab(activeTab,true)} async function open(btn=null){if(!await checkPlatformAdmin(true))return toast('Этот аккаунт не имеет прав разработчика.','error');if(!await ensureDeveloperMfa())return;ensureNav();showDevView(btn);shell();await activateTab(activeTab,true)} async function activateTab(tab,force=false){if(!TABS.some(x=>x[0]===tab))tab='overview';activeTab=tab;localStorage.setItem(TAB_KEY,tab);qa('[data-dev-tab]').forEach(b=>b.classList.toggle('on',b.dataset.devTab===tab));const body=$('sunDevBody');if(!body)return;body.innerHTML='
    Загрузка…
    ';try{if(tab==='overview')await renderOverview(body);else if(tab==='companies')await renderCompanies(body);else if(tab==='accounts')await renderAccounts(body);else if(tab==='plans')await renderPlans(body);else if(tab==='features')await renderFeatures(body);else if(tab==='system')await renderSystem(body);else if(tab==='backups')await renderBackups(body);else if(tab==='journal')await renderJournal(body)}catch(e){body.innerHTML=`

    Не удалось загрузить раздел

    ${esc(e?.message||e)}

    `}} function modal(title,html){$('sunDevModalV22')?.remove();const m=document.createElement('div');m.className='sun-dev-modal';m.id='sunDevModalV22';m.innerHTML=`

    ${esc(title)}

    ${html}
    `;document.body.appendChild(m);const close=()=>m.remove();m.addEventListener('click',e=>{if(e.target===m||e.target.closest('[data-close]'))close()});return {m,body:$('#sunDevModalBody'),close}} function confirmDanger(text){return confirm(text)} async function ensureDeveloperMfa(){ const c=client();if(!c?.auth?.mfa)throw new Error('Supabase MFA недоступна.'); const readAal=async()=>{const a=await c.auth.mfa.getAuthenticatorAssuranceLevel();if(a.error)throw a.error;return a.data||{}}; const waitAal2=async()=>{for(let i=0;i<12;i++){const a=await readAal();if(a.currentLevel==='aal2')return true;await new Promise(r=>setTimeout(r,180))}return false}; const initial=await readAal();if(initial.currentLevel==='aal2')return true; const listed=await c.auth.mfa.listFactors();if(listed.error)throw listed.error; let factor=(listed.data?.totp||[]).find(f=>f.status==='verified');let enrollment=null; if(!factor){ for(const f of (listed.data?.totp||[]).filter(x=>x.status!=='verified')){ try{const u=await c.auth.mfa.unenroll({factorId:f.id});if(u.error&&!/not found/i.test(String(u.error.message||'')))throw u.error}catch(e){console.warn('[Developer MFA] reset factor',e)} } const enr=await c.auth.mfa.enroll({factorType:'totp',friendlyName:'Caterium Developer'});if(enr.error)throw enr.error;factor=enr.data;enrollment=enr.data; } const firstSetup=Boolean(enrollment),qr=enrollment?.totp?.qr_code||'',secret=enrollment?.totp?.secret||''; const ui=modal(firstSetup?'Подключение 2FA разработчика':'Подтверждение разработчика',firstSetup?`

    Отсканируйте этот QR-код приложением-аутентификатором. Затем введите текущие 6 цифр и нажмите Подтвердить и открыть.

    ${qr?`QR для 2FA`:''}${secret?`

    Если QR не сканируется, добавьте ключ вручную:

    ${esc(secret)}
    `:''}
    `:`

    Введите текущий 6-значный код из приложения-аутентификатора.

    `); return await new Promise(resolve=>{ let settled=false,busy=false; const finish=v=>{if(settled)return;settled=true;try{ui.close()}catch(_){}resolve(v)}; const showError=(e,notify=true)=>{const msg=e?.message||String(e||'Не удалось подтвердить код.');const box=$('sunDevMfaError');if(box)box.textContent=msg;if(notify)toast(msg,'error',6500)}; const verify=async()=>{ if(busy)return; const code=String(document.getElementById('sunDevMfaCode')?.value||'').replace(/\D/g,''); if(code.length!==6){showError(new Error('Введите ровно 6 цифр из приложения-аутентификатора.'),false);return} busy=true;const btn=$('sunDevMfaVerify');if(btn){btn.disabled=true;btn.textContent='Проверяю…'};const box=$('sunDevMfaError');if(box)box.textContent='Проверяю код…'; try{ let vr; if(typeof c.auth.mfa.challengeAndVerify==='function') vr=await c.auth.mfa.challengeAndVerify({factorId:factor.id,code}); else {const ch=await c.auth.mfa.challenge({factorId:factor.id});if(ch.error)throw ch.error;vr=await c.auth.mfa.verify({factorId:factor.id,challengeId:ch.data.id,code})} if(vr?.error)throw vr.error; if(!await waitAal2()){ try{await c.auth.refreshSession()}catch(_){} if(!await waitAal2())throw new Error('Код принят, но сессия разработчика не получила AAL2. Выйдите из аккаунта и войдите снова, затем повторите код.'); } toast('2FA подтверждена. Открываю кабинет разработчика…','success',2800); finish(true); }catch(e){busy=false;if(btn){btn.disabled=false;btn.textContent='Подтвердить и открыть'};showError(e)} }; const b=document.getElementById('sunDevMfaVerify'),inp=document.getElementById('sunDevMfaCode');if(b)b.addEventListener('click',verify);if(inp)inp.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();verify()}}); ui.m.addEventListener('click',e=>{if((e.target===ui.m||e.target.closest('[data-close]'))&&!busy)finish(false)}); setTimeout(()=>inp?.focus(),60); }) } async function renderOverview(body){const d=await rpc('sun_dev_dashboard');body.innerHTML=`
    Компании${Number(d.companies||0)}
    Аккаунты${Number(d.accounts||0)}
    Активные подписки${Number(d.active_subscriptions||0)}
    Ограниченные компании${Number(d.restricted_companies||0)}
    Ошибки за 24 ч.${Number(d.errors_24h||0)}
    Копии за 24 ч.${Number(d.backups_24h||0)}
    Участники компаний${Number(d.memberships||0)}
    Размер БД${esc(d.database_size||'—')}

    Платформа

    Версия приложения: 17.6.0

    PostgreSQL: ${esc(d.postgres_version||'—')}

    Последнее изменение рабочей базы: ${fmtDate(d.last_state_at)}

    Безопасность

    MFA AAL2 Кабинет разработчика защищён двухфакторной авторизацией.

    Права разработчика проверяются сервером через sun_platform_admins.

    `;try{const r=await fetch('release-manifest.json',{cache:'no-store'});if(r.ok){const m=await r.json();$('#sunDevReleaseVersion').textContent=m.version||'17.6.0'}}catch(_){} } async function getCompanies(){let rows=[];try{rows=await rpc('sun_dev_list_companies')}catch(_){rows=await rpc('sun_dev_list_companies')}lastCompanies=Array.isArray(rows)?rows:[];return lastCompanies} function companyOptions(rows,selected=''){return `${rows.map(r=>``).join('')}`} async function renderCompanies(body){const rows=await getCompanies();body.innerHTML=`
    КомпанияВладелецТарифДоступСотрудникиСозданаДействия
    `;const draw=()=>{const q=String($('#sunDevCompanySearch').value||'').trim().toLowerCase();const filtered=rows.filter(r=>`${r.workspace_name||''} ${r.owner_email||''}`.toLowerCase().includes(q));$('#sunDevCompaniesTbody').innerHTML=filtered.map(r=>`${esc(r.workspace_name)}
    ${esc(r.workspace_id)}
    ${esc(r.owner_email||r.pending_owner_email||'—')}${r.pending_owner_email?'
    ожидает регистрации
    ':''}${esc(PLANS[r.plan_id]||r.plan_name||r.plan_id||'—')}
    до ${fmtDay(r.trial_ends_at||r.current_period_end)}
    ${esc(r.access_mode||r.status||'—')}${Number(r.member_count||0)} / ${r.max_members==null?'∞':r.max_members}${fmtDay(r.created_at)}
    `).join('')||'Компаний не найдено.';bindCompanyRows()};draw();$('#sunDevCompanySearch').oninput=draw;$('#sunDevCreateCompany').onclick=openCreateCompany} function bindCompanyRows(){qa('#sunDevCompaniesTbody tr[data-ws]').forEach(tr=>{const ws=tr.dataset.ws,row=lastCompanies.find(x=>String(x.workspace_id)===ws);tr.querySelector('[data-support]').onclick=()=>enterSupport(row);tr.querySelector('[data-diag]').onclick=()=>showDiagnostics(row);tr.querySelector('[data-plan]').onclick=()=>showCompanyPlan(row);tr.querySelector('[data-block]').onclick=async()=>{const blocked=row.access_mode==='blocked';if(!blocked&&!confirmDanger(`Заблокировать компанию «${row.workspace_name}»? Данные не удалятся.`))return;await rpc('sun_dev_set_subscription',{p_workspace:ws,p_plan:row.plan_id||'full',p_days:blocked?30:0,p_status:blocked?'active':'expired'});await log(blocked?'company.unblock':'company.block',ws,null,{company:row.workspace_name});toast(blocked?'Компания разблокирована.':'Компания заблокирована.','success');activateTab('companies',true)}})} function openCreateCompany(){const ui=modal('Создать компанию',`

    Компания начнёт с пустого каталога, без заказов и клиентов.

    `);$('#sunDevNewCreate').onclick=async()=>{const name=String($('#sunDevNewCompanyName').value||'').trim(),email=String($('#sunDevNewOwnerEmail').value||'').trim(),plan=$('#sunDevNewPlan').value,days=Number($('#sunDevNewDays').value||30);if(!name)return $('#sunDevNewError').textContent='Введите название.';const btn=$('#sunDevNewCreate');btn.disabled=true;$('#sunDevNewError').textContent='Создаю…';try{const data=await rpc('sun_dev_create_company',{p_name:name,p_owner_email:email||null,p_plan:plan,p_days:days,p_boxes:[],p_catalog_version:String(window.SUN_OFFICIAL_CATALOG_VERSION||'')});ui.body.innerHTML=`

    Компания создана: ${esc(data.workspace_name||name)}

    Каталог: ${Number(data.catalog_count||0)} позиций.

    ${data.owner_status==='invite'?`

    Владелец ещё не зарегистрирован. Код приглашения сохранён на сервере.

    `:''}
    `;setTimeout(()=>activateTab('companies',true),400)}catch(e){btn.disabled=false;$('#sunDevNewError').textContent=e?.message||String(e)}}} function showCompanyPlan(row){const ui=modal(`Тариф · ${row.workspace_name}`,`
    `);$('#sunDevPlanSave').onclick=async()=>{await rpc('sun_dev_set_subscription',{p_workspace:row.workspace_id,p_plan:$('#sunDevPlanSelect').value,p_days:Number($('#sunDevPlanDays').value),p_status:'active'});await log('company.subscription.set',row.workspace_id,null,{plan:$('#sunDevPlanSelect').value,days:Number($('#sunDevPlanDays').value)});ui.close();toast('Тариф обновлён.','success');activateTab('companies',true)}} async function showDiagnostics(row){const d=await rpc('sun_dev_workspace_diagnostics',{p_workspace:row.workspace_id});modal(`Диагностика · ${row.workspace_name}`,`
    Заказы${Number(d.orders||0)}
    Клиенты${Number(d.clients||0)}
    Каталог${Number(d.catalog_items||0)}
    Ошибки 24 ч.${Number(d.errors_24h||0)}

    Revision: ${Number(d.revision||0)}

    Обновлено: ${fmtDate(d.state_updated_at)}

    Последняя копия: ${fmtDate(d.latest_backup)}

    Режим доступа: ${esc(d.access_mode||'—')}

    `)} async function enterSupport(row){if(!row)return;await cloud().enterSupportWorkspace(row.workspace_id,row.workspace_name);await log('support.ui_enter',row.workspace_id,null,{company:row.workspace_name});ensureSupportBanner();try{window.render?.();window.renderOrders?.();window.renderStats?.()}catch(_){}const orders=qa('header nav button').find(b=>String(b.dataset.navLabel||b.textContent||'').trim()==='Заказы');if(orders)orders.click();else{qa('.view').forEach(v=>v.classList.remove('on'));$('orders')?.classList.add('on')}toast('Режим поддержки открыт. Изменения не будут сохранены.','info',6500)} function ensureSupportBanner(){let b=$('sunDevSupportBannerV22');const sm=cloud()?.getSupportMode?.();if(!sm){b?.remove();return}if(!b){b=document.createElement('div');b.id='sunDevSupportBannerV22';b.className='sun-dev-support-banner';document.body.appendChild(b)}b.innerHTML=`Поддержка:${esc(sm.name||'Компания')}только просмотр`;b.querySelector('button').onclick=async()=>{try{await log('support.exit',sm.workspaceId,null,{company:sm.name});await cloud().exitSupportWorkspace()}catch(e){toast(e?.message||String(e),'error')}}} async function fetchAccounts(){const rows=await rpc('sun_dev_list_users');const map=new Map();for(const r of rows||[]){const id=String(r.user_id);if(!map.has(id))map.set(id,{id,email:r.email,created_at:r.created_at,last_sign_in_at:r.last_sign_in_at,email_confirmed_at:'',is_platform_admin:r.is_platform_admin===true,is_banned:false,authAdminActions:false,workspaces:[]});if(r.workspace_id)map.get(id).workspaces.push({id:r.workspace_id,name:r.workspace_name,role:r.role,is_active:r.is_active!==false})}return [...map.values()]} async function renderAccounts(body){const users=await fetchAccounts();body.innerHTML=`
    ${users.length} аккаунтов
    АккаунтКомпанииРегистрацияПоследний входСтатусДействия
    `;const draw=()=>{const q=String($('#sunDevUserSearch').value||'').trim().toLowerCase();const arr=users.filter(u=>`${u.email||''} ${(u.workspaces||[]).map(w=>w.name).join(' ')}`.toLowerCase().includes(q));$('#sunDevUsersTbody').innerHTML=arr.map(u=>`${esc(u.email||'—')}${u.is_platform_admin?'
    Разработчик
    ':''}
    ${esc(u.id)}
    ${(u.workspaces||[]).length?(u.workspaces||[]).map(w=>`${esc(w.name)} · ${esc(w.role||'')}`).join(''):'—'}${fmtDate(u.created_at)}${fmtDate(u.last_sign_in_at)}${u.is_banned?'заблокирован':'активен'}${u.email_confirmed_at?'
    email подтверждён
    ':''}${u.authAdminActions?`
    ${u.is_platform_admin?'':``}
    `:'Просмотр аккаунта доступен. Глобальная блокировка Auth и письмо восстановления требуют защищённой backend-функции.'}`).join('')||'Ничего не найдено.';qa('#sunDevUsersTbody tr[data-user]').forEach(tr=>{const u=users.find(x=>String(x.id)===tr.dataset.user);tr.querySelector('[data-recovery]')?.addEventListener('click',()=>authAdminAction('send_recovery',u));tr.querySelector('[data-ban]')?.addEventListener('click',()=>authAdminAction('set_ban',u,{banned:!u.is_banned}))})};draw();$('#sunDevUserSearch').oninput=draw} async function authAdminAction(action,u,extra={}){if(action==='set_ban'&&extra.banned&&!confirmDanger(`Заблокировать вход для ${u.email}?`))return;const r=await client().functions.invoke('caterium-platform-auth-admin',{body:{action,user_id:u.id,...extra}});if(r.error)throw r.error;if(r.data?.error)throw new Error(r.data.error);toast(action==='send_recovery'?'Письмо восстановления отправлено.':extra.banned?'Аккаунт заблокирован.':'Аккаунт разблокирован.','success');activateTab('accounts',true)} async function renderPlans(body){const c=client();const [plansR,featuresR]=await Promise.all([c.from('sun_plans').select('id,name,description,max_members,is_active,sort_order').order('sort_order'),c.from('sun_plan_features').select('plan_id,feature_key,enabled')]);if(plansR.error)throw plansR.error;if(featuresR.error)throw featuresR.error;const plans=plansR.data||[],features=featuresR.data||[];body.innerHTML=`
    ${plans.map(p=>`

    ${esc(p.name)}

    ${esc(p.description||'')}

    Функции

    ${Object.keys(FEATURES).map(f=>{const row=features.find(x=>x.plan_id===p.id&&x.feature_key===f);return ``}).join('')}
    `).join('')}
    `;qa('[data-plan-card]').forEach(card=>{const plan=card.dataset.planCard;card.querySelector('[data-save-max]').onclick=async()=>{const raw=card.querySelector('[data-max]').value;await rpc('sun_dev_set_plan_max_members',{p_plan:plan,p_max_members:raw===''?null:Number(raw)});toast('Лимит сохранён.','success')};qa('[data-feature]',card).forEach(ch=>ch.onchange=async()=>{ch.disabled=true;try{await rpc('sun_dev_set_plan_feature',{p_plan:plan,p_feature:ch.dataset.feature,p_enabled:ch.checked});toast('Матрица тарифа обновлена.','success')}catch(e){ch.checked=!ch.checked;toast(e?.message||String(e),'error')}finally{ch.disabled=false}})})} async function renderFeatures(body){const rows=lastCompanies.length?lastCompanies:await getCompanies();body.innerHTML=`
    Выберите компанию.
    `;const load=async()=>{const ws=$('#sunDevFeatureCompany').value;if(!ws)return;const data=await rpc('sun_dev_list_workspace_features',{p_workspace:ws});const root=$('sunDevFeatureBody');root.className='sun-dev-table-wrap';root.innerHTML=`${(data||[]).map(f=>``).join('')}
    ФункцияПо тарифуИндивидуальноИтогДействия
    ${esc(FEATURES[f.feature_key]||f.feature_key)}${f.plan_enabled?'✓':'—'}${f.override_enabled==null?'нет':f.override_enabled?'включено':'выключено'}
    ${f.override_expires_at?`до ${fmtDay(f.override_expires_at)}`:''}
    ${f.effective_enabled?'доступна':'закрыта'}
    `;qa('tr[data-feature]',root).forEach(tr=>{const f=tr.dataset.feature;tr.querySelector('[data-enable]').onclick=()=>setOverride(ws,f,true,30,load);tr.querySelector('[data-disable]').onclick=()=>setOverride(ws,f,false,30,load);tr.querySelector('[data-reset]').onclick=async()=>{await rpc('sun_dev_reset_feature_override',{p_workspace:ws,p_feature:f});toast('Индивидуальное правило сброшено.','success');load()}})};$('#sunDevFeatureLoad').onclick=load;$('#sunDevFeatureCompany').onchange=load} async function setOverride(ws,f,en,days,refresh){await rpc('sun_dev_set_feature_override',{p_workspace:ws,p_feature:f,p_enabled:en,p_days:days,p_note:'Developer console v17.6.0'});await log('feature.override.set',ws,null,{feature:f,enabled:en,days});toast('Индивидуальная функция обновлена.','success');await refresh()} async function renderSystem(body){const cfg=cloud()?.getTechnicalConfig?.()||{},d=await rpc('sun_dev_dashboard');let stability=null;try{stability=await window.SunStabilityV17?.diagnostics?.()}catch(_){}const rows=lastCompanies.length?lastCompanies:await getCompanies();body.innerHTML=`

    Подключение Supabase

    Эти параметры скрыты от обычных пользователей.

    Состояние

    База: ${esc(d.database_size||'—')}

    PostgreSQL: ${esc(d.postgres_version||'—')}

    Последняя синхронизация: ${fmtDate(cfg.lastSync)}

    Ошибки 24 ч.: ${Number(d.errors_24h||0)}

    Client ID: ${esc(cloud()?.getClientId?.()||'')}

    Стартовый каталог

    ${Number(window.SUN_OFFICIAL_CATALOG?.length||0)} позиций · версия ${esc(window.SUN_OFFICIAL_CATALOG_VERSION||'—')}

    Новые компании получают этот каталог автоматически. Существующие компании не меняются без явной команды.

    Диагностика клиента

    ${esc(stability?JSON.stringify(stability,null,2):'Диагностика stability-модуля недоступна.')}
    `;$('#sunDevAutoSync').onchange=e=>cloud().setAutoSyncDeveloper?.(e.target.checked);$('#sunDevReconnect').onclick=async()=>{if(!confirmDanger('Переподключить приложение к указанному Supabase? Неверные данные могут временно лишить приложение облака.'))return;await cloud().setTechnicalConnection($('#sunDevSupabaseUrl').value,$('#sunDevSupabaseKey').value);toast('Подключение обновлено.','success')};$('#sunDevForceCatalog').onclick=async()=>{const ws=$('#sunDevCatalogCompany').value;if(!ws)return toast('Выберите компанию.','warn');const row=rows.find(r=>r.workspace_id===ws);if(!confirmDanger(`ЗАМЕНИТЬ каталог компании «${row?.workspace_name||ws}» текущим стартовым каталогом? Собственный каталог компании будет перезаписан.`))return;const n=await rpc('sun_dev_seed_workspace_catalog',{p_workspace:ws,p_boxes:window.SUN_OFFICIAL_CATALOG||[],p_catalog_version:String(window.SUN_OFFICIAL_CATALOG_VERSION||''),p_replace:true});toast(`Каталог заменён: ${n} позиций.`,'success',6500)}} async function renderBackups(body){const rows=lastCompanies.length?lastCompanies:await getCompanies();body.innerHTML=`
    Выберите компанию.
    `;const load=async()=>{const ws=$('#sunDevBackupCompany').value;if(!ws)return;const data=await rpc('sun_v17_list_backups',{p_workspace:ws,p_limit:60});const root=$('sunDevBackupBody');root.className='sun-dev-table-wrap';root.innerHTML=`${(data||[]).map(b=>``).join('')||''}
    ДатаТипМеткаIDДействия
    ${fmtDate(b.created_at)}${esc(b.kind)}${esc(b.label||'—')}${esc(b.id)}
    Копий нет.
    `;qa('tr[data-backup]',root).forEach(tr=>tr.querySelector('[data-restore]').onclick=async()=>{if(!confirmDanger('Восстановить эту резервную копию? Перед восстановлением автоматически создастся новая копия текущего состояния.'))return;await rpc('sun_v17_restore_backup',{p_workspace:ws,p_backup:tr.dataset.backup});await log('backup.restore',ws,null,{backup_id:tr.dataset.backup});toast('Резервная копия восстановлена.','success',6500);load()})};$('#sunDevBackupLoad').onclick=load;$('#sunDevBackupCompany').onchange=load;$('#sunDevBackupCreate').onclick=async()=>{const ws=$('#sunDevBackupCompany').value;if(!ws)return toast('Выберите компанию.','warn');const id=await rpc('sun_v17_create_backup',{p_workspace:ws,p_kind:'manual',p_label:'Создано из кабинета разработчика'});await log('backup.create',ws,null,{backup_id:id});toast('Резервная копия создана.','success');load()}} async function renderJournal(body){const [activity,errors]=await Promise.all([rpc('sun_dev_list_activity',{p_limit:150}),rpc('sun_dev_list_errors',{p_workspace:null,p_limit:100})]);body.innerHTML=`

    Действия разработчика

    ${(activity||[]).map(a=>``).join('')||''}
    ДатаДействиеКомпания / аккаунтДетали
    ${fmtDate(a.created_at)}${esc(a.action)}
    ${esc(a.actor_email||'')}
    ${esc(a.workspace_name||a.target_email||'—')}${esc(JSON.stringify(a.details||{}))}
    Записей пока нет.

    Ошибки приложения

    ${(errors||[]).map(e=>``).join('')||''}
    ДатаКомпанияВерсияОшибка
    ${fmtDate(e.created_at)}${esc(e.workspace_name||'—')}${esc(e.app_version||'—')}${esc(e.level||'error')}
    ${esc(e.message||'')}
    Ошибок нет.
    `} function enhanceDeveloperGate(){const gate=$('sunCloudAuthGateV3'),ss=session(),ws=cloud()?.getWorkspace?.(),st=cloud()?.status?.();if(!platformAdmin||!gate||!ss?.user||ws||st?.membershipsLoading||st?.membershipError||st?.membershipsLoaded===false)return;const card=gate.querySelector('.sun-cloud-auth-card');if(!card||card.dataset.devEnhanced==='1')return;card.dataset.devEnhanced='1';gate.dataset.saasEnhanced='1';card.innerHTML=`
    Caterium

    Аккаунт разработчика

    ${esc(ss.user.email||'')}

    Этот аккаунт управляет платформой и не обязан иметь собственную рабочую компанию.

    `;card.querySelector('[data-open-dev]').onclick=()=>open();card.querySelector('[data-signout]').onclick=()=>cloud()?.signOut?.()} function syncChrome(){if(platformAdmin){ensureNav();enhanceDeveloperGate()}else{$('sunDeveloperNavV22')?.remove()}ensureSupportBanner()} function boot(){installStyle();let tries=0;const timer=setInterval(async()=>{tries++;if(document.hidden)return;if(client()&&session()?.user){await checkPlatformAdmin(false);enhanceDeveloperGate();ensureSupportBanner()}else if(!session()?.user){platformAdmin=false;syncChrome()}if(tries>40)clearInterval(timer)},3000);window.addEventListener('sun:cloud-permissions-changed',()=>setTimeout(()=>checkPlatformAdmin(true),120));window.addEventListener('sun:cloud-state-applied',()=>ensureSupportBanner());document.addEventListener('click',e=>{const b=e.target.closest('header nav button');if(!b)return;if(String(b.dataset.navLabel||b.textContent||'').trim()==='Настройки'&&platformAdmin)setTimeout(()=>{$('sunSaaSSettingsCardV16')?.querySelector('[data-saas-admin]')?.remove()},120)},true);setTimeout(()=>checkPlatformAdmin(true),900)} window.SunDeveloperV22={VERSION,open,isPlatformAdmin,checkPlatformAdmin,ensureNav,activateTab,ensureSupportBanner}; if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot(); })(); /* ===== MODULE: chat-v31.js ===== */ (() => { 'use strict'; if (window.SunChatV31) return; const VERSION = '2026.09.06-chat-v31'; const $ = id => document.getElementById(id); const qa = (s,r=document) => [...r.querySelectorAll(s)]; const esc=window.SunSafe.escapeHTML; const toast = (text,type='info',timeout=4500) => { try { if(window.SunEnterprise?.toast) return window.SunEnterprise.toast(text,type,timeout); } catch (_) {} alert(text); }; const ROLE_LABELS = {admin:'Администратор',manager:'Менеджер',kitchen:'Кухня',courier:'Курьер',viewer:'Просмотр'}; let threads = []; let members = []; let messages = []; let activeThread = null; let workspaceChannel = null; let threadChannel = null; let workspaceChannelId = ''; let threadChannelId = ''; let online = new Set(); let pendingFiles = []; let loadingThreads = false; let loadingMessages = false; let typingStopTimer = null; let lastTypingSent = 0; let typingUsers = new Map(); let sidebarMode = 'general'; let chatDock = 'global'; let bootRetryTimer = null; function cloud(){ return window.SunCloudV2 || null; } function client(){ return cloud()?.getClient?.() || null; } function session(){ return cloud()?.getSession?.() || null; } function workspace(){ return cloud()?.getWorkspace?.() || null; } function supportMode(){ return Boolean(cloud()?.isSupportMode?.()); } function available(){ return Boolean(client() && session()?.user && workspace()?.id && !supportMode()); } function me(){ return session()?.user?.id || ''; } function meName(){ const ws=workspace();return ws?.display_name || session()?.user?.email?.split('@')[0] || 'Сотрудник'; } async function rpc(name,args={}){ const c=client();if(!c)throw new Error('Supabase не подключён.');const r=await c.rpc(name,args);if(r.error)throw r.error;return r.data; } function fmtTime(v){ if(!v)return '';try{return new Intl.DateTimeFormat('ru-RU',{hour:'2-digit',minute:'2-digit'}).format(new Date(v))}catch(_){return ''} } function fmtDay(v){ if(!v)return '';try{return new Intl.DateTimeFormat('ru-RU',{day:'numeric',month:'long'}).format(new Date(v))}catch(_){return ''} } function sameDay(a,b){ try{const x=new Date(a),y=new Date(b);return x.getFullYear()===y.getFullYear()&&x.getMonth()===y.getMonth()&&x.getDate()===y.getDate()}catch(_){return false} } function bytes(n){ n=Number(n||0);if(n<1024)return `${n} Б`;if(n<1024*1024)return `${Math.round(n/1024)} КБ`;return `${(n/1024/1024).toFixed(1)} МБ`; } function safeName(name){ return String(name||'file').replace(/[\\/:*?"<>|\u0000-\u001f]+/g,'-').replace(/\s+/g,' ').trim().slice(0,120)||'file'; } function uid(){ try{return window.sunUUID?.() || crypto.randomUUID()}catch(_){return `${Date.now()}-${Math.random().toString(36).slice(2)}`} } function currentOrderId(){ try{return typeof draft!=='undefined'&&draft?.id?String(draft.id):''}catch(_){return ''} } function savedOrders(){ try{return Array.isArray(orders)?orders.slice():[]}catch(_){return []} } function installStyle(){ if($('sun-chat-style-v31'))return; $('sun-chat-style-v29')?.remove(); const s=document.createElement('style');s.id='sun-chat-style-v31';s.textContent=` #sunChatNavV29{position:relative}.sun-chat-nav-badge{display:none;position:absolute;right:5px;top:5px;min-width:18px;height:18px;border-radius:999px;padding:0 5px;background:#d83b35;color:#fff;font:800 10px/18px Arial;text-align:center;box-shadow:0 0 0 2px var(--sun-sidebar-bg,#32255d)}.sun-chat-nav-badge.on{display:block} #sun-chat-view{padding:16px;min-height:calc(100vh - 70px);background:#f3f4f7}.sun-chat-shell{max-width:1420px;margin:0 auto;height:calc(100vh - 102px);min-height:620px;background:#fff;border:1px solid #e0e4e9;border-radius:18px;overflow:hidden;display:grid;grid-template-columns:330px minmax(0,1fr);box-shadow:0 12px 40px rgba(30,40,60,.07)} .sun-chat-side{border-right:1px solid #e6e9ed;background:#fafbfc;display:flex;flex-direction:column;min-width:0}.sun-chat-side-head{padding:17px 17px 12px;border-bottom:1px solid #e8ebef}.sun-chat-side-head h1{font-size:22px;margin:0;color:#17384d}.sun-chat-side-head p{margin:5px 0 11px;color:#7a838d;font-size:12px}.sun-chat-section-tabs{display:grid;grid-template-columns:repeat(3,1fr);gap:5px}.sun-chat-section-tab{position:relative;border:1px solid #dfe4e8;background:#fff;color:#53616d;border-radius:10px;min-height:34px;padding:6px 5px;font-size:10.5px;font-weight:850;cursor:pointer}.sun-chat-section-tab.on{background:#17384d;color:#fff;border-color:#17384d}.sun-chat-section-badge{display:none;position:absolute;right:3px;top:2px;min-width:15px;height:15px;padding:0 4px;border-radius:999px;background:#d83b35;color:#fff;font:800 8px/15px Arial}.sun-chat-section-badge.on{display:block}.sun-chat-side-scroll{overflow:auto;padding:10px;flex:1}.sun-chat-group-title{display:flex;align-items:center;justify-content:space-between;padding:10px 8px 6px;color:#8a919a;font-size:10px;text-transform:uppercase;letter-spacing:.08em;font-weight:900}.sun-chat-list{display:grid;gap:3px}.sun-chat-thread{width:100%;border:0;background:transparent;border-radius:11px;padding:9px 10px;text-align:left;display:grid;grid-template-columns:36px minmax(0,1fr) auto;gap:9px;align-items:center;cursor:pointer;color:#223342}.sun-chat-thread:hover{background:#f0f3f5}.sun-chat-thread.on{background:#eaf1f4}.sun-chat-avatar{width:36px;height:36px;border-radius:12px;background:#e3eaee;display:grid;place-items:center;font-weight:900;color:#36566b;position:relative;flex:none}.sun-chat-avatar.company{background:#1e4057;color:#fff}.sun-chat-avatar.order{background:#f3ead3;color:#7b5d1f}.sun-chat-presence-dot{position:absolute;width:10px;height:10px;border-radius:50%;right:-2px;bottom:-2px;background:#aeb5bb;border:2px solid #fff}.sun-chat-presence-dot.on{background:#28a568}.sun-chat-thread-main{min-width:0}.sun-chat-thread-title{font-size:13px;font-weight:850;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sun-chat-thread-preview{font-size:10.5px;color:#838b94;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sun-chat-unread{min-width:20px;height:20px;padding:0 6px;border-radius:999px;background:#17384d;color:#fff;font:800 10px/20px Arial;text-align:center}.sun-chat-role{font-size:9px;color:#9299a1;margin-top:2px} .sun-chat-main{display:flex;flex-direction:column;min-width:0;min-height:0;background:#fff}.sun-chat-head{min-height:68px;border-bottom:1px solid #e7eaee;padding:10px 16px;display:flex;align-items:center;gap:12px}.sun-chat-head-back{border:0;background:#f0f2f4;border-radius:10px;padding:8px 10px;cursor:pointer;font-weight:800}.sun-chat-head-text{min-width:0;flex:1}.sun-chat-head-title{font-size:16px;font-weight:900;color:#213649;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sun-chat-head-sub{font-size:11px;color:#808891;margin-top:3px}.sun-chat-online-text{color:#22845a}.sun-chat-body{flex:1;min-height:0;overflow:auto;background:linear-gradient(#f8f9fb,#f5f7f9);padding:18px}.sun-chat-empty{height:100%;display:grid;place-items:center;text-align:center;color:#858d96;padding:30px}.sun-chat-empty b{display:block;color:#52616d;font-size:18px;margin-bottom:5px}.sun-chat-day{text-align:center;margin:10px 0 14px}.sun-chat-day span{display:inline-block;background:#e7ebee;color:#707a83;border-radius:999px;padding:4px 9px;font-size:10px}.sun-chat-msg{max-width:min(680px,82%);margin:6px 0;display:flex;gap:8px;align-items:flex-end}.sun-chat-msg.own{margin-left:auto;flex-direction:row-reverse}.sun-chat-bubble{border:1px solid #e1e5e9;background:#fff;border-radius:15px 15px 15px 4px;padding:9px 11px;box-shadow:0 3px 12px rgba(20,30,40,.035);min-width:100px}.sun-chat-msg.own .sun-chat-bubble{background:#e8f1f4;border-color:#d8e6eb;border-radius:15px 15px 4px 15px}.sun-chat-sender{font-size:10px;font-weight:850;color:#47667a;margin-bottom:4px}.sun-chat-text{font-size:13px;line-height:1.42;color:#253642;overflow-wrap:anywhere;white-space:pre-wrap}.sun-chat-meta{display:flex;gap:7px;justify-content:flex-end;align-items:center;margin-top:5px;font-size:9px;color:#8c949c}.sun-chat-read{font-weight:750;color:#718693}.sun-chat-files{display:grid;gap:6px;margin-top:7px}.sun-chat-file{border:1px solid #dfe4e8;border-radius:10px;background:rgba(255,255,255,.7);overflow:hidden;display:block;color:#2d536a;text-decoration:none;max-width:360px}.sun-chat-file-img{width:100%;max-height:260px;display:block;object-fit:cover;background:#eef1f3}.sun-chat-file-line{display:flex;align-items:center;gap:8px;padding:8px 9px}.sun-chat-file-icon{font-size:20px}.sun-chat-file-name{font-size:11px;font-weight:750;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sun-chat-file-size{font-size:9px;color:#8a929a;margin-top:2px} .sun-chat-typing{height:22px;padding:3px 16px;color:#71808a;font-size:10px;background:#f8f9fb}.sun-chat-composer{border-top:1px solid #e4e8eb;padding:10px 12px;background:#fff}.sun-chat-pending{display:flex;gap:6px;flex-wrap:wrap;margin:0 0 8px}.sun-chat-pending:empty{display:none}.sun-chat-pending-chip{display:flex;align-items:center;gap:6px;background:#eef2f4;border-radius:9px;padding:5px 7px;max-width:260px}.sun-chat-pending-chip span{font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sun-chat-pending-chip button{border:0;background:transparent;cursor:pointer;font-size:15px}.sun-chat-compose-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:8px;align-items:end}.sun-chat-attach,.sun-chat-send{border:0;border-radius:11px;height:40px;cursor:pointer;font-weight:850}.sun-chat-attach{width:42px;background:#eef2f4;color:#476170;font-size:20px}.sun-chat-send{padding:0 17px;background:#17384d;color:#fff}.sun-chat-send:disabled{opacity:.55;cursor:default}.sun-chat-input{width:100%;min-height:40px;max-height:130px;resize:none;border:1px solid #d7dde1;border-radius:12px;padding:10px 12px;line-height:1.35;outline:none}.sun-chat-input:focus{border-color:#7896a9;box-shadow:0 0 0 3px rgba(70,110,135,.08)} .sun-chat-order-tab{position:relative}.sun-chat-order-tab .sun-chat-mini-badge{position:absolute;top:-2px;right:-3px;background:#d83b35;color:#fff;border-radius:999px;min-width:16px;height:16px;padding:0 4px;font:800 9px/16px Arial}.sun-order-chat-panel{display:none;min-height:570px;height:min(68vh,760px);border:1px solid #e1e5e9;border-radius:14px;overflow:hidden;margin-top:10px;background:#fff}.sun-order-chat-panel.on{display:block}.sun-order-chat-panel .sun-chat-main{height:100%;min-height:0}.sun-chat-offline-note{padding:14px;border-radius:12px;background:#fff6e2;color:#85631c;margin:18px} @media(max-width:850px){#sun-chat-view{padding:0}.sun-chat-shell{height:calc(100vh - 94px);min-height:0;border-radius:0;border-left:0;border-right:0;grid-template-columns:1fr}.sun-chat-side{display:flex}.sun-chat-main{display:none}.sun-chat-shell.thread-open .sun-chat-side{display:none}.sun-chat-shell.thread-open .sun-chat-main{display:flex}.sun-chat-msg{max-width:92%}.sun-chat-body{padding:12px}.sun-chat-section-tab{font-size:10px}.sun-order-chat-panel{height:62vh;min-height:460px}.sun-order-chat-panel .sun-chat-main{display:flex!important}.sun-order-chat-panel .sun-chat-head{min-height:58px}} `;document.head.appendChild(s); } function ensureView(){ installStyle(); let v=$('sun-chat-view'); if(!v){ v=document.createElement('section');v.id='sun-chat-view';v.className='view';v.innerHTML=`
    Выберите чатОбщий чат компании, сотрудника или обсуждение заказа.
    `; const firstModal=document.querySelector('body > .modal');if(firstModal)firstModal.before(v);else document.body.appendChild(v); $('sunChatAttachV29').onclick=()=>$('sunChatFilesV29').click(); $('sunChatFilesV29').onchange=e=>addPendingFiles(e.target.files); $('sunChatSendV29').onclick=sendMessage; $('sunChatInputV29').addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendMessage();}}); $('sunChatInputV29').addEventListener('input',()=>{autoGrowInput();sendTyping(true);}); qa('[data-chat-section]',v).forEach(b=>b.addEventListener('click',()=>switchSidebarMode(b.dataset.chatSection))); } return v; } function ensureNav(){ const nav=document.querySelector('header nav');if(!nav)return null; let b=$('sunChatNavV29'); if(!available()){b?.remove();return null;} if(!b){ b=document.createElement('button');b.id='sunChatNavV29';b.type='button';b.dataset.navLabel='Чат';b.className='nav-chat';b.innerHTML=`Чат `; b.addEventListener('click',()=>openMain({mode:'general'})); } if(typeof window.sunRegisterNavButton==='function'){ window.sunRegisterNavButton({button:b,group:'management',beforeLabel:'Настройки',navLabel:'Чат',className:'nav-chat'}); }else{ const group=nav.querySelector('.sun-nav-management')||nav; if(b.parentElement!==group)group.appendChild(b); b.classList.add('sun-nav-button','nav-chat'); } return b; } function setNavBadge(n){ const b=$('sunChatNavBadgeV29');if(!b)return;n=Number(n||0);b.textContent=n>99?'99+':String(n);b.classList.toggle('on',n>0); } function setSectionBadge(mode,n){const b=document.querySelector(`[data-chat-section-badge="${mode}"]`);if(!b)return;n=Number(n||0);b.textContent=n>99?'99+':String(n);b.classList.toggle('on',n>0);} function setActiveNav(){qa('header nav button').forEach(x=>x.classList.remove('on'));$('sunChatNavV29')?.classList.add('on');qa('.view').forEach(x=>x.classList.remove('on'));ensureView().classList.add('on');} function threadIcon(t){if(t.kind==='company')return ['КО','company'];if(t.kind==='order')return ['№','order'];return [String(t.title||'?').trim().slice(0,1).toUpperCase(),''];} function threadById(id){return threads.find(t=>String(t.thread_id)===String(id))||null;} function directByUser(id){return threads.find(t=>t.kind==='direct'&&String(t.other_user_id)===String(id))||null;} function orderThreadByOrder(id){return threads.find(t=>t.kind==='order'&&String(t.order_id)===String(id))||null;} function memberById(id){return members.find(m=>String(m.user_id)===String(id))||null;} function switchSidebarMode(mode){ sidebarMode=['general','direct','orders'].includes(mode)?mode:'general'; qa('[data-chat-section]').forEach(b=>b.classList.toggle('on',b.dataset.chatSection===sidebarMode)); renderSidebar(); if(sidebarMode==='general'){ const t=threads.find(x=>x.kind==='company'); if(t&&(!activeThread||activeThread.kind!=='company'))openThread(t); }else if(activeThread&&((sidebarMode==='direct'&&activeThread.kind!=='direct')||(sidebarMode==='orders'&&activeThread.kind!=='order'))){ activeThread=null;messages=[];unsubscribeThread();$('sunChatShellV29')?.classList.remove('thread-open');$('sunChatComposerV29').hidden=true;renderHead();renderMessages(); } } function renderSidebar(){ const root=$('sunChatSidebarV29');if(!root)return; $('sunChatCompanyNameV29').textContent=workspace()?.name||'Компания'; qa('[data-chat-section]').forEach(b=>b.classList.toggle('on',b.dataset.chatSection===sidebarMode)); const general=threads.find(t=>t.kind==='company'); const others=members.filter(m=>String(m.user_id)!==String(me())); const localOrders=savedOrders().sort((a,b)=>Number(b?.id||0)-Number(a?.id||0)); const row=t=>{const [letter,cls]=threadIcon(t);const isOn=activeThread&&String(activeThread.thread_id)===String(t.thread_id);return ``}; const memberRow=m=>{const t=directByUser(m.user_id);if(t)return row(t);const isOnline=online.has(String(m.user_id));return ``}; const orderRow=o=>{const t=orderThreadByOrder(o.id);if(t)return row({...t,title:`№${o.id}${o.event?` · ${o.event}`:''}`});return ``}; if(sidebarMode==='general')root.innerHTML=`
    Общий чат компании
    ${general?row(general):'
    Загружаю общий чат…
    '}
    `; if(sidebarMode==='direct')root.innerHTML=`
    Сотрудники${others.length}
    ${others.length?others.map(memberRow).join(''):'
    Добавьте сотрудников — здесь появятся личные чаты.
    '}
    `; if(sidebarMode==='orders')root.innerHTML=`
    Чаты заказов${localOrders.length}
    ${localOrders.length?localOrders.map(orderRow).join(''):'
    Сохранённых заказов пока нет.
    '}
    `; qa('[data-chat-thread]',root).forEach(b=>b.onclick=()=>openThread(threadById(b.dataset.chatThread))); qa('[data-chat-user]',root).forEach(b=>b.onclick=()=>openDirect(b.dataset.chatUser)); qa('[data-chat-order]',root).forEach(b=>b.onclick=()=>openOrderGlobal(b.dataset.chatOrder)); updateUnreadBadges(); } function updateUnreadBadges(){ const general=threads.filter(t=>t.kind==='company').reduce((n,t)=>n+Number(t.unread_count||0),0); const direct=threads.filter(t=>t.kind==='direct').reduce((n,t)=>n+Number(t.unread_count||0),0); const order=threads.filter(t=>t.kind==='order').reduce((n,t)=>n+Number(t.unread_count||0),0); setSectionBadge('general',general);setSectionBadge('direct',direct);setSectionBadge('orders',order);setNavBadge(general+direct+order);updateOrderChatBadge(); } function activeSubtitle(t){ if(!t)return ''; if(t.kind==='direct'){const m=memberById(t.other_user_id);return online.has(String(t.other_user_id))?'онлайн':esc(ROLE_LABELS[m?.role]||'сотрудник');} if(t.kind==='company'){const count=members.length,on=members.filter(m=>online.has(String(m.user_id))).length;return `${count} сотрудников · ${on} онлайн`;} return 'Обсуждение заказа'; } function renderHead(){ const h=$('sunChatHeadV29');if(!h)return; if(!activeThread){h.innerHTML='';return;} const mobileBack=chatDock==='global'?``:''; const orderBack=chatDock==='order'?``:''; h.innerHTML=`${orderBack||mobileBack}
    ${esc(activeThread.title||'Чат')}
    ${activeSubtitle(activeThread)}
    `; h.querySelector('[data-chat-mobile-back]')?.addEventListener('click',()=>{activeThread=null;messages=[];unsubscribeThread();$('sunChatShellV29')?.classList.remove('thread-open');renderSidebar();renderHead();renderMessages();$('sunChatComposerV29').hidden=true;}); h.querySelector('[data-chat-order-back]')?.addEventListener('click',()=>$('orderTab')?.click()); } function renderMessages(){ const root=$('sunChatBodyV29');if(!root)return; if(!activeThread){root.innerHTML='
    Выберите чатОбщий чат компании, сотрудника или обсуждение заказа.
    ';return;} if(loadingMessages){root.innerHTML='
    Загружаю сообщения…
    ';return;} if(!messages.length){root.innerHTML='
    Пока нет сообщенийНапишите первое сообщение.
    ';return;} let prev=null,html=''; for(const m of messages){ if(!prev||!sameDay(prev,m.created_at))html+=`
    ${esc(fmtDay(m.created_at))}
    `; prev=m.created_at;const own=String(m.sender_user_id)===String(me());const atts=Array.isArray(m.attachments)?m.attachments:[]; const files=atts.length?``:''; const read=own?(Number(m.read_by_count||0)>0?(activeThread.kind==='direct'?'Прочитано':`Прочитали: ${Number(m.read_by_count)}`):'Доставлено'):''; html+=`
    ${!own&&activeThread.kind!=='direct'?`
    ${esc(m.sender_name||m.sender_email||'Сотрудник')}
    `:''}${m.body?`
    ${esc(m.body)}
    `:''}${files}
    ${esc(fmtTime(m.created_at))}${read?`${esc(read)}`:''}
    `; } root.innerHTML=html;hydrateAttachments();requestAnimationFrame(()=>{root.scrollTop=root.scrollHeight}); } async function hydrateAttachments(){ const c=client();if(!c)return; const links=qa('[data-chat-attachment]',$('sunChatBodyV29')); for(const a of links){const path=a.dataset.chatAttachment;if(!path)continue;try{const r=await c.storage.from('sun-chat').createSignedUrl(path,3600);if(r.error)throw r.error;const url=r.data?.signedUrl;if(!url)continue;a.href=url;const type=a.dataset.chatType||'';if(type.startsWith('image/')){const p=a.querySelector('.sun-chat-file-preview');if(p)p.innerHTML=`Вложение`;}}catch(e){a.onclick=ev=>{ev.preventDefault();toast('Не удалось открыть вложение.','error')}}} } function renderTyping(){const root=$('sunChatTypingV29');if(!root)return;const names=[...typingUsers.values()].filter(x=>x&&x.user_id!==me()&&Date.now()-x.at<3500).map(x=>x.name||'Сотрудник');root.textContent=names.length?`${names.slice(0,2).join(', ')} ${names.length>1?'печатают':'печатает'}…`:'';} function renderPending(){const root=$('sunChatPendingV29');if(!root)return;root.innerHTML=pendingFiles.map((f,i)=>`
    ${esc(f.name)} · ${esc(bytes(f.size))}
    `).join('');qa('[data-remove-file]',root).forEach(b=>b.onclick=()=>{pendingFiles.splice(Number(b.dataset.removeFile),1);renderPending()});} function autoGrowInput(){const x=$('sunChatInputV29');if(!x)return;x.style.height='auto';x.style.height=Math.min(130,x.scrollHeight)+'px';} async function loadThreads({quiet=false}={}){ if(!available()||loadingThreads)return; loadingThreads=true; try{ const ws=workspace();await rpc('sun_chat_get_company_thread_v29',{p_workspace:ws.id}); const [t,m]=await Promise.all([rpc('sun_chat_list_threads_v29',{p_workspace:ws.id}),rpc('sun_chat_list_members_v29',{p_workspace:ws.id})]); threads=Array.isArray(t)?t:[];members=Array.isArray(m)?m:[]; if(activeThread){const fresh=threadById(activeThread.thread_id);if(fresh)activeThread={...activeThread,...fresh};} renderSidebar();renderHead();updateUnreadBadges(); }catch(e){if(!quiet)toast(e.message||String(e),'error',6500)}finally{loadingThreads=false;} } async function loadMessages({mark=true,quiet=false}={}){ if(!activeThread||!available()||loadingMessages)return; loadingMessages=true;renderMessages(); try{ const data=await rpc('sun_chat_list_messages_v29',{p_thread:activeThread.thread_id,p_limit:120,p_before:null});messages=Array.isArray(data)?data:[]; if(mark){const t=threadById(activeThread.thread_id);if(Number(t?.unread_count||0)>0){await rpc('sun_chat_mark_read_v29',{p_thread:activeThread.thread_id});await loadThreads({quiet:true});}} }catch(e){if(!quiet)toast(e.message||String(e),'error',6500)}finally{loadingMessages=false;renderMessages();} } async function openThread(t){ if(!t)return;activeThread=t;messages=[];pendingFiles=[];$('sunChatShellV29')?.classList.add('thread-open');$('sunChatComposerV29').hidden=false;$('sunChatInputV29').value='';renderPending();renderSidebar();renderHead();renderMessages();await subscribeThread(t.thread_id);await loadMessages({mark:true});setTimeout(()=>$('sunChatInputV29')?.focus(),80); } async function openDirect(userId){ if(!available())return;try{sidebarMode='direct';const id=await rpc('sun_chat_get_direct_thread_v29',{p_workspace:workspace().id,p_other_user:userId});await loadThreads({quiet:true});const t=threadById(id)||{thread_id:id,kind:'direct',other_user_id:userId,title:memberById(userId)?.display_name||'Сотрудник',unread_count:0};await openThread(t);}catch(e){toast(e.message||String(e),'error',6500)} } async function getOrderThread(orderId){ const id=await rpc('sun_chat_get_order_thread_v29',{p_workspace:workspace().id,p_order_id:String(orderId)});await loadThreads({quiet:true});return threadById(id)||{thread_id:id,kind:'order',order_id:String(orderId),title:`Заказ №${orderId}`,unread_count:0}; } async function openOrderGlobal(orderId){ if(!available())return toast('Войдите в Caterium, чтобы открыть обсуждение заказа.','warn');if(!orderId)return toast('Сначала сохраните заказ.','warn'); try{dockMainGlobal();setActiveNav();sidebarMode='orders';await ensureWorkspaceChannel();const t=await getOrderThread(orderId);renderSidebar();await openThread(t);}catch(e){toast(e.message||String(e),'error',6500)} } function ensureOrderChatPanel(){ const sub=document.querySelector('#new .subnav');if(!sub)return null; let p=$('sunOrderChatPanelV31'); if(!p){p=document.createElement('div');p.id='sunOrderChatPanelV31';p.className='sun-order-chat-panel';sub.after(p);} return p; } function dockMainGlobal(){ const main=$('sunChatMainV29'),shell=$('sunChatShellV29');if(main&&shell&&main.parentElement!==shell)shell.appendChild(main);chatDock='global'; } function dockMainOrder(){ const main=$('sunChatMainV29'),panel=ensureOrderChatPanel();if(main&&panel&&main.parentElement!==panel)panel.appendChild(main);chatDock='order'; } function setOrderChatMode(on){ const sub=document.querySelector('#new .subnav'),card=sub?.closest('.card'),panel=ensureOrderChatPanel(),chatTab=$('sunOrderChatTabV31');if(!card||!panel)return; const money=card.querySelector(':scope > .money-form'),summary=card.querySelector(':scope > .order-summary'),actions=card.querySelector(':scope > .actions'); if(on){ $('orderForm')?.style.setProperty('display','none');$('details')?.style.setProperty('display','none'); if(money)money.style.display='none';if(summary)summary.style.display='none';if(actions)actions.style.display='none'; panel.classList.add('on');panel.hidden=false;$('orderTab')?.classList.remove('on');$('detailTab')?.classList.remove('on');chatTab?.classList.add('on');dockMainOrder(); }else{ panel.classList.remove('on');panel.hidden=true;if(money)money.style.display='';if(summary)summary.style.display='';if(actions)actions.style.display='';chatTab?.classList.remove('on');dockMainGlobal(); } } async function openOrderInline(orderId){ if(!available())return toast('Войдите в Caterium, чтобы открыть обсуждение заказа.','warn');if(!orderId)return toast('Сначала сохраните заказ — после этого появится чат.','warn'); try{ensureView();ensureOrderDiscussionTab();setOrderChatMode(true);await ensureWorkspaceChannel();sidebarMode='orders';const t=await getOrderThread(orderId);await openThread(t);}catch(e){setOrderChatMode(false);toast(e.message||String(e),'error',6500)} } async function openMain({threadId=null,mode='general'}={}){ if(!available())return toast('Чат доступен после входа в рабочую компанию.','warn');ensureView();dockMainGlobal();ensureNav();setActiveNav();sidebarMode=['general','direct','orders'].includes(mode)?mode:'general';await ensureWorkspaceChannel();await loadThreads();let t=threadId?threadById(threadId):null;if(!t&&sidebarMode==='general')t=threads.find(x=>x.kind==='company')||null;if(t)await openThread(t);else{activeThread=null;messages=[];unsubscribeThread();$('sunChatShellV29')?.classList.remove('thread-open');$('sunChatComposerV29').hidden=true;renderSidebar();renderHead();renderMessages();} } async function addPendingFiles(fileList){ const files=[...(fileList||[])];$('sunChatFilesV29').value=''; for(const f of files){if(pendingFiles.length>=5){toast('Можно отправить не более 5 файлов за раз.','warn');break}if(f.size>15*1024*1024){toast(`Файл «${f.name}» больше 15 МБ.`,'warn');continue}pendingFiles.push(f)}renderPending(); } async function uploadPending(){ const c=client(),ws=workspace();if(!c||!ws||!activeThread)return []; const result=[]; for(const f of pendingFiles){const path=`${ws.id}/${activeThread.thread_id}/${uid()}-${safeName(f.name)}`;const up=await c.storage.from('sun-chat').upload(path,f,{contentType:f.type||'application/octet-stream',upsert:false});if(up.error)throw up.error;result.push({path,name:f.name,type:f.type||'application/octet-stream',size:f.size});} return result; } async function sendMessage(){ if(!activeThread||!available())return;const input=$('sunChatInputV29'),btn=$('sunChatSendV29');const text=String(input?.value||'').trim();if(!text&&!pendingFiles.length)return; btn.disabled=true;btn.textContent='…'; try{const attachments=await uploadPending();await rpc('sun_chat_send_message_v29',{p_thread:activeThread.thread_id,p_body:text,p_attachments:attachments});input.value='';input.style.height='auto';pendingFiles=[];renderPending();sendTyping(false);await Promise.all([loadThreads({quiet:true}),loadMessages({mark:false,quiet:true})]);} catch(e){toast(e.message||String(e),'error',7000)}finally{btn.disabled=false;btn.textContent='Отправить';input?.focus();} } async function ensureWorkspaceChannel(){ if(!available())return unsubscribeWorkspace();const c=client(),ws=workspace(),ss=session();if(!c||!ws||!ss)return; if(workspaceChannel&&workspaceChannelId===ws.id)return; unsubscribeWorkspace();workspaceChannelId=ws.id; try{if(c.realtime?.setAuth)await c.realtime.setAuth(ss.access_token);}catch(_){} const ch=c.channel(`sun-chat-workspace:${ws.id}`,{config:{private:true,presence:{key:me()},broadcast:{ack:true}}});workspaceChannel=ch; ch.on('broadcast',{event:'chat_changed'},async()=>{await loadThreads({quiet:true});if(activeThread){const fresh=threadById(activeThread.thread_id);if(fresh&&Number(fresh.unread_count||0)>0)await loadMessages({mark:true,quiet:true});else renderMessages();}}) .on('presence',{event:'sync'},()=>{online=new Set(Object.keys(ch.presenceState()||{}));renderSidebar();renderHead();}) .on('presence',{event:'join'},()=>{online=new Set(Object.keys(ch.presenceState()||{}));renderSidebar();renderHead();}) .on('presence',{event:'leave'},()=>{online=new Set(Object.keys(ch.presenceState()||{}));renderSidebar();renderHead();}) .subscribe(async(status)=>{if(status==='SUBSCRIBED'){try{await ch.track({user_id:me(),name:meName(),at:new Date().toISOString()})}catch(_){}await loadThreads({quiet:true});}}); } function unsubscribeWorkspace(){if(workspaceChannel){try{client()?.removeChannel?.(workspaceChannel)}catch(_){}workspaceChannel=null;}workspaceChannelId='';online=new Set();unsubscribeThread();} async function subscribeThread(id){ if(!available()||!id)return;const c=client(),ss=session();if(threadChannel&&threadChannelId===id)return;unsubscribeThread();threadChannelId=id; try{if(c.realtime?.setAuth)await c.realtime.setAuth(ss.access_token);}catch(_){} const ch=c.channel(`sun-chat-thread:${id}`,{config:{private:true,broadcast:{ack:false,self:false}}});threadChannel=ch; ch.on('broadcast',{event:'message'},()=>loadMessages({mark:true,quiet:true})) .on('broadcast',{event:'read'},()=>loadMessages({mark:false,quiet:true})) .on('broadcast',{event:'typing'},payload=>{const p=payload?.payload||{};if(!p.user_id||String(p.user_id)===String(me()))return;if(p.typing)typingUsers.set(String(p.user_id),{user_id:p.user_id,name:p.name||'Сотрудник',at:Date.now()});else typingUsers.delete(String(p.user_id));renderTyping();setTimeout(()=>renderTyping(),3600)}) .subscribe(); } function unsubscribeThread(){if(threadChannel){try{client()?.removeChannel?.(threadChannel)}catch(_){}threadChannel=null;}threadChannelId='';typingUsers.clear();renderTyping();} async function sendTyping(flag){ if(!threadChannel||!activeThread)return;const now=Date.now();if(flag&&now-lastTypingSent<700){clearTimeout(typingStopTimer);typingStopTimer=setTimeout(()=>sendTyping(false),1800);return}lastTypingSent=now; try{await threadChannel.send({type:'broadcast',event:'typing',payload:{user_id:me(),name:meName(),typing:Boolean(flag)}})}catch(_){} clearTimeout(typingStopTimer);if(flag)typingStopTimer=setTimeout(()=>sendTyping(false),1800); } function updateOrderChatBadge(){ const b=$('sunOrderChatTabV31');if(!b)return;const id=currentOrderId(),t=id?orderThreadByOrder(id):null,n=Number(t?.unread_count||0);let badge=b.querySelector('.sun-chat-mini-badge');if(n>0){if(!badge){badge=document.createElement('span');badge.className='sun-chat-mini-badge';b.appendChild(badge);}badge.textContent=n>99?'99+':String(n);}else badge?.remove(); } function ensureOrderDiscussionTab(){ const sub=document.querySelector('#new .subnav');if(!sub)return; ensureOrderChatPanel(); let b=$('sunOrderChatTabV31'); $('sunOrderChatTabV29')?.remove(); if(!b){ b=document.createElement('button');b.id='sunOrderChatTabV31';b.type='button';b.className='sun-chat-order-tab';b.textContent='Чат';sub.appendChild(b); b.addEventListener('click',e=>{e.preventDefault();const id=currentOrderId();if(!id)return toast('Сначала сохраните заказ — после этого появится чат.','warn');openOrderInline(id);}); } b.title=currentOrderId()?'Чат этого заказа':'Сначала сохраните заказ';b.style.opacity=currentOrderId()?'1':'.55'; ['orderTab','detailTab'].forEach(id=>{const x=$(id);if(x&&!x.dataset.sunChatCloseBound){x.dataset.sunChatCloseBound='1';x.addEventListener('click',()=>setOrderChatMode(false));}}); updateOrderChatBadge(); } function openOrderFromCard(id){ if(!id)return; try{if(typeof window.openOrder==='function')window.openOrder(Number(id));}catch(e){return toast(e.message||String(e),'error')} setTimeout(()=>openOrderInline(String(id)),60); } function decorateOrderCards(){ qa('#sunOrderCards .sun-order-card').forEach(card=>{const actions=card.querySelector('.sun-order-card-actions');if(!actions||actions.querySelector('[data-card-chat]'))return;const b=document.createElement('button');b.className='mini';b.type='button';b.dataset.cardChat=card.dataset.orderId;b.textContent='Чат';b.onclick=e=>{e.stopPropagation();openOrderFromCard(b.dataset.cardChat)};actions.prepend(b);}); } function safeStateChanged(){ try{ installStyle();ensureView();ensureOrderDiscussionTab();decorateOrderCards(); if(available()){ensureNav();ensureWorkspaceChannel();loadThreads({quiet:true});} else{setNavBadge(0);$('sunChatNavV29')?.remove();unsubscribeWorkspace();activeThread=null;threads=[];members=[];messages=[];setOrderChatMode(false);} }catch(e){console.error('[Caterium Chat] state refresh failed',e);} } function boot(){ installStyle();ensureView();ensureOrderDiscussionTab(); const priorOpen=window.openOrder; if(typeof priorOpen==='function'&&!priorOpen.__sunChatV31){const wrapped=function(...args){setOrderChatMode(false);const r=priorOpen.apply(this,args);setTimeout(()=>{ensureOrderDiscussionTab();decorateOrderCards();},0);return r};wrapped.__sunChatV31=true;window.openOrder=wrapped;} const priorRenderOrders=window.renderOrders; if(typeof priorRenderOrders==='function'&&!priorRenderOrders.__sunChatV31){const wrapped=function(...args){const r=priorRenderOrders.apply(this,args);setTimeout(decorateOrderCards,0);return r};wrapped.__sunChatV31=true;window.renderOrders=wrapped;} document.addEventListener('click',e=>{const nav=e.target.closest('header nav button');if(!nav)return;if(nav.id!=='sunChatNavV29')setOrderChatMode(false);setTimeout(()=>{ensureOrderDiscussionTab();decorateOrderCards();},20);},true); window.addEventListener('sun:cloud-permissions-changed',()=>setTimeout(safeStateChanged,60)); window.addEventListener('sun:cloud-state-applied',()=>setTimeout(safeStateChanged,60)); window.addEventListener('online',()=>{if(available())ensureWorkspaceChannel()}); window.addEventListener('beforeunload',()=>{try{workspaceChannel?.untrack?.()}catch(_){}}); let tries=0;bootRetryTimer=setInterval(()=>{tries++;safeStateChanged();if(available()||tries>=24){clearInterval(bootRetryTimer);bootRetryTimer=null;}},750);setTimeout(safeStateChanged,250); } window.SunChatV29={VERSION,open:openMain,openOrder:openOrderInline,openDirect,refresh:()=>loadThreads(),getThreads:()=>threads.slice(),getMembers:()=>members.slice()}; window.SunChatV31=window.SunChatV29; if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot(); })();