Выберите раздел. Любую позицию можно добавить, изменить или удалить.
';
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 item=boxes.find(x=>String(x.id)===String(line?.id));return window.CateriumPricing.linePrice(line,item);}
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=>(window.CateriumTrainingCatalog?.visible(i)??(i.hidden!==true))&&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='
`;
}
window.sunOrderLinePriceChanged=(index,input)=>{const line=draft.lines?.[index];if(!line)return;const value=Number(String(input.value||0).replace(',','.'));if(!Number.isFinite(value)){input.value=String(lineUnitPrice(line));return;}line.price=Math.max(0,Math.round(value*100)/100);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();};
window.sunRemoveOrderLine=(index)=>{if(!Array.isArray(draft.lines))return false;const i=Number(index);if(!Number.isInteger(i)||i<0||i>=draft.lines.length)return false;draft.lines.splice(i,1);renderCatalogV5();updateOrderSummary();return true;};
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=>(window.CateriumTrainingCatalog?.visible(item)??(item.hidden!==true))&&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({catalogOnly=false}={}){
$('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=>(window.CateriumTrainingCatalog?.visible(item)??(item.hidden!==true))&&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});if(!catalogOnly){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}`;
if(!catalogOnly){renderOrderLines();updateOrderSummary();}
}
window.addEventListener('caterium:catalog-prices-changed',()=>{
if($('new')?.classList.contains('on'))renderCatalogV5({catalogOnly:true});
if($('manager')?.classList.contains('on'))renderManagerV5();
});
window.CateriumPricing?.watch(()=>boxes);
window.render=renderCatalogV5;
window.add=id=>{const item=boxes.find(x=>String(x.id)===String(id));if(!item||!(window.CateriumTrainingCatalog?.visible(item)??item.hidden!==true))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,window.CateriumPricing.price(item))})}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=>(window.CateriumTrainingCatalog?.visible(i)??(i.hidden!==true))&&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||'';window.CateriumPricing.loadEditor(edited);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();if(!window.CateriumPricing.saveEditor(edited))return false;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=>(window.CateriumTrainingCatalog?.visible(i)??(i.hidden!==true))&&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){alert('Введите название.');return false;}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.CateriumPricing.refresh();return true;};
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 не загружен';}
// ---------------- 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 с прозрачным фоном, до 15 МБ. Пустые прозрачные поля убираются, пропорции и цвета сохраняются. JPEG и WebP тоже поддерживаются.
';
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 customerVisibleOrderNote(value){return String(value||'').split(/\r?\n/).filter(line=>{const text=line.trim();if(!text)return false;return !/^(?:заказ\s+из\s+telegram|импорт(?:ирован)?(?:о|ный)?\s+из\s+telegram|источник\s*[:—-]?\s*telegram|source\s*[:—-]?\s*telegram)\b/i.test(text)}).join('\n').trim()}
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(),visibleNote=customerVisibleOrderNote($('note')?.value);
dialog.innerHTML=`
Отслеживание заказов, клиентов, продаж и предоставленной скидки по промокодам.
${list.length?`
Промокод
Заказов
Клиентов
Продажи
Скидка
${list.map(g=>`
${esc(g.code)}
${g.orders}
${g.clients.size}
${money(g.sales)}
${money(g.discount)}
`).join('')}
`:'
Заказов с промокодами пока нет.
'}`;
}
// ---------------- 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.
Тексты расчёта стоимости
Блок «Всё под контролем»
Дополнительно к заказу
Что показывать клиенту
Фото на столе: авто, только в свободное место
Изменения применятся к следующему открытию предложения; открытое предложение обновится автоматически.
${esc(cfg.texts.finalGalleryTitle||'Как это выглядит на вашем столе')}
${finalGalleryItems.map((src,i)=>``).join('')}
`:'';
return `Предложение клиенту${body}${footerNote}${finalPhoto}`;
}
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=>`${Number(v||0).toLocaleString('ru-RU',{maximumFractionDigits:2})} \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&&(window.CateriumTrainingCatalog?.visible(x)??(x?.hidden!==true))&&window.CateriumPricing.price(x)>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:window.CateriumPricing.price(x),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=`
Принудительно загрузить актуальные заказы, клиентов и остальные данные с сервера, заменив локальную копию на этом устройстве. Перед загрузкой автоматически создаётся резервная копия.
Управление сотрудниками и правами внутри этой компании.
`:''}
${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(){clearInterval(remotePollTimer);remotePollTimer=null;}
async function pollRemoteChanges(){
if(remotePollBusy||document.hidden||!navigator.onLine||isSyncing||supportMode||!workspaceMigrated()||!session?.user||!workspace?.id)return;
const startedUser=session.user.id,startedWorkspace=workspace.id,startedClient=client;
const current=()=>!signOutInProgress&&client===startedClient&&session?.user?.id===startedUser&&workspace?.id===startedWorkspace;
remotePollBusy=true;
try{
// Poll only the revision; download/merge the full base only after a change.
// The same RLS and authorization apply as to the previous socket channel.
// Direct table reads are intentionally revoked in production. PostgREST
// projects just revision from the existing, tenant-checked RPC result.
const {data,error}=await client.rpc('sun_fetch_app_state',{p_workspace:startedWorkspace}).select('revision').maybeSingle();
if(error)throw error;
const baseline=await getBaseline();
if(current()&&data&&Number(data.revision)!==Number(baseline?.revision))await syncNow({quiet:true});
}catch(_){/* A later poll retries; normal saves retain their visible error/retry UI. */}
finally{remotePollBusy=false;}
}
function subscribeRealtime(){
unsubscribeRealtime();if(!client||!session||!workspace?.id)return;
remotePollTimer=setInterval(pollRemoteChanges,20000);
}
document.addEventListener('visibilitychange',()=>{if(remotePollTimer&&!document.hidden)pollRemoteChanges();});
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='
Активных администраторов: ${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);}
}
function removeLegacyQuickPin(){
$('sunCloudQuickPinCardV3')?.remove();
$('sunCloudPinLockV3')?.remove();
try{
Object.keys(localStorage).filter(k=>k.startsWith('sunCloudQuickPinV3:')).forEach(k=>localStorage.removeItem(k));
Object.keys(sessionStorage).filter(k=>k.startsWith('sunCloudQuickPinUnlockedV3:')).forEach(k=>sessionStorage.removeItem(k));
}catch(_){}
}
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
${esc(e?.message||e||'Не удалось проверить приглашение.')}
Если аккаунт с этим 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;}}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='
${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=`
${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='light';
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 candidate=window.CateriumProposalPDF?.resolveTemplate(raw.id)||raw.id;
const id=TEMPLATES.some(x=>x.id===candidate)?candidate: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(3,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=>`
${offerTemplateMini(t.id)}
${esc(t.name)}✓
${esc(t.desc)}
`).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=3||foreign)await idbDelete('errors',item.id);else await idbPut('errors',{...item,attempts});
}}}
finally{flushBusy=false}
}
async function mirror(payload){
const c=client(),ws=workspace();if(mirrorBusy||!c||!ws?.id||!payload||!networkReady())return null;mirrorBusy=true;
try{const {data,error}=await stabilityRpc(c.rpc('sun_v17_mirror_legacy',{p_workspace:ws.id,p_payload:payload,p_client_id:clientId()}),'Зеркалирование v17');if(error)throw error;noteNetworkSuccess();lastMirrorAt=new Date().toISOString();await idbPut('meta',{key:'mirror:'+ws.id,at:Date.now(),result:data||{}});return data;}
catch(e){if(networkish(e))noteNetworkFailure();await queueError('Ошибка зеркалирования v17: '+(e.message||e),e.stack||'','warn');return null}
finally{mirrorBusy=false}
}
async function ensureDailyBackup(){
const c=client(),ws=workspace();if(backupBusy||!c||!ws?.id||!networkReady())return;backupBusy=true;
try{const today=new Date().toISOString().slice(0,10);let state={};try{state=JSON.parse(localStorage.getItem(DAILY_KEY)||'{}')||{}}catch(_){}if(state[ws.id]===today)return;const {error}=await stabilityRpc(c.rpc('sun_v17_create_backup',{p_workspace:ws.id,p_kind:'daily',p_label:'Автоматическая копия '+today}),'Ежедневная резервная копия');if(error)throw error;await stabilityRpc(c.rpc('sun_v17_prune_backups',{p_workspace:ws.id}),'Очистка старых резервных копий');noteNetworkSuccess();state[ws.id]=today;localStorage.setItem(DAILY_KEY,JSON.stringify(state));}
catch(e){if(networkish(e))noteNetworkFailure();await queueError('Не удалось создать ежедневную резервную копию: '+(e.message||e),e.stack||'','warn')}
finally{backupBusy=false}
}
async function onCloudSync(ev){
noteNetworkSuccess();
const payload=ev?.detail?.payload;
if(payload)await mirror(payload);
await ensureDailyBackup();await flushErrors();refreshCardSoon();
}
function installErrorHooks(){
window.addEventListener('error',e=>{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=null;
let subscriptionScope='';
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);}
const currentSubscriptionScope=()=>JSON.stringify([session()?.user?.id||'',workspace()?.id||'']);
function resetSubscription(){
loading=null;snapshot=null;lastWorkspaceId='';subscriptionScope='';removeSubscriptionUi();applyReadOnlyControls();
}
async function refreshSnapshot(force=false){
const c=client(),ws=workspace();
if(!c||!ws?.id){resetSubscription();return null;}
const scope=currentSubscriptionScope();
if(subscriptionScope!==scope){resetSubscription();subscriptionScope=scope;}
if(loading)return loading.promise;
if(!force&&lastWorkspaceId===ws.id&&snapshot)return snapshot;
const request={promise:null};loading=request;
const isCurrent=()=>loading===request&¤tSubscriptionScope()===scope;
request.promise=(async()=>{
try{
const {data,error}=await c.rpc('sun_subscription_snapshot',{p_workspace:ws.id});
if(!isCurrent())return null;
if(error)throw error;
const next=Array.isArray(data)?(data[0]||null):data;
if(!next||!['full','read_only','blocked'].includes(next.access_mode))throw new Error('Некорректный ответ о подписке');
snapshot=next;lastWorkspaceId=ws.id;
applyAll();
window.dispatchEvent(new CustomEvent('sun:subscription-changed',{detail:snapshot}));
return snapshot;
}catch(err){if(isCurrent())console.error('[SaaS] subscription snapshot',err);return null;}
finally{if(loading===request)loading=null;}
})();
return request.promise;
}
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:12100;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();$('sunSaaSPlansModalV16')?.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';
const html=`${esc(snapshot.plan_name||PLAN_LABELS[snapshot.plan_id]||'Тариф')}${trial?`Пробный период${d!=null?` · ${Math.max(0,d)} дн.`:''}`:accessLabel(snapshot.access_mode)}`;
if(badge.innerHTML!==html)badge.innerHTML=html;
badge.onclick=()=>showPlans();badge.title='Тариф и подписка';badge.style.cursor='pointer';
}
function applyAccessMode(){
if(snapshot?.access_mode!=='read_only')$('sunSaaSReadonlyV16')?.remove();
if(snapshot?.access_mode!=='blocked')$('sunSaaSBlockedV16')?.remove();
if(!snapshot)return;
applyReadOnlyControls();
if(snapshot.access_mode==='read_only'){
let b=$('sunSaaSReadonlyV16');
if(!b){b=document.createElement('div');b.id='sunSaaSReadonlyV16';b.className='sun-saas-readonly-banner';document.body.appendChild(b);}
const text=`Подписка закончилась. До ${formatDate(snapshot.grace_until)} база доступна только для просмотра.`;
if(b.textContent!==text)b.textContent=text;
}else if(snapshot.access_mode==='blocked'){
if($('sunSaaSBlockedV16'))return;
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';
// A feature guard may be triggered inside another dialog, including a PDF.
m.style.zIndex=String(Math.max(12100,...qa('.modal.on,.sun-v1762-modal,.sun-dev-modal').map(el=>Number(getComputedStyle(el).zIndex)||0))+1);
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();
const html=`
Тариф и подписка
Подписка относится ко всей компании. Данные этой рабочей базы изолированы от других компаний.
Управление платформой, компаниями и техническим состоянием приложения.
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='
`}}
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)}
let developerMfaPending=null,developerMfaEnrollment=null;
function ensureDeveloperMfa(){
const c=client(),uid=session()?.user?.id;
if(!c?.auth?.mfa||!uid)return Promise.reject(new Error('Войдите в аккаунт разработчика.'));
if(developerMfaPending?.client===c&&developerMfaPending.uid===uid)return developerMfaPending.promise;
const task={client:c,uid,promise:null};developerMfaPending=task;
const run=()=>runDeveloperMfa(c,uid);
task.promise=(navigator.locks?.request?navigator.locks.request(`caterium-developer-mfa:${uid}`,{ifAvailable:true},lock=>{
if(!lock){toast('Подтверждение разработчика уже открыто в другой вкладке. Завершите его там или закройте то окно.','info');return false;}
return run();
}):run()).finally(()=>{if(developerMfaPending===task)developerMfaPending=null;});
return task.promise;
}
async function runDeveloperMfa(c,uid){
const current=()=>client()===c&&session()?.user?.id===uid;
const check=()=>{if(!current())throw new Error('Аккаунт изменился. Откройте кабинет разработчика заново.');};
const readAal=async()=>{check();const a=await c.auth.mfa.getAuthenticatorAssuranceLevel();check();if(a.error)throw a.error;return a.data||{};};
const list=async()=>{
check();const r=await c.auth.mfa.listFactors();check();if(r.error)throw r.error;
// Supabase .totp contains VERIFIED factors only. Abandoned enrollments
// are in .all, and must not be confused with another authenticator.
return [...new Map([...(r.data?.all||[]),...(r.data?.totp||[])].map(f=>[f.id,f])).values()];
};
const totp=f=>f.factor_type==='totp'||f.type==='totp';
const verified=rows=>rows.find(f=>totp(f)&&f.status==='verified');
const abandoned=f=>totp(f)&&f.status==='unverified'&&f.friendly_name==='Caterium Developer';
const waitAal2=async()=>{for(let i=0;i<12;i++){if((await readAal()).currentLevel==='aal2')return true;await new Promise(r=>setTimeout(r,180));}return false;};
if((await readAal()).currentLevel==='aal2')return true;
let rows=await list(),factor=verified(rows),enrollment=null;
const remembered=developerMfaEnrollment;
if(factor)developerMfaEnrollment=null;
else if(remembered?.client===c&&remembered.uid===uid&&rows.some(f=>f.id===remembered.data.id&&abandoned(f))){
enrollment=remembered.data;factor=enrollment;
}else{
developerMfaEnrollment=null;
for(const old of rows.filter(abandoned)){
// Refresh immediately before deleting: never remove a factor that was
// confirmed in another session, nor a phone/passkey/other app factor.
rows=await list();factor=verified(rows);if(factor)break;
if(!rows.some(f=>f.id===old.id&&abandoned(f)))continue;
const reset=await c.auth.mfa.unenroll({factorId:old.id});check();
if(reset.error&&reset.error.code!=='mfa_factor_not_found'&&!/factor.*not found/i.test(reset.error.message||'')){
throw new Error('Не удалось восстановить незавершённую настройку 2FA. Проверьте соединение и откройте раздел ещё раз.');
}
}
if(!factor){
rows=await list();factor=verified(rows);
if(!factor){
const enr=await c.auth.mfa.enroll({factorType:'totp',friendlyName:'Caterium Developer'});check();
if(enr.error){
// A second device may have completed enrollment while this one
// was waiting. Reuse only its verified factor; never bypass MFA.
rows=await list();factor=verified(rows);
if(!factor)throw new Error(/already exists|friendly name/i.test(enr.error.message||'')?'Настройка 2FA начата в другом окне. Закройте его и повторно откройте кабинет разработчика.':enr.error.message||'Не удалось создать QR-код. Повторите попытку.');
}else{
if(!enr.data?.id||!enr.data?.totp?.qr_code)throw new Error('Сервер не вернул QR-код. Повторно откройте кабинет разработчика.');
enrollment=enr.data;factor=enrollment;
developerMfaEnrollment={client:c,uid,data:enrollment};
}
}
}
}
check();
const firstSetup=Boolean(enrollment),rawQr=enrollment?.totp?.qr_code||'';
const qr=rawQr.trim().startsWith('
Закрыли окно случайно? Откройте его снова — настройку можно продолжить. После перезагрузки используйте новый QR-код, показанный здесь.
${qr?``:''}${secret?`
Или добавьте ключ вручную:
${esc(secret)}
`:''}`:'
Введите текущий 6-значный код из приложения-аутентификатора. Подтверждённая защита уже подключена.
';
const ui=modal(firstSetup?'Подключение 2FA разработчика':'Подтверждение разработчика',`${intro}`);
return new Promise(resolve=>{
let settled=false,busy=false,focusTimer=0;
const inp=ui.m.querySelector('#sunDevMfaCode'),btn=ui.m.querySelector('#sunDevMfaVerify'),box=ui.m.querySelector('#sunDevMfaError');
const finish=value=>{if(settled)return;settled=true;clearTimeout(focusTimer);observer.disconnect();window.removeEventListener('sun:cloud-permissions-changed',accountChanged);ui.close();resolve(value);};
const accountChanged=()=>{if(!current()){developerMfaEnrollment=null;finish(false);}};
const observer=new MutationObserver(()=>{if(!ui.m.isConnected)finish(false);});observer.observe(document.body,{childList:true});
window.addEventListener('sun:cloud-permissions-changed',accountChanged);
const showError=e=>{box.textContent=e?.message||String(e||'Не удалось подтвердить код.');};
const verify=async()=>{
if(busy||settled)return;
if(!current()){accountChanged();return;}
const code=String(inp.value||'').replace(/\D/g,'');
if(code.length!==6){showError(new Error('Введите ровно 6 цифр из приложения-аутентификатора.'));return;}
busy=true;btn.disabled=true;btn.textContent='Проверяю…';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});check();if(ch.error)throw ch.error;vr=await c.auth.mfa.verify({factorId:factor.id,challengeId:ch.data.id,code});}
check();if(settled)return;if(vr?.error)throw vr.error;
developerMfaEnrollment=null;
if(!await waitAal2()){
check();const refreshed=await c.auth.refreshSession();check();if(refreshed?.error)throw refreshed.error;
if(!await waitAal2())throw new Error('Код принят, но сессия ещё не подтверждена. Войдите в аккаунт заново и введите текущий код.');
}
if(!settled){toast('2FA подтверждена. Открываю кабинет разработчика…','success',2800);finish(true);}
}catch(e){if(!current()){accountChanged();return;}if(!settled){busy=false;btn.disabled=false;btn.textContent='Подтвердить и открыть';showError(e);}}
};
btn.addEventListener('click',verify);inp.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();verify();}});
// Capture before the generic modal's close handler. Closing during a
// verification request must not remove the form or leave a pending lock.
ui.m.addEventListener('click',e=>{if(e.target===ui.m||e.target.closest('[data-close]')){e.preventDefault();e.stopImmediatePropagation();if(!busy)finish(false);}},true);
ui.m.addEventListener('keydown',e=>{if(e.key==='Escape'){e.preventDefault();e.stopImmediatePropagation();if(!busy)finish(false);}},true);
focusTimer=setTimeout(()=>{if(!settled)inp.focus();},60);
});
}
async function renderOverview(body){const d=await rpc('sun_dev_dashboard');body.innerHTML=`
';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('Создать компанию',`
Компания начнёт с пустого каталога, без заказов и клиентов.
Владелец ещё не зарегистрирован. Код приглашения сохранён на сервере.
`:''}`;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=`
`;$('#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=`
`;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=>`
${fmtDate(a.created_at)}
${esc(a.action)}
${esc(a.actor_email||'')}
${esc(a.workspace_name||a.target_email||'—')}
${esc(JSON.stringify(a.details||{}))}
`).join('')||'
Записей пока нет.
'}
Ошибки приложения
Дата
Компания
Версия
Ошибка
${(errors||[]).map(e=>`
${fmtDate(e.created_at)}
${esc(e.workspace_name||'—')}
${esc(e.app_version||'—')}
${esc(e.level||'error')}
${esc(e.message||'')}
`).join('')||'
Ошибок нет.
'}
`}
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=`
Аккаунт разработчика
${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 workspaceChannelId = '';
let threadChannelId = '';
let online = new Set();
let pendingFiles = [];
let loadingThreads = false;
let loadingMessages = false;
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(''):'
Добавьте сотрудников — здесь появятся личные чаты.