Optional per-profile training catalog with reversible example visibility (#35)

Add Settings > Учебный каталог, off by default and available on ordinary writable profiles. Load ready example boxes, photos, TTKs and linked sample inventory additively; preserve own data, saved orders and edited examples when hiding or re-enabling. Respect company/profile scope, read-only permissions, failed downloads and tenant changes. Preserve the opened recipe guide across real catalog refreshes on iPhone. Integrated feature checks and full pull-request QA passed. Standard main QA and exact-asset production UI verification remain in place.
This commit is contained in:
pavlov346346-source 2026-09-20 12:18:40 +03:00 committed by GitHub
parent ab32199bd6
commit 716a79c58d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 429 additions and 59 deletions

View File

@ -54,6 +54,7 @@ jobs:
core/banquet-menu.js core/banquet-menu.js
core/data-layer-v1773.js core/data-layer-v1773.js
core/trial-demo.js core/trial-demo.js
core/training-catalog.js
legacy/bootstrap.js legacy/bootstrap.js
caterium-mark-light.svg caterium-mark-light.svg
service-worker.js service-worker.js
@ -116,6 +117,9 @@ jobs:
- name: Check the published mobile menu form and return arrow - name: Check the published mobile menu form and return arrow
timeout-minutes: 4 timeout-minutes: 4
run: node tests/production-mobile-menu.mjs run: node tests/production-mobile-menu.mjs
- name: Check the published optional training catalog
timeout-minutes: 4
run: node tests/production-training-catalog.mjs
- name: Save production UI verification - name: Save production UI verification
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

View File

@ -0,0 +1,36 @@
# Optional learning catalog
Settings → Обучение и знакомство → Учебный каталог.
Off by default, including previously automatically seeded demo records. Opting in
is not limited to a trial subscription or an empty company. Normal catalog edit
permissions, support read-only and subscription write restrictions still apply.
Visibility is saved per authenticated user inside the current workspace's
sunTrialDemoV1.trainingCatalog metadata. Other profiles and workspaces do not
inherit the selection. A cloud-synced profile uses the same preference on its
other devices. This is an optional catalog, not a separate isolated account.
Enabling loads the existing versioned example bundle: ten photographed boxes,
premium sets, eighteen banquet dishes, drinks, supplies, extras and delivery.
Composition, recipe/TTK, prices, sample suppliers and zero-balance inventory are
included. New sample inventory has distinct names to prevent matching a real
product with the same name during stock lookup. Historical seeded inventory is
not renamed and existing recipes are never overwritten.
Disabling changes only selection visibility; it does not delete records, undo
manual stock operations, remove orders, change saved prices or reset edits.
Catalog/menu/search/stock/supplier browse screens hide the sample records. Lookup
for existing orders, preparation and documents continues using the full data.
Re-enabling is additive and idempotent; user-created products and existing sample
edits, inventory balances and supplier details are preserved.
There is no automatic creation of orders or inventory movements. The existing
explicit trial-order button keeps autoCompletionDisabled. Stock actions still
require an explicit user command and existing permissions. No production database
migration, account mutation or other-project change is required for this release.
Regression coverage includes first opt-in on a populated paid profile, switching
off and back on, reload, old seeded records, names/recipes/stock preservation,
TTKs and trial order, per-profile/workspace scope, read-only/support rejection,
failed downloads and late responses after a tenant change.

View File

@ -152,7 +152,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
const buildPdf=pages=>window.SunPdfEngine.fromJpegs(pages); const buildPdf=pages=>window.SunPdfEngine.fromJpegs(pages);
async function createCatalogPdfBlob(cat,progress){ async function createCatalogPdfBlob(cat,progress){
const items=allBoxes().filter(item=>item.hidden!==true&&Number(item.category||0)===Number(cat)); const items=allBoxes().filter(item=>(window.CateriumTrainingCatalog?.visible(item)??(item.hidden!==true))&&Number(item.category||0)===Number(cat));
if(!items.length)throw new Error('В этом разделе нет позиций.'); if(!items.length)throw new Error('В этом разделе нет позиций.');
const pages=[],dateText=new Date().toLocaleDateString('ru-RU'),brand=window.CateriumBranding.identity(); const pages=[],dateText=new Date().toLocaleDateString('ru-RU'),brand=window.CateriumBranding.identity();
for(let i=0;i<items.length;i++){ for(let i=0;i<items.length;i++){
@ -203,7 +203,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
window.sunOpenCatalogPdfViewer=openCatalogPdfViewer; window.sunOpenCatalogPdfViewer=openCatalogPdfViewer;
window.sunBuildCatalogPdfBlob=()=>createCatalogPdfBlob(currentCategory()); window.sunBuildCatalogPdfBlob=()=>createCatalogPdfBlob(currentCategory());
window.sunCatalogDebugPdfPages=async(cat=currentCategory(),limit=3)=>{const items=allBoxes().filter(item=>item.hidden!==true&&Number(item.category||0)===Number(cat)).slice(0,Math.max(1,Number(limit||3)));const dateText=new Date().toLocaleDateString('ru-RU'),pages=[];for(let i=0;i<items.length;i++)pages.push(await renderPageJpeg(items[i],cat,i,items.length,dateText));return pages}; window.sunCatalogDebugPdfPages=async(cat=currentCategory(),limit=3)=>{const items=allBoxes().filter(item=>(window.CateriumTrainingCatalog?.visible(item)??(item.hidden!==true))&&Number(item.category||0)===Number(cat)).slice(0,Math.max(1,Number(limit||3)));const dateText=new Date().toLocaleDateString('ru-RU'),pages=[];for(let i=0;i<items.length;i++)pages.push(await renderPageJpeg(items[i],cat,i,items.length,dateText));return pages};
installButton();setTimeout(installButton,150);setTimeout(installButton,800); installButton();setTimeout(installButton,150);setTimeout(installButton,800);
document.addEventListener('click',e=>{if(e.target.closest('#new .cats button,#new .sun-catalog-top-toolbar .cats button'))setTimeout(installButton,30)},true); document.addEventListener('click',e=>{if(e.target.closest('#new .cats button,#new .sun-catalog-top-toolbar .cats button'))setTimeout(installButton,30)},true);
const catalog=document.querySelector('#new .catalog');if(catalog&&'MutationObserver'in window)new MutationObserver(()=>{const b=$('sunLiveCatalogPdfButton');if(!b||b.textContent!=='PDF'||b.onclick!==openCatalogPdfViewer)queueMicrotask(installButton)}).observe(catalog,{childList:true,subtree:true}); const catalog=document.querySelector('#new .catalog');if(catalog&&'MutationObserver'in window)new MutationObserver(()=>{const b=$('sunLiveCatalogPdfButton');if(!b||b.textContent!=='PDF'||b.onclick!==openCatalogPdfViewer)queueMicrotask(installButton)}).observe(catalog,{childList:true,subtree:true});
@ -356,7 +356,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
function renderCategoryManager(){ function renderCategoryManager(){
ensureCategoryModal();const root=$('sunCategoryRows');if(!root)return; ensureCategoryModal();const root=$('sunCategoryRows');if(!root)return;
root.innerHTML=catalogCats.slice().sort((a,b)=>a.order-b.order).map(c=>{ root.innerHTML=catalogCats.slice().sort((a,b)=>a.order-b.order).map(c=>{
const count=(typeof boxes!=='undefined'?boxes:[]).filter(b=>b.hidden!==true&&Number(b.category||0)===Number(c.id)).length; const count=(typeof boxes!=='undefined'?boxes:[]).filter(b=>(window.CateriumTrainingCatalog?.visible(b)??(b.hidden!==true))&&Number(b.category||0)===Number(c.id)).length;
return `<div class="sun-category-row ${c.hidden?'is-hidden':''}" data-cat-row="${c.id}"><input value="${esc(c.name)}" data-cat-name="${c.id}" aria-label="Название вкладки"><label class="sun-category-color" title="Цвет вкладки"><input type="color" value="${esc(c.accent||'#708090')}" data-cat-color="${c.id}" aria-label="Цвет вкладки"></label><small>${count} поз.</small><div class="sun-category-row-actions"><button class="outline" type="button" data-cat-up="${c.id}" title="Выше">↑</button><button class="outline" type="button" data-cat-down="${c.id}" title="Ниже">↓</button><button class="outline" type="button" data-cat-toggle="${c.id}">${c.hidden?'Восстановить':'Скрыть'}</button></div></div>`; return `<div class="sun-category-row ${c.hidden?'is-hidden':''}" data-cat-row="${c.id}"><input value="${esc(c.name)}" data-cat-name="${c.id}" aria-label="Название вкладки"><label class="sun-category-color" title="Цвет вкладки"><input type="color" value="${esc(c.accent||'#708090')}" data-cat-color="${c.id}" aria-label="Цвет вкладки"></label><small>${count} поз.</small><div class="sun-category-row-actions"><button class="outline" type="button" data-cat-up="${c.id}" title="Выше">↑</button><button class="outline" type="button" data-cat-down="${c.id}" title="Ниже">↓</button><button class="outline" type="button" data-cat-toggle="${c.id}">${c.hidden?'Восстановить':'Скрыть'}</button></div></div>`;
}).join(''); }).join('');
qa('[data-cat-name]',root).forEach(input=>input.onchange=()=>{const c=catById(input.dataset.catName);if(!c)return;c.name=input.value.trim()||c.name;saveCats();renderCategoryTabs();syncEditorCategoryOptions();renderCatalogV5();renderCategoryManager();}); qa('[data-cat-name]',root).forEach(input=>input.onchange=()=>{const c=catById(input.dataset.catName);if(!c)return;c.name=input.value.trim()||c.name;saveCats();renderCategoryTabs();syncEditorCategoryOptions();renderCatalogV5();renderCategoryManager();});
@ -421,7 +421,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
const fields=ensureBanquetEditor();if(!fields)return;const isBanquet=Number(category)===BANQUET_CATEGORY; 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'; fields.section.style.display=isBanquet?'flex':'none';fields.weight.style.display=isBanquet?'flex':'none';
const caption=$('boxPriceCaption');if(caption)caption.textContent=isBanquet?'Обычная цена на 1 гостя, ₽':'Обычная цена, ₽'; const caption=$('boxPriceCaption');if(caption)caption.textContent=isBanquet?'Обычная цена на 1 гостя, ₽':'Обычная цена, ₽';
window.CateriumBanquet?.editor(category,item,boxes.filter(i=>i.hidden!==true&&Number(i.category)===6)); 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)||''} 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='<div class="sun-ingredient-head"><span>Вид / название</span><span>Кол-во</span><span>Ед.</span><span>Вес 1 шт., г</span><span></span></div>';root.innerHTML=head+edited.ingredients.map((row,i)=>{const w=ingredientUnitWeight(row);return `<div class="ingredient sun-ingredient-v175"><input value="${esc(cleanIngredientName(row[0]))}" onchange="edited.ingredients[${i}][0]=this.value"><input type="number" min="0" step="0.01" value="${Number(row[1]||0)}" onchange="edited.ingredients[${i}][1]=+this.value"><input value="${esc(row[2]||'шт.')}" onchange="edited.ingredients[${i}][2]=this.value"><input type="number" min="0" step="0.1" inputmode="decimal" placeholder="г" value="${w||''}" onchange="edited.ingredients[${i}][3]=Math.max(0,+this.value||0)"><button type="button" onclick="edited.ingredients.splice(${i},1);renderIngredients()">×</button></div>`}).join('')};window.addIngredient=()=>{if(!edited)return;if(!Array.isArray(edited.ingredients))edited.ingredients=[];edited.ingredients.push(['Новая позиция',1,'шт.',0]);window.renderIngredients();};} function installIngredientEditorV175(){window.renderIngredients=()=>{if(!edited)return;normalizeBoxItem(edited);const root=$('ingredients');if(!root)return;const head='<div class="sun-ingredient-head"><span>Вид / название</span><span>Кол-во</span><span>Ед.</span><span>Вес 1 шт., г</span><span></span></div>';root.innerHTML=head+edited.ingredients.map((row,i)=>{const w=ingredientUnitWeight(row);return `<div class="ingredient sun-ingredient-v175"><input value="${esc(cleanIngredientName(row[0]))}" onchange="edited.ingredients[${i}][0]=this.value"><input type="number" min="0" step="0.01" value="${Number(row[1]||0)}" onchange="edited.ingredients[${i}][1]=+this.value"><input value="${esc(row[2]||'шт.')}" onchange="edited.ingredients[${i}][2]=this.value"><input type="number" min="0" step="0.1" inputmode="decimal" placeholder="г" value="${w||''}" onchange="edited.ingredients[${i}][3]=Math.max(0,+this.value||0)"><button type="button" onclick="edited.ingredients.splice(${i},1);renderIngredients()">×</button></div>`}).join('')};window.addIngredient=()=>{if(!edited)return;if(!Array.isArray(edited.ingredients))edited.ingredients=[];edited.ingredients.push(['Новая позиция',1,'шт.',0]);window.renderIngredients();};}
@ -437,15 +437,15 @@ window.SUN_LEGACY_CATALOG_V175=[];
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 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)} 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; const BANQUET_CATEGORY=6;
function banquetCatalog(){return boxes.filter(item=>item.hidden!==true&&Number(item.category)===BANQUET_CATEGORY)} 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();}; window.toggleBanquetItem=id=>{window.CateriumBanquet.toggle(draft,banquetCatalog(),String(id));renderCatalogV5();};
function renderBanquetView(items){return window.CateriumBanquet.render({items,catalog:banquetCatalog(),draft})} function renderBanquetView(items){return window.CateriumBanquet.render({items,catalog:banquetCatalog(),draft})}
function renderCatalogV5({catalogOnly=false}={}){ function renderCatalogV5({catalogOnly=false}={}){
$('new')?.classList.toggle('sun-banquet-active',Number(activeCat)===BANQUET_CATEGORY); $('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); 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=>item.hidden!==true&&Number(item.category||0)===Number(activeCat)),items=all.filter(item=>itemMatches(item,catalogQuery));const cat=catById(activeCat)||{name:'Каталог',prep:true}; 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;} 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=>`<button class="tile" type="button" onclick="add('${esc(item.id)}')" title="Добавить в заказ">${item.photo?`<img src="${esc(window.SunSafe.imageAssetSrc(item.photo))}" alt="" loading="lazy" decoding="async" onerror="this.onerror=null;this.src='${window.CateriumBranding.logoHTML()}'">`:("<div class=\"ph\"><img src=\""+window.CateriumBranding.logoHTML()+"\" class=\"sun-ph-logo\" alt=\"Логотип "+window.CateriumBranding.nameHTML()+"\"></div>")}<span>${esc(item.name)}</span>${item.catalogSection?`<small class="tile-section">${esc(item.catalogSection)}</small>`:''}${(item.weight||inferBoxPieces(item))?`<small class="tile-meta">${item.weight?`Вес: ${esc(item.weight)}`:''}${item.weight&&inferBoxPieces(item)?' · ':''}${inferBoxPieces(item)?`${inferBoxPieces(item)} шт.`:''}</small>`:''}<span class="tile-price-wrap"><small class="tile-price">${money(window.CateriumPricing.price(item))}</small></span>${window.CateriumPricing.badge(item)}</button>`).join(''); const cards=items.map(item=>`<button class="tile" type="button" onclick="add('${esc(item.id)}')" title="Добавить в заказ">${item.photo?`<img src="${esc(window.SunSafe.imageAssetSrc(item.photo))}" alt="" loading="lazy" decoding="async" onerror="this.onerror=null;this.src='${window.CateriumBranding.logoHTML()}'">`:("<div class=\"ph\"><img src=\""+window.CateriumBranding.logoHTML()+"\" class=\"sun-ph-logo\" alt=\"Логотип "+window.CateriumBranding.nameHTML()+"\"></div>")}<span>${esc(item.name)}${window.CateriumTrainingCatalog?.badge(item)||''}</span>${item.catalogSection?`<small class="tile-section">${esc(item.catalogSection)}</small>`:''}${(item.weight||inferBoxPieces(item))?`<small class="tile-meta">${item.weight?`Вес: ${esc(item.weight)}`:''}${item.weight&&inferBoxPieces(item)?' · ':''}${inferBoxPieces(item)?`${inferBoxPieces(item)} шт.`:''}</small>`:''}<span class="tile-price-wrap"><small class="tile-price">${money(window.CateriumPricing.price(item))}</small></span>${window.CateriumPricing.badge(item)}</button>`).join('');
const addText=Number(activeCat)===0?'Добавить бокс':(Number(activeCat)===5?'Добавить премиум':'Добавить позицию'); const addText=Number(activeCat)===0?'Добавить бокс':(Number(activeCat)===5?'Добавить премиум':'Добавить позицию');
const empty=items.length?'':`<div class="catalog-empty">${catalogQuery?'По вашему запросу ничего не найдено.':`В разделе «${esc(cat.name)}» пока нет позиций.`}</div>`; const empty=items.length?'':`<div class="catalog-empty">${catalogQuery?'По вашему запросу ничего не найдено.':`В разделе «${esc(cat.name)}» пока нет позиций.`}</div>`;
$('tiles').innerHTML=`<button class="tile add" type="button" onclick="editBox(null)"><br>${addText}</button>${cards}${empty}`; $('tiles').innerHTML=`<button class="tile add" type="button" onclick="editBox(null)"><br>${addText}</button>${cards}${empty}`;
@ -457,7 +457,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
}); });
window.CateriumPricing?.watch(()=>boxes); window.CateriumPricing?.watch(()=>boxes);
window.render=renderCatalogV5; window.render=renderCatalogV5;
window.add=id=>{const item=boxes.find(x=>String(x.id)===String(id));if(!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.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();}; 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(){ function ensureManagerToolbar(){
@ -466,7 +466,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
} }
function renderManagerV5(){ 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)} 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=>i.hidden!==true&&Number(i.category||0)===Number(activeCat)&&itemMatches(i,managerQuery)),cat=catById(activeCat)||{prep:true}; 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=>`<button type="button" onclick="editBox('${esc(item.id)}')">${item.photo?`<img src="${esc(window.SunSafe.imageAssetSrc(item.photo))}" alt="" loading="lazy" decoding="async" onerror="this.onerror=null;this.src='${window.CateriumBranding.logoHTML()}'">`:("<div class=\"ph\"><img src=\""+window.CateriumBranding.logoHTML()+"\" class=\"sun-ph-logo\" alt=\"Логотип "+window.CateriumBranding.nameHTML()+"\"></div>")}<b>${esc(item.name)}</b><span class="manager-price-wrap"><small class="manager-card-price">${money(window.CateriumPricing.price(item))}</small></span>${window.CateriumPricing.badge(item)}${(item.weight||inferBoxPieces(item))?`<small class="manager-card-meta">${item.weight?`Вес: ${esc(item.weight)}`:''}${item.weight&&inferBoxPieces(item)?' · ':''}${inferBoxPieces(item)?`${inferBoxPieces(item)} шт.`:''}</small>`:''}${cat.prep?`<small>${(item.ingredients||[]).length} позиций в составе</small>`:''}</button>`).join(''):'<p class="empty">Ничего не найдено.</p>'; $('managerList').innerHTML=items.length?items.map(item=>`<button type="button" onclick="editBox('${esc(item.id)}')">${item.photo?`<img src="${esc(window.SunSafe.imageAssetSrc(item.photo))}" alt="" loading="lazy" decoding="async" onerror="this.onerror=null;this.src='${window.CateriumBranding.logoHTML()}'">`:("<div class=\"ph\"><img src=\""+window.CateriumBranding.logoHTML()+"\" class=\"sun-ph-logo\" alt=\"Логотип "+window.CateriumBranding.nameHTML()+"\"></div>")}<b>${esc(item.name)}</b><span class="manager-price-wrap"><small class="manager-card-price">${money(window.CateriumPricing.price(item))}</small></span>${window.CateriumPricing.badge(item)}${(item.weight||inferBoxPieces(item))?`<small class="manager-card-meta">${item.weight?`Вес: ${esc(item.weight)}`:''}${item.weight&&inferBoxPieces(item)?' · ':''}${inferBoxPieces(item)?`${inferBoxPieces(item)} шт.`:''}</small>`:''}${cat.prep?`<small>${(item.ingredients||[]).length} позиций в составе</small>`:''}</button>`).join(''):'<p class="empty">Ничего не найдено.</p>';
} }
window.openManager=()=>{managerQuery='';ensureManagerToolbar();if($('sunManagerSearch'))$('sunManagerSearch').value='';renderManagerV5();window.modal?.('manager');}; window.openManager=()=>{managerQuery='';ensureManagerToolbar();if($('sunManagerSearch'))$('sunManagerSearch').value='';renderManagerV5();window.modal?.('manager');};
@ -476,7 +476,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
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'); 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?'Новая премиум позиция':'Новая позиция'));}; 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=>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.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?.();}; 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 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)})}
@ -1841,7 +1841,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
for(const st of stats.values()){maxOrders=Math.max(maxOrders,st.orders);maxQty=Math.max(maxQty,st.qty)} 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; const out=new Map();for(const [id,st] of stats)out.set(id,(maxOrders?st.orders/maxOrders:0)*.78+(maxQty?st.qty/maxQty:0)*.22);return out;
} }
function foodCandidates(){const pop=popularityMap();return getBoxes().filter(x=>Number(x?.category||0)===0&&x?.hidden!==true&&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 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 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 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){ function score(s,t,seed){

View File

@ -119,7 +119,7 @@
let originalModal=null,originalCloseModal=null,originalSaveBox=null,originalRemoveBox=null; let originalModal=null,originalCloseModal=null,originalSaveBox=null,originalRemoveBox=null;
function menuCanEdit(){if(support())return false;const c=cloud();if(!c?.getSession?.()?.user)return true;return can('catalog.edit')} function menuCanEdit(){if(support())return false;const c=cloud();if(!c?.getSession?.()?.user)return true;return can('catalog.edit')}
function currentCategory(){return CATEGORIES.find(x=>x.id===menuCategory)||CATEGORIES[0]} function currentCategory(){return CATEGORIES.find(x=>x.id===menuCategory)||CATEGORIES[0]}
function menuItems(){const q=menuQuery.trim().toLowerCase();return boxes().filter(x=>x.hidden!==true&&Number(x.category||0)===menuCategory&&(!q||[x.name,x.weight,x.catalogSection,...(x.composition||[])].join(' ').toLowerCase().includes(q)))} function menuItems(){const q=menuQuery.trim().toLowerCase();return boxes().filter(x=>(window.CateriumTrainingCatalog?.visible(x)??(x.hidden!==true))&&Number(x.category||0)===menuCategory&&(!q||[x.name,x.weight,x.catalogSection,...(x.composition||[])].join(' ').toLowerCase().includes(q)))}
function restoreEditorDialog(){if(editorHome&&editorDialog&&editorDialog.parentNode!==editorHome){editorHome.appendChild(editorDialog);editorDialog.classList.remove('sun-menu-editor-docked');}editorHome?.classList.remove('on');} function restoreEditorDialog(){if(editorHome&&editorDialog&&editorDialog.parentNode!==editorHome){editorHome.appendChild(editorDialog);editorDialog.classList.remove('sun-menu-editor-docked');}editorHome?.classList.remove('on');}
function emptyMenuDetail(text='Выберите позицию слева, чтобы открыть карточку.'){ function emptyMenuDetail(text='Выберите позицию слева, чтобы открыть карточку.'){
const pane=$('sunMenuDetailV1762');if(!pane)return;restoreEditorDialog();pane.innerHTML=`<div class="sun-menu-detail-placeholder"><div><b>${esc(text)}</b>${!menuCanEdit()?'<div class="sun-menu-readonly-badge" style="margin-top:9px">только просмотр</div>':''}</div></div>`; const pane=$('sunMenuDetailV1762');if(!pane)return;restoreEditorDialog();pane.innerHTML=`<div class="sun-menu-detail-placeholder"><div><b>${esc(text)}</b>${!menuCanEdit()?'<div class="sun-menu-readonly-badge" style="margin-top:9px">только просмотр</div>':''}</div></div>`;
@ -139,7 +139,7 @@
if(!menuView)return;const list=$('sunMenuListV1762'),count=$('sunMenuCountV1762');if(!list)return; if(!menuView)return;const list=$('sunMenuListV1762'),count=$('sunMenuCountV1762');if(!list)return;
qa('[data-menu-cat-v1762]',menuView).forEach(b=>b.classList.toggle('on',Number(b.dataset.menuCatV1762)===menuCategory)); qa('[data-menu-cat-v1762]',menuView).forEach(b=>b.classList.toggle('on',Number(b.dataset.menuCatV1762)===menuCategory));
const items=menuItems();if(count)count.textContent=`${items.length} поз.`; const items=menuItems();if(count)count.textContent=`${items.length} поз.`;
list.innerHTML=items.length?items.map(item=>`<button type="button" class="sun-menu-row" data-menu-item-v1762="${esc(item.id)}">${item.photo?`<img src="${esc(item.photo)}" alt="" loading="lazy" decoding="async">`:'<div class="ph"></div>'}<span><b>${esc(item.name||'Без названия')}</b><small>${[item.weight,Number(item.pieces||0)?`${Number(item.pieces)} шт.`:'',item.catalogSection].filter(Boolean).map(esc).join(' · ')}</small></span><span class="sun-menu-row-price">${esc(money((window.CateriumPricing?.price(item)??Number(item?.price||0))))}${window.CateriumPricing?.badge(item)||''}</span></button>`).join(''):'<div class="sun-menu-empty">В этом разделе ничего не найдено.</div>'; list.innerHTML=items.length?items.map(item=>`<button type="button" class="sun-menu-row" data-menu-item-v1762="${esc(item.id)}">${item.photo?`<img src="${esc(item.photo)}" alt="" loading="lazy" decoding="async">`:'<div class="ph"></div>'}<span><b>${esc(item.name||'Без названия')}</b>${window.CateriumTrainingCatalog?.badge(item)||''}<small>${[item.weight,Number(item.pieces||0)?`${Number(item.pieces)} шт.`:'',item.catalogSection].filter(Boolean).map(esc).join(' · ')}</small></span><span class="sun-menu-row-price">${esc(money((window.CateriumPricing?.price(item)??Number(item?.price||0))))}${window.CateriumPricing?.badge(item)||''}</span></button>`).join(''):'<div class="sun-menu-empty">В этом разделе ничего не найдено.</div>';
qa('[data-menu-item-v1762]',list).forEach(b=>b.onclick=()=>{const item=boxes().find(x=>String(x.id)===String(b.dataset.menuItemV1762));if(!item)return;if(menuCanEdit()){window.editBox?.(item.id);setTimeout(dockEditor,0)}else showReadOnlyMenuItem(item)}); qa('[data-menu-item-v1762]',list).forEach(b=>b.onclick=()=>{const item=boxes().find(x=>String(x.id)===String(b.dataset.menuItemV1762));if(!item)return;if(menuCanEdit()){window.editBox?.(item.id);setTimeout(dockEditor,0)}else showReadOnlyMenuItem(item)});
} }
function patchMenuLegacyEditor(){ function patchMenuLegacyEditor(){
@ -254,6 +254,7 @@
document.addEventListener('click',e=>{const more=e.target.closest('#calendar .cal-more');if(more){e.preventDefault();e.stopImmediatePropagation();showCalendarDay(more.closest('.cal-day'));return}interceptRouteActions(e)},true); document.addEventListener('click',e=>{const more=e.target.closest('#calendar .cal-more');if(more){e.preventDefault();e.stopImmediatePropagation();showCalendarDay(more.closest('.cal-day'));return}interceptRouteActions(e)},true);
window.addEventListener('sun:cloud-permissions-changed',()=>{patchSupportPermissions();maintainSupport();syncMenuPermission();if(menuActive){renderMenuList();emptyMenuDetail();}}); window.addEventListener('sun:cloud-permissions-changed',()=>{patchSupportPermissions();maintainSupport();syncMenuPermission();if(menuActive){renderMenuList();emptyMenuDetail();}});
window.addEventListener('caterium:catalog-prices-changed',()=>{if(menuActive)renderMenuList();}); window.addEventListener('caterium:catalog-prices-changed',()=>{if(menuActive)renderMenuList();});
window.addEventListener('caterium:training-catalog-changed',()=>{if(menuActive)renderMenuList();});
window.addEventListener('sun:cloud-state-applied',()=>{maintainSupport();syncMenuPermission();if(menuActive)renderMenuList();setTimeout(enhanceRoutePage,80)}); window.addEventListener('sun:cloud-state-applied',()=>{maintainSupport();syncMenuPermission();if(menuActive)renderMenuList();setTimeout(enhanceRoutePage,80)});
const mo=new MutationObserver(records=>{let route=false,supportBanner=false;for(const r of records){for(const n of r.addedNodes){if(n.nodeType!==1)continue;if(n.id==='routeContent'||n.querySelector?.('#routeContent')||n.closest?.('#sun-routes-view'))route=true;if(n.id==='sunDevSupportBannerV22'||n.querySelector?.('#sunDevSupportBannerV22'))supportBanner=true;}}if(route)setTimeout(enhanceRoutePage,30);if(supportBanner)setTimeout(renderSupportStatus,20);});mo.observe(document.documentElement,{childList:true,subtree:true}); const mo=new MutationObserver(records=>{let route=false,supportBanner=false;for(const r of records){for(const n of r.addedNodes){if(n.nodeType!==1)continue;if(n.id==='routeContent'||n.querySelector?.('#routeContent')||n.closest?.('#sun-routes-view'))route=true;if(n.id==='sunDevSupportBannerV22'||n.querySelector?.('#sunDevSupportBannerV22'))supportBanner=true;}}if(route)setTimeout(enhanceRoutePage,30);if(supportBanner)setTimeout(renderSupportStatus,20);});mo.observe(document.documentElement,{childList:true,subtree:true});
if(!maintenanceTimer)maintenanceTimer=setInterval(()=>{if(document.hidden)return;maintainSupport();syncMenuPermission();if($('sun-routes-view')?.classList.contains('on'))enhanceRoutePage();},8000); if(!maintenanceTimer)maintenanceTimer=setInterval(()=>{if(document.hidden)return;maintainSupport();syncMenuPermission();if($('sun-routes-view')?.classList.contains('on'))enhanceRoutePage();},8000);

View File

@ -0,0 +1,165 @@
/* Optional learning examples. Visibility belongs to a profile in a workspace;
records stay available for existing orders, recipes and document snapshots. */
(()=>{
'use strict';
if(window.CateriumTrainingCatalog)return;
const KEY='sunTrialDemoV1',EVENT='caterium:training-catalog-changed';
const IDS={"catalog":["demo-v1-bruschetta-tomato","demo-v1-salmon-cream","demo-v1-chicken-sandwich","demo-v1-caprese","demo-v1-mushroom-tartlet","demo-v1-turkey-wrap","demo-v1-cheese-fruit","demo-v1-meat-assortment","demo-v1-vegetables-hummus","demo-v1-berry-dessert","demo-banquet-v1-caprese","demo-banquet-v1-roastbeef","demo-banquet-v1-salmon-roll","demo-banquet-v1-hummus","demo-banquet-v1-caesar","demo-banquet-v1-olivier","demo-banquet-v1-greek","demo-banquet-v1-julienne","demo-banquet-v1-stuffed-mushrooms","demo-banquet-v1-chicken","demo-banquet-v1-cod","demo-banquet-v1-beef-hot","demo-banquet-v1-mash","demo-banquet-v1-rice","demo-banquet-v1-berry-cream","demo-banquet-v1-cheese-honey","demo-banquet-v1-fruit","demo-banquet-v1-bread","demo-extras-v1-salmon-caprese","demo-extras-v1-meat-cheese","demo-extras-v1-mini-buffet","demo-extras-v1-water","demo-extras-v1-sparkling","demo-extras-v1-juice","demo-extras-v1-mors","demo-extras-v1-plate","demo-extras-v1-fork","demo-extras-v1-glass","demo-extras-v1-napkin","demo-extras-v1-ice","demo-extras-v1-tablecloth","demo-extras-v1-serving-kit","demo-extras-v1-delivery-city","demo-extras-v1-delivery-outer"],"stock":["demo-v1-stock-baguette","demo-v1-stock-tomato","demo-v1-stock-basil","demo-v1-stock-oil","demo-v1-stock-rye","demo-v1-stock-salmon","demo-v1-stock-cream","demo-v1-stock-cucumber","demo-v1-stock-dill","demo-v1-stock-toast","demo-v1-stock-chicken","demo-v1-stock-lettuce","demo-v1-stock-mozzarella","demo-v1-stock-cherry","demo-v1-stock-tartlet","demo-v1-stock-mushroom","demo-v1-stock-cooking-cream","demo-v1-stock-gouda","demo-v1-stock-onion","demo-v1-stock-tortilla","demo-v1-stock-turkey","demo-v1-stock-pepper","demo-v1-stock-brie","demo-v1-stock-blue","demo-v1-stock-grape","demo-v1-stock-walnut","demo-v1-stock-honey","demo-v1-stock-cracker","demo-v1-stock-salami","demo-v1-stock-beef","demo-v1-stock-pickle","demo-v1-stock-olive","demo-v1-stock-mustard","demo-v1-stock-carrot","demo-v1-stock-hummus","demo-v1-stock-biscuit","demo-v1-stock-strawberry","demo-v1-stock-blueberry","demo-v1-stock-sugar","demo-v1-stock-box","demo-v1-stock-skewer","demo-v1-stock-cup","demo-v1-stock-sauce-cup","demo-banquet-v1-stock-potato","demo-banquet-v1-stock-butter","demo-banquet-v1-stock-milk","demo-banquet-v1-stock-egg","demo-banquet-v1-stock-peas","demo-banquet-v1-stock-chicken-raw","demo-banquet-v1-stock-whitefish","demo-banquet-v1-stock-lemon","demo-banquet-v1-stock-rice","demo-extras-v1-stock-water","demo-extras-v1-stock-sparkling","demo-extras-v1-stock-juice","demo-extras-v1-stock-mors","demo-extras-v1-stock-plate","demo-extras-v1-stock-fork","demo-extras-v1-stock-glass","demo-extras-v1-stock-napkin","demo-extras-v1-stock-ice","demo-extras-v1-stock-tablecloth","demo-extras-v1-stock-serving-kit"],"suppliers":["demo-v1-supplier-fresh","demo-v1-supplier-protein","demo-v1-supplier-bakery","demo-v1-supplier-grocery","demo-v1-supplier-pack"]};
const sets=Object.fromEntries(Object.entries(IDS).map(([k,v])=>[k,new Set(v)]));
const $=id=>document.getElementById(id),copy=v=>JSON.parse(JSON.stringify(v));
const cloud=()=>window.SunCloudV2,repo=()=>window.CateriumDataV1773;
let changing=false,epoch=0,busy=false,desired=null,bundlePromise=null,lastState='',note='';
function read(key,fallback){const raw=localStorage.getItem(key);if(raw===null)return copy(fallback);return JSON.parse(raw);}
function metadata(){try{const m=read(KEY,{});return m&&typeof m==='object'&&!Array.isArray(m)?m:{}}catch(_){return {}}}
function context(){const c=cloud();return {user:String(c?.getSession?.()?.user?.id||''),workspace:String(c?.getWorkspace?.()?.id||'')};}
function enabled(){
if(changing)return false;
const c=context(),s=metadata().trainingCatalog;
return Boolean(c.user&&c.workspace&&s?.workspaceId===c.workspace&&s?.profiles?.[c.user]===true);
}
function isSample(item,kind='catalog'){
return Boolean(item&&((sets[kind]||sets.catalog).has(String(item.id))||(kind==='stock'&&item.trainingCatalog===true)));
}
function visible(item,kind='catalog'){return item?.hidden!==true&&(!isSample(item,kind)||enabled());}
function badge(item){return isSample(item)&&enabled()?'<small class="ct-training-badge">Учебное</small>':'';}
function canManage(){
const c=cloud(),who=context();
return !changing&&Boolean(who.user&&who.workspace)&&!c?.isSupportMode?.()&&!c?.getSupportMode?.()&&
(c?.hasPermission?.('catalog.edit')===true||c?.hasPermission?.('app.write')===true)&&window.SunSaaSV16?.isWritable?.()!==false;
}
async function loadBundle(){
if(bundlePromise)return bundlePromise;
bundlePromise=(async()=>{
const controller=new AbortController(),timeout=setTimeout(()=>controller.abort(),15000);
try{
const [base,banquet,extras]=await Promise.all(['catalog-v1','banquet-v1','extras-v1'].map(async name=>{
const r=await fetch(`demo/${name}.json?v=20260920-training`,{signal:controller.signal,credentials:'same-origin'});
if(!r.ok)throw new Error('Не удалось загрузить учебный каталог. Проверьте интернет и повторите.');
const value=await r.json();if(value?.version!==1)throw new Error('Неизвестная версия учебного каталога.');return value;
}));
if(![base.boxes,base.stock,base.suppliers,banquet.banquet,banquet.stock,extras.items,extras.stock].every(Array.isArray))throw new Error('Учебный каталог повреждён.');
const result={boxes:[...base.boxes,...banquet.banquet,...extras.items],stock:[...base.stock,...banquet.stock,...extras.stock],suppliers:base.suppliers,scenario:base.scenario,batch:base.batch};
for(const [domain,rows] of Object.entries({catalog:result.boxes,stock:result.stock,suppliers:result.suppliers})){
if(rows.length!==sets[domain].size||new Set(rows.map(x=>x?.id)).size!==rows.length||rows.some(x=>!sets[domain].has(x?.id)||typeof x?.name!=='string'))throw new Error('Неполный учебный каталог.');
}
return result;
}finally{clearTimeout(timeout);}
})().catch(error=>{bundlePromise=null;throw error;});
return bundlePromise;
}
// Pure additive merge: never overwrite a user's prices, recipes, inventory,
// supplier contacts or orders. New inventory has distinct names so a test
// recipe does not resolve to an existing real product with the same name.
function merge(seed,current){
for(const domain of ['boxes','stock','suppliers'])if(!Array.isArray(current[domain]))throw new Error('Рабочие данные ещё не загружены. Повторите после загрузки.');
const next=copy(current),maps=Object.fromEntries(['boxes','stock','suppliers'].map(k=>[k,new Map(next[k].map(x=>[String(x.id),x]))]));
const sku=(name,unit)=>`${String(name).trim().toLowerCase()}|${String(unit).trim().toLowerCase()}`;
const names=new Set(next.stock.map(p=>sku(p.name,p.unit)));
for(const source of seed.suppliers)if(!maps.suppliers.has(source.id)){
const row={...copy(source),trainingCatalog:true};next.suppliers.push(row);maps.suppliers.set(row.id,row);
}
for(const source of seed.stock)if(!maps.stock.has(source.id)){
let name=`Учебное: ${source.name.replace(/^Демо:\s*/,'')}`,n=2;
while(names.has(sku(name,source.unit)))name=`Учебное: ${source.name} (${n++})`;
const row={...copy(source),name,qty:0,min:0,trainingCatalog:true};next.stock.push(row);maps.stock.set(row.id,row);names.add(sku(name,row.unit));
}
const byIngredient=new Map(seed.stock.map(s=>[sku(s.name,s.unit),maps.stock.get(s.id)]));
for(const source of seed.boxes)if(!maps.boxes.has(source.id)){
const row=copy(source);row.demo=true;
row.ingredients=(row.ingredients||[]).map(part=>{const p=byIngredient.get(sku(part[0],part[2]));return p?[p.name,...part.slice(1)]:part;});
if(row.ttk?.rows)row.ttk.rows=row.ttk.rows.map(r=>{const p=maps.stock.get(r.productId);return p?{...r,name:p.name}:r;});
next.boxes.push(row);maps.boxes.set(row.id,row);
}
return next;
}
function liveCatalog(){return repo()?.catalog?.list?.()||read('sunBoxes',[]);}
function notify(){
renderSettings();window.CateriumTrialDemo?.render?.();
window.dispatchEvent(new CustomEvent(EVENT));
// Only selection UI is refreshed. Stored orders, their line prices and the
// editor node are not filtered, deleted, regenerated or repriced here.
window.render?.();window.sunRenderSuppliers?.();
}
async function setEnabled(value){
if(busy)throw new Error('Дождитесь завершения загрузки учебного каталога.');
if(typeof value!=='boolean')throw new Error('Некорректное значение переключателя.');
if(!canManage())throw new Error('Для изменения учебного каталога нужны права редактирования каталога в своей компании.');
const ticket=epoch,who=context();busy=true;desired=value;note=value?'Загружаю учебные примеры…':'';renderSettings();
try{
const seed=value?await loadBundle():null;
if(ticket!==epoch||JSON.stringify(context())!==JSON.stringify(who))throw new Error('Профиль или компания сменились. Изменение отменено.');
if(!canManage())throw new Error('Права изменились. Учебный каталог не изменён.');
const meta=read(KEY,{});if(!meta||typeof meta!=='object'||Array.isArray(meta))throw new Error('Не удалось прочитать настройки. Ничего не изменено.');
const oldMeta=meta.trainingCatalog;
const nextMeta={...meta,trainingCatalog:{workspaceId:who.workspace,profiles:{...(oldMeta?.workspaceId===who.workspace?oldMeta.profiles:{}),[who.user]:value}}};
const previous={sunBoxes:localStorage.getItem('sunBoxes'),sunStock:localStorage.getItem('sunStock'),sunSuppliers:localStorage.getItem('sunSuppliers'),[KEY]:localStorage.getItem(KEY)};
const before=liveCatalog();let next=null;
if(value){
next=merge(seed,{boxes:before,stock:read('sunStock',[]),suppliers:read('sunSuppliers',[])});
nextMeta.version=1;nextMeta.batch=meta.batch||seed.batch;nextMeta.scenario=meta.scenario||seed.scenario;
}
try{
if(next){
// Commit synchronously before cloud autosync takes its next snapshot.
// persistLocal:false writes only catalog, not unrelated order domains.
localStorage.setItem('sunStock',JSON.stringify(next.stock));
localStorage.setItem('sunSuppliers',JSON.stringify(next.suppliers));
repo().catalog.replace(next.boxes,{persistLocal:false,reason:'training-catalog.enable'});
}
localStorage.setItem(KEY,JSON.stringify(nextMeta));
}catch(error){
for(const [key,raw] of Object.entries(previous)){try{if(raw===null)localStorage.removeItem(key);else localStorage.setItem(key,raw);}catch(_){}}
try{if(typeof boxes!=='undefined'){boxes.length=0;boxes.push(...copy(before));}}catch(_){}
throw new Error('Не удалось сохранить учебный каталог. Проверьте свободное место и повторите.');
}
note=value?'Учебный каталог включён. Откройте «Меню» или «Новый заказ».':'Учебные позиции скрыты. Ваши данные и созданные заказы сохранены.';
lastState=stateSignature();notify();return true;
}catch(error){if(ticket===epoch)note=error?.name==='AbortError'?'Загрузка заняла слишком долго. Проверьте интернет и повторите.':error.message;throw error;}
finally{if(ticket===epoch){busy=false;desired=null;renderSettings();}}
}
function stateSignature(){const c=context();return JSON.stringify([c.user,c.workspace,enabled(),canManage()]);}
function renderSettings(){
const host=$('enterprise-settings')?.querySelector('.enterprise-grid')||$('enterprise-settings');
const who=context();if(!host)return;
if(changing||!who.user||!who.workspace){$('ctTrainingSettings')?.remove();return;}
let card=$('ctTrainingSettings');
if(!card){
card=document.createElement('section');card.id='ctTrainingSettings';card.className='enterprise-card';
card.innerHTML='<h2>Обучение и знакомство</h2><label class="ct-training-toggle"><input id="ctTrainingEnabled" type="checkbox" aria-describedby="ctTrainingDescription ctTrainingSafety"><span><b>Учебный каталог</b><small>Готовые боксы и блюда для знакомства с Caterium</small></span></label><p id="ctTrainingDescription">Фотографии, составы, технологические карты, учебные цены и поставщики. Можно собрать пробный заказ, посмотреть предложение клиенту, заготовки и закупки — без ручного заполнения каталога.</p><p id="ctTrainingSafety">Снимите галочку, чтобы скрыть учебные позиции. Свои блюда, изменения учебных карточек и созданные заказы не удаляются. Переключатель действует для вашего профиля в этой компании. Складские операции выполняются только по вашей команде.</p><p id="ctTrainingAccess" class="hint"></p><p id="ctTrainingStatus" role="status" aria-live="polite"></p>';
host.prepend(card);
card.querySelector('input').addEventListener('change',e=>{setEnabled(e.target.checked).catch(()=>{});});
}
const check=$('ctTrainingEnabled');check.checked=busy?desired:enabled();check.disabled=busy||!canManage()||!repo()?.catalog;
card.setAttribute('aria-busy',String(busy));
const access=!canManage()?'Для включения нужны права редактирования каталога. Режим просмотра не даёт дополнительных прав.':'';
if($('ctTrainingAccess').textContent!==access)$('ctTrainingAccess').textContent=access;
if($('ctTrainingStatus').textContent!==note)$('ctTrainingStatus').textContent=note;
}
function refresh(){
const signature=stateSignature();renderSettings();
if(signature!==lastState){lastState=signature;window.CateriumTrialDemo?.render?.();window.dispatchEvent(new CustomEvent(EVENT));window.render?.();}
}
function boot(){
const style=document.createElement('style');style.textContent=`
#ctTrainingSettings{min-width:0;padding:18px;border:1px solid var(--sun-ui-border,#deded6);border-radius:14px;background:var(--sun-ui-card,#fff);color:var(--sun-ui-text,#27241e)}
#ctTrainingSettings h2{margin:0 0 14px;font-size:18px}#ctTrainingSettings p{font-size:12px;line-height:1.5;margin:10px 0 0}
#ctTrainingSettings .ct-training-toggle{display:flex!important;flex-direction:row!important;align-items:flex-start;gap:12px;margin:0;cursor:pointer}
#ctTrainingSettings #ctTrainingEnabled{width:20px!important;height:20px!important;min-height:20px;margin:2px 0 0;flex:0 0 20px;accent-color:#b88c2e}
#ctTrainingSettings .ct-training-toggle b{display:block;font-size:15px;line-height:1.4}#ctTrainingSettings .ct-training-toggle small{display:block;font-size:12px;line-height:1.5;color:var(--sun-ui-muted,#72766e)}
#ctTrainingSettings input:focus-visible{outline:2px solid #b88c2e;outline-offset:3px}#ctTrainingStatus:empty,#ctTrainingAccess:empty{display:none}
.ct-training-badge{display:inline-block!important;width:auto!important;max-width:100%;padding:2px 5px;margin:3px 0;border-radius:5px;background:#ece6d7;color:#695a37!important;font:700 10px/1.4 Arial,sans-serif!important;white-space:nowrap}
@media print{#ctTrainingSettings{display:none!important}}
`;document.head.append(style);
window.addEventListener('sun:cloud-tenant-changing',()=>{changing=true;epoch++;busy=false;desired=null;note='';lastState='';$('ctTrainingSettings')?.remove();});
window.addEventListener('sun:cloud-state-applied',()=>{changing=false;refresh();});
window.addEventListener('sun:cloud-permissions-changed',()=>{changing=false;refresh();});
window.addEventListener('sun:subscription-changed',refresh);
window.addEventListener('storage',e=>{if(e.key===KEY)refresh();});
document.addEventListener('click',e=>{if(e.target.closest?.('header nav button'))renderSettings();});
const attach=()=>{const host=$('enterprise-settings');if(!host)return false;renderSettings();new MutationObserver(()=>{if(!$('ctTrainingSettings'))renderSettings();}).observe(host,{childList:true,subtree:true});return true;};
if(!attach()){const waiting=new MutationObserver(()=>{if(attach())waiting.disconnect();});waiting.observe(document.body,{childList:true});}
refresh();
}
window.CateriumTrainingCatalog=Object.freeze({enabled,visible,isSample,badge,canManage,setEnabled,merge,refresh,renderSettings});
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot();
})();

View File

@ -16,44 +16,36 @@
let status=null,statusWorkspace='',pending=null,epoch=0,busy=false; let status=null,statusWorkspace='',pending=null,epoch=0,busy=false;
function navigate(id){const label={stock:'Склад',shopping:'Закупки'}[id];const nav=[...document.querySelectorAll('header nav button')].find(b=>String(b.dataset.navLabel||b.textContent||'').trim()===label);if(nav)nav.click();else window.show?.(id);} function navigate(id){const label={stock:'Склад',shopping:'Закупки'}[id];const nav=[...document.querySelectorAll('header nav button')].find(b=>String(b.dataset.navLabel||b.textContent||'').trim()===label);if(nav)nav.click();else window.show?.(id);}
function message(text){const el=$('ctDemoMessage');if(el)el.textContent=text;} function message(text){const el=$('ctDemoMessage');if(el)el.textContent=text;}
async function refreshStatus(){ async function refreshStatus(){status=null;statusWorkspace='';render();}
const c=cloud(),ws=c?.getWorkspace?.()?.id;
if(!signedIn()||c?.isSupportMode?.()){status=null;statusWorkspace='';render();return;}
if(statusWorkspace===ws||pending)return;
const ticket=epoch;pending=ws;
try{const result=await c.getClient?.()?.rpc('caterium_trial_demo_status',{p_workspace:ws});
if(ticket!==epoch||c.getWorkspace?.()?.id!==ws)return;
if(result?.error)throw result.error;
status=result?.data||null;statusWorkspace=ws;
}catch(_){status=null;}finally{if(ticket===epoch){pending=null;render();}}
}
function render(){ function render(){
const host=$('new');if(!host)return; const host=$('new');if(!host)return;
if(window.CateriumTrainingCatalog&&!window.CateriumTrainingCatalog.enabled()){$('ctTrialDemo')?.remove();return;}
const demoItems=catalog().filter(b=>b.demo),items=demoItems.filter(b=>b.ttk),active=installed()&&demoItems.length>0; const demoItems=catalog().filter(b=>b.demo),items=demoItems.filter(b=>b.ttk),active=installed()&&demoItems.length>0;
if(!signedIn()||cloud()?.isSupportMode?.()||(!active&&!status?.canInstall)){$('ctTrialDemo')?.remove();return;} if(!signedIn()||cloud()?.isSupportMode?.()||(!active&&!status?.canInstall)){$('ctTrialDemo')?.remove();return;}
let card=$('ctTrialDemo');if(!card){card=document.createElement('section');card.id='ctTrialDemo';card.className='card';host.prepend(card);} let card=$('ctTrialDemo');if(!card){card=document.createElement('section');card.id='ctTrialDemo';card.className='card';host.prepend(card);}
const signature=JSON.stringify({active,items:demoItems.map(x=>[x.id,x.name]),upgrade:status?.canUpgrade,can:writable()}); const signature=JSON.stringify({active,items:demoItems.map(x=>[x.id,x.name]),upgrade:status?.canUpgrade,can:writable()});
if(card.dataset.signature===signature)return;card.dataset.signature=signature; if(card.dataset.signature===signature)return;card.dataset.signature=signature;
card.innerHTML=active?`<div class="ct-demo-heading"><div><strong>Демонстрационная база</strong><p>${demoItems.length} тестовых позиций · готовые составы, учебные цены и поставщики</p></div><span class="ct-demo-badge">Можно пробовать</span></div> const previousGuide={open:card.querySelector('details')?.open,selected:$('ctDemoBox')?.value,message:$('ctDemoMessage')?.textContent};
card.innerHTML=active?`<div class="ct-demo-heading"><div><strong>Учебный каталог включён</strong><p>${demoItems.length} тестовых позиций · готовые составы, учебные цены и поставщики</p></div><span class="ct-demo-badge">Учебные примеры</span></div>
<p>Создайте пробный заказ, посмотрите нехватку в закупках, оформите приход на складе и спишите продукты по заказу. Все данные можно редактировать.</p> <p>Создайте пробный заказ, посмотрите нехватку в закупках, оформите приход на складе и спишите продукты по заказу. Все данные можно редактировать.</p>
<div class="ct-demo-actions"><button class="primary" data-demo-order ${writable()?'':'disabled'}>Создать пробный заказ</button><button class="outline" data-demo-go="stock">Склад</button><button class="outline" data-demo-go="shopping">Закупки</button></div> <div class="ct-demo-actions"><button class="primary" data-demo-order ${writable()?'':'disabled'}>Создать пробный заказ</button><button class="outline" data-demo-go="stock">Склад</button><button class="outline" data-demo-go="shopping">Закупки</button></div>
${status?.canUpgrade?'<button class="outline" data-demo-install>Дополнить пустые вкладки демо</button>':''} ${status?.canUpgrade?'<button class="outline" data-demo-install>Дополнить пустые вкладки демо</button>':''}
<details><summary>Технологические карты боксов и блюд</summary><div class="ct-demo-actions"><label>Бокс или блюдо<select id="ctDemoBox">${[...new Set(items.map(b=>Number(b.category)===6?'Банкетное меню':Number(b.category)===5?'Премиум':'Боксы'))].map(label=>`<optgroup label="${label}">${items.filter(b=>(Number(b.category)===6?'Банкетное меню':Number(b.category)===5?'Премиум':'Боксы')===label).map(b=>`<option value="${esc(b.id)}">${esc(b.name)}</option>`).join('')}</optgroup>`).join('')}</select></label><button class="outline" data-demo-ttk>Открыть ТТК</button></div></details><p id="ctDemoMessage" role="status" aria-live="polite"></p>`: <details><summary>Технологические карты боксов и блюд</summary><div class="ct-demo-actions"><label>Бокс или блюдо<select id="ctDemoBox">${[...new Set(items.map(b=>Number(b.category)===6?'Банкетное меню':Number(b.category)===5?'Премиум':'Боксы'))].map(label=>`<optgroup label="${label}">${items.filter(b=>(Number(b.category)===6?'Банкетное меню':Number(b.category)===5?'Премиум':'Боксы')===label).map(b=>`<option value="${esc(b.id)}">${esc(b.name)}</option>`).join('')}</optgroup>`).join('')}</select></label><button class="outline" data-demo-ttk>Открыть ТТК</button></div></details><p id="ctDemoMessage" role="status" aria-live="polite"></p>`:
`<div class="ct-demo-heading"><div><strong>Попробуйте приложение на готовом меню</strong><p>Боксы и премиум-сеты, 18 банкетных блюд, напитки, посуда, дополнения и доставка. Учебные цены, составы и поставщики для проверки заказа, склада и закупки.</p></div></div><button class="primary" data-demo-install>Загрузить демо</button><p id="ctDemoMessage" role="status" aria-live="polite"></p>`; `<div class="ct-demo-heading"><div><strong>Попробуйте приложение на готовом меню</strong><p>Боксы и премиум-сеты, 18 банкетных блюд, напитки, посуда, дополнения и доставка. Учебные цены, составы и поставщики для проверки заказа, склада и закупки.</p></div></div><button class="primary" data-demo-install>Загрузить демо</button><p id="ctDemoMessage" role="status" aria-live="polite"></p>`;
// Preserve the user's disclosure and selection across actual data changes.
if(previousGuide.open&&card.querySelector('details'))card.querySelector('details').open=true;
const selector=$('ctDemoBox');if(selector&&[...selector.options].some(o=>o.value===previousGuide.selected))selector.value=previousGuide.selected;
if(previousGuide.message&&$('ctDemoMessage'))$('ctDemoMessage').textContent=previousGuide.message;
card.querySelector('[data-demo-install]')?.addEventListener('click',install); card.querySelector('[data-demo-install]')?.addEventListener('click',install);
card.querySelector('[data-demo-order]')?.addEventListener('click',createOrder); card.querySelector('[data-demo-order]')?.addEventListener('click',createOrder);
card.querySelector('[data-demo-ttk]')?.addEventListener('click',()=>showTTK($('ctDemoBox').value)); card.querySelector('[data-demo-ttk]')?.addEventListener('click',()=>showTTK($('ctDemoBox').value));
card.querySelectorAll('[data-demo-go]').forEach(b=>b.addEventListener('click',()=>navigate(b.dataset.demoGo))); card.querySelectorAll('[data-demo-go]').forEach(b=>b.addEventListener('click',()=>navigate(b.dataset.demoGo)));
} }
async function install(){ async function install(){try{await window.CateriumTrainingCatalog.setEnabled(true);}catch(error){message(error.message);}}
if(busy)return;busy=true;const btn=$('ctTrialDemo')?.querySelector('[data-demo-install]');if(btn)btn.disabled=true;
message('Загружаю демонстрационную базу…');
try{await cloud().installTrialDemo();status=null;statusWorkspace='';render();message('Готово. Выберите позиции в нужной вкладке каталога.');await refreshStatus();}
catch(error){message(error?.message||'Не удалось загрузить демо. Повторите попытку.');}
finally{busy=false;if(btn?.isConnected)btn.disabled=false;}
}
function createOrder(){ function createOrder(){
if(!writable()||!installed())return; if(!writable()||!installed()||(window.CateriumTrainingCatalog&&!window.CateriumTrainingCatalog.enabled()))return;
const repo=data()?.orders;if(!repo){message('Приложение ещё загружается. Повторите через несколько секунд.');return;} const repo=data()?.orders;if(!repo){message('Приложение ещё загружается. Повторите через несколько секунд.');return;}
const all=repo.list(),existing=all.find(o=>o.demoBatch===BATCH); const all=repo.list(),existing=all.find(o=>o.demoBatch===BATCH);
if(existing){message(`Пробный заказ №${existing.id} уже создан. Его можно открыть во вкладке «Заказы».`);return;} if(existing){message(`Пробный заказ №${existing.id} уже создан. Его можно открыть во вкладке «Заказы».`);return;}

View File

@ -1,4 +1,4 @@
<!doctype html><html lang="ru"><head><link rel="stylesheet" href="core/login-signature-v1776.css?v=20260918-ui-stability"><script src="core/cloud-transport.js?v=20260918-ui-stability"></script><script src="core/banquet-menu.js?v=20260918-ui-stability-20260919-client-menu"></script><script src="core/access-policy.js?v=20260918-ui-stability"></script><style>body.sun-cloud-auth-required>header,body.sun-cloud-auth-required>.view{visibility:hidden!important;pointer-events:none!important}</style><meta charset="utf-8"><script defer src="core/import-archive.js?v=20260918-ui-stability"></script><script src="core/company-branding.js?v=20260918-ui-stability"></script><script src="core/brand-theme.js?v=20260918-ui-stability"></script><script id="sun-startup-stability-guard"> <!doctype html><html lang="ru"><head><script src="core/training-catalog.js?v=20260920-training"></script><link rel="stylesheet" href="core/login-signature-v1776.css?v=20260918-ui-stability"><script src="core/cloud-transport.js?v=20260918-ui-stability"></script><script src="core/banquet-menu.js?v=20260918-ui-stability-20260919-client-menu"></script><script src="core/access-policy.js?v=20260918-ui-stability"></script><style>body.sun-cloud-auth-required>header,body.sun-cloud-auth-required>.view{visibility:hidden!important;pointer-events:none!important}</style><meta charset="utf-8"><script defer src="core/import-archive.js?v=20260918-ui-stability"></script><script src="core/company-branding.js?v=20260918-ui-stability"></script><script src="core/brand-theme.js?v=20260918-ui-stability"></script><script id="sun-startup-stability-guard">
(()=>{ (()=>{
'use strict'; 'use strict';
const stamp=()=>new Date().toISOString().replace(/[:.]/g,'-'); const stamp=()=>new Date().toISOString().replace(/[:.]/g,'-');
@ -261,7 +261,7 @@ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID !== 'function') {
} }
function activeItems() { function activeItems() {
return boxes.filter(item => item.hidden !== true && Number(item.category || 0) === activeCategory); return boxes.filter(item => (window.CateriumTrainingCatalog?.visible(item)??(item.hidden !== true)) && Number(item.category || 0) === activeCategory);
} }
function syncCategoryUI() { function syncCategoryUI() {
@ -1096,7 +1096,7 @@ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID !== 'function') {
function supplierOptions(selected = '') { function supplierOptions(selected = '') {
const current = String(selected || ''); const current = String(selected || '');
const list = getSuppliers().filter(s => s.active !== false || s.id === current).sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'ru')); const list = getSuppliers().filter(s => s.id===current||(trainingVisible(s,'suppliers')&&s.active!==false)).sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'ru'));
return `<option value="">Без поставщика</option>${list.map(s => `<option value="${stockEsc(s.id)}" ${s.id === current ? 'selected' : ''}>${stockEsc(s.name || 'Без названия')}${s.active === false ? ' · неактивен' : ''}</option>`).join('')}`; return `<option value="">Без поставщика</option>${list.map(s => `<option value="${stockEsc(s.id)}" ${s.id === current ? 'selected' : ''}>${stockEsc(s.name || 'Без названия')}${s.active === false ? ' · неактивен' : ''}</option>`).join('')}`;
} }
@ -1110,17 +1110,18 @@ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID !== 'function') {
return `${String(name || '').trim().toLowerCase()}|${String(unit || '').trim().toLowerCase()}`; return `${String(name || '').trim().toLowerCase()}|${String(unit || '').trim().toLowerCase()}`;
} }
function trainingVisible(row,kind='stock') { return window.CateriumTrainingCatalog?.visible(row,kind)??row?.hidden!==true; }
function syncProductsFromCatalog() { function syncProductsFromCatalog() {
const existing = new Map(stock.map(p => [skuKey(p.name, p.unit), p])); const existing = new Map(stock.map(p => [skuKey(p.name, p.unit), p]));
let changed = false; let changed = false;
boxes.forEach(item => { boxes.filter(item=>trainingVisible(item,'catalog')).forEach(item => {
(item.ingredients || []).forEach(part => { (item.ingredients || []).forEach(part => {
const name = String(part[0] || '').trim(); const name = String(part[0] || '').trim();
const unit = String(part[2] || 'шт.').trim() || 'шт.'; const unit = String(part[2] || 'шт.').trim() || 'шт.';
if (!name) return; if (!name) return;
const key = skuKey(name, unit); const key = skuKey(name, unit);
if (!existing.has(key)) { if (!existing.has(key)) {
const product = { id: sunUUID(), name, unit, qty: 0, cost: 0, min: 0, supplierId: '', lastPurchaseDate: '', lastPurchasePrice: 0 }; const product = { id: sunUUID(), name, unit, qty: 0, cost: 0, min: 0, supplierId: '', lastPurchaseDate: '', lastPurchasePrice: 0, trainingCatalog:window.CateriumTrainingCatalog?.isSample(item)===true };
stock.push(product); stock.push(product);
existing.set(key, product); existing.set(key, product);
changed = true; changed = true;
@ -1132,7 +1133,7 @@ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID !== 'function') {
// Normalize legacy values. // Normalize legacy values.
stock = stock.map(p => ({ stock = stock.map(p => ({
id: p.id || sunUUID(), name: String(p.name || 'Без названия'), unit: String(p.unit || 'шт.'), ...p, id: p.id || sunUUID(), name: String(p.name || 'Без названия'), unit: String(p.unit || 'шт.'),
qty: Number(p.qty || 0), cost: Number(p.cost || 0), min: Number(p.min || 0), qty: Number(p.qty || 0), cost: Number(p.cost || 0), min: Number(p.min || 0),
supplierId: String(p.supplierId || ''), lastPurchaseDate: String(p.lastPurchaseDate || ''), lastPurchasePrice: Number(p.lastPurchasePrice || 0) supplierId: String(p.supplierId || ''), lastPurchaseDate: String(p.lastPurchaseDate || ''), lastPurchasePrice: Number(p.lastPurchasePrice || 0)
})); }));
@ -1210,15 +1211,16 @@ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID !== 'function') {
} }
function renderStockSummary() { function renderStockSummary() {
const low = stock.filter(p => Number(p.qty) <= Number(p.min) && Number(p.min) > 0).length; const shownStock=stock.filter(p=>trainingVisible(p));
const zero = stock.filter(p => Number(p.qty) <= 0).length; const low = shownStock.filter(p => Number(p.qty) <= Number(p.min) && Number(p.min) > 0).length;
const value = stock.reduce((sum, p) => sum + Number(p.qty || 0) * Number(p.cost || 0), 0); const zero = shownStock.filter(p => Number(p.qty) <= 0).length;
const value = shownStock.reduce((sum, p) => sum + Number(p.qty || 0) * Number(p.cost || 0), 0);
const need = aggregateNeed().reduce((sum, n) => { const need = aggregateNeed().reduce((sum, n) => {
const p = productByKey(n.name, n.unit); const p = productByKey(n.name, n.unit);
return sum + Math.max(0, n.qty - Number(p?.qty || 0)) * Number(p?.cost || 0); return sum + Math.max(0, n.qty - Number(p?.qty || 0)) * Number(p?.cost || 0);
}, 0); }, 0);
document.getElementById('stockSummary').innerHTML = ` document.getElementById('stockSummary').innerHTML = `
<div class="summary-box"><small>Позиций на складе</small><b>${stock.length}</b></div> <div class="summary-box"><small>Позиций на складе</small><b>${shownStock.length}</b></div>
<div class="summary-box"><small>Нулевой / минусовой остаток</small><b>${zero}</b></div> <div class="summary-box"><small>Нулевой / минусовой остаток</small><b>${zero}</b></div>
<div class="summary-box"><small>Стоимость остатков</small><b>${fmt(value)}</b></div> <div class="summary-box"><small>Стоимость остатков</small><b>${fmt(value)}</b></div>
<div class="summary-box"><small>Ориентир закупки под заказы</small><b>${fmt(need)}</b></div>`; <div class="summary-box"><small>Ориентир закупки под заказы</small><b>${fmt(need)}</b></div>`;
@ -1227,7 +1229,7 @@ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID !== 'function') {
function renderBalances(filter = '') { function renderBalances(filter = '') {
const q = String(filter || '').trim().toLowerCase(); const q = String(filter || '').trim().toLowerCase();
const supplierMap = new Map(getSuppliers().map(s => [String(s.id), s])); const supplierMap = new Map(getSuppliers().map(s => [String(s.id), s]));
const list = stock.filter(p => { const list = stock.filter(p=>trainingVisible(p)).filter(p => {
const supplier = supplierMap.get(String(p.supplierId || '')); const supplier = supplierMap.get(String(p.supplierId || ''));
const haystack = `${p.name} ${p.unit} ${supplier?.name || ''} ${supplier?.phone || ''}`.toLowerCase(); const haystack = `${p.name} ${p.unit} ${supplier?.name || ''} ${supplier?.phone || ''}`.toLowerCase();
return !q || haystack.includes(q); return !q || haystack.includes(q);
@ -1436,6 +1438,10 @@ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID !== 'function') {
} }
window.sunRefreshStock = renderStock; window.sunRefreshStock = renderStock;
window.addEventListener('caterium:training-catalog-changed',()=>{
try{const next=JSON.parse(localStorage.getItem(STOCK_KEY)||'[]');if(Array.isArray(next))stock=next;}catch(_){return;}
if(stockView.classList.contains('on'))renderStock();
});
// Cloud hydration replaces localStorage after this module has booted. // Cloud hydration replaces localStorage after this module has booted.
// Refresh the closure before edits/receipts can write an older inventory back. // Refresh the closure before edits/receipts can write an older inventory back.
window.addEventListener('sun:cloud-state-applied', () => { window.addEventListener('sun:cloud-state-applied', () => {
@ -2728,7 +2734,7 @@ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID !== 'function') {
const fmt=v=>typeof money==='function'?money(v):`${Number(v||0).toLocaleString('ru-RU',{maximumFractionDigits:2})} ₽`; const fmt=v=>typeof money==='function'?money(v):`${Number(v||0).toLocaleString('ru-RU',{maximumFractionDigits:2})} ₽`;
const dateLabel=v=>v?new Date(`${v}T12:00:00`).toLocaleDateString('ru-RU'):'—'; const dateLabel=v=>v?new Date(`${v}T12:00:00`).toLocaleDateString('ru-RU'):'—';
const readArray=(key)=>{try{const x=JSON.parse(localStorage.getItem(key)||'[]');return Array.isArray(x)?x:[];}catch{return[];}}; const readArray=(key)=>{try{const x=JSON.parse(localStorage.getItem(key)||'[]');return Array.isArray(x)?x:[];}catch{return[];}};
let suppliers=readArray(KEY).map(s=>({id:String(s.id||sunUUID()),name:String(s.name||'Без названия'),category:String(s.category||''),contact:String(s.contact||''),phone:String(s.phone||''),email:String(s.email||''),address:String(s.address||''),deliveryDays:String(s.deliveryDays||''),minOrder:Number(s.minOrder||0),notes:String(s.notes||''),active:s.active!==false,createdAt:s.createdAt||new Date().toISOString()})); let suppliers=readArray(KEY).map(s=>({...s,id:String(s.id||sunUUID()),name:String(s.name||'Без названия'),category:String(s.category||''),contact:String(s.contact||''),phone:String(s.phone||''),email:String(s.email||''),address:String(s.address||''),deliveryDays:String(s.deliveryDays||''),minOrder:Number(s.minOrder||0),notes:String(s.notes||''),active:s.active!==false,createdAt:s.createdAt||new Date().toISOString()}));
let editingId=null; let editingId=null;
function saveSuppliers(){ function saveSuppliers(){
@ -2770,11 +2776,12 @@ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID !== 'function') {
const el=id=>document.getElementById(id); const el=id=>document.getElementById(id);
function render(){ function render(){
const stock=getStock(), moves=getMoves(), linked=stock.filter(p=>p.supplierId).length, unassigned=stock.filter(p=>!p.supplierId).length, active=suppliers.filter(s=>s.active!==false).length; const shownSuppliers=suppliers.filter(s=>window.CateriumTrainingCatalog?.visible(s,'suppliers')??true);
const stock=getStock().filter(p=>window.CateriumTrainingCatalog?.visible(p,'stock')??true), moves=getMoves(), linked=stock.filter(p=>p.supplierId).length, unassigned=stock.filter(p=>!p.supplierId).length, active=shownSuppliers.filter(s=>s.active!==false).length;
const purchaseTotal=moves.filter(m=>m.type==='in'&&m.supplierId).reduce((s,m)=>s+Math.abs(Number(m.qty||0))*Number(m.cost||0),0); const purchaseTotal=moves.filter(m=>m.type==='in'&&m.supplierId).reduce((s,m)=>s+Math.abs(Number(m.qty||0))*Number(m.cost||0),0);
el('supSummary').innerHTML=`<div><small>Всего поставщиков</small><b>${suppliers.length}</b></div><div><small>Активных</small><b>${active}</b></div><div><small>Продуктов привязано</small><b>${linked}</b></div><div><small>Без поставщика</small><b>${unassigned}</b></div><div><small>Закупок по истории</small><b>${fmt(purchaseTotal)}</b></div>`; el('supSummary').innerHTML=`<div><small>Всего поставщиков</small><b>${shownSuppliers.length}</b></div><div><small>Активных</small><b>${active}</b></div><div><small>Продуктов привязано</small><b>${linked}</b></div><div><small>Без поставщика</small><b>${unassigned}</b></div><div><small>Закупок по истории</small><b>${fmt(purchaseTotal)}</b></div>`;
const q=(el('supSearch').value||'').trim().toLowerCase(), filter=el('supFilter').value; const q=(el('supSearch').value||'').trim().toLowerCase(), filter=el('supFilter').value;
const list=suppliers.filter(s=>(filter==='all'||(filter==='active'&&s.active!==false)||(filter==='inactive'&&s.active===false))&&(!q||`${s.name} ${s.category} ${s.contact} ${s.phone} ${s.email} ${s.address}`.toLowerCase().includes(q))).sort((a,b)=>(a.active===b.active?String(a.name).localeCompare(String(b.name),'ru'):(a.active===false?1:-1))); const list=shownSuppliers.filter(s=>(filter==='all'||(filter==='active'&&s.active!==false)||(filter==='inactive'&&s.active===false))&&(!q||`${s.name} ${s.category} ${s.contact} ${s.phone} ${s.email} ${s.address}`.toLowerCase().includes(q))).sort((a,b)=>(a.active===b.active?String(a.name).localeCompare(String(b.name),'ru'):(a.active===false?1:-1)));
const cards=list.map(s=>{ const cards=list.map(s=>{
const products=stock.filter(p=>String(p.supplierId||'')===String(s.id)), stats=purchaseStats(s.id), phoneHref=String(s.phone||'').replace(/[^\d+]/g,''); const products=stock.filter(p=>String(p.supplierId||'')===String(s.id)), stats=purchaseStats(s.id), phoneHref=String(s.phone||'').replace(/[^\d+]/g,'');
return `<article class="sup-card ${s.active===false?'inactive':''}"><div class="sup-card-head"><div><h3>${esc(s.name)}</h3><small class="hint">${esc(s.category||'Категория не указана')}</small></div><span class="sup-badge ${s.active===false?'off':''}">${s.active===false?'Неактивен':'Активен'}</span></div><div class="sup-meta">${s.contact?`<span>Контакт: <b>${esc(s.contact)}</b></span>`:''}${s.phone?`<span>Телефон: <a href="tel:${esc(phoneHref)}">${esc(s.phone)}</a></span>`:''}${s.email?`<span>Email: <a href="mailto:${esc(s.email)}">${esc(s.email)}</a></span>`:''}${s.address?`<span>Адрес: ${esc(s.address)}</span>`:''}${s.deliveryDays?`<span>Доставка: ${esc(s.deliveryDays)}</span>`:''}${Number(s.minOrder||0)>0?`<span>Минимальный заказ: <b>${fmt(s.minOrder)}</b></span>`:''}</div><div class="sup-stats"><div><small>Продуктов</small><b>${products.length}</b></div><div><small>Закупок</small><b>${stats.count}</b></div><div><small>Последняя</small><b>${dateLabel(stats.last)}</b></div></div>${s.notes?`<div class="sup-notes">${esc(s.notes)}</div>`:''}<div class="sup-actions"><button class="outline" type="button" onclick="sunSupplierProducts('${esc(s.id)}')">Продукты</button><button class="primary" type="button" onclick="sunEditSupplier('${esc(s.id)}')">Изменить</button></div></article>`; return `<article class="sup-card ${s.active===false?'inactive':''}"><div class="sup-card-head"><div><h3>${esc(s.name)}</h3><small class="hint">${esc(s.category||'Категория не указана')}</small></div><span class="sup-badge ${s.active===false?'off':''}">${s.active===false?'Неактивен':'Активен'}</span></div><div class="sup-meta">${s.contact?`<span>Контакт: <b>${esc(s.contact)}</b></span>`:''}${s.phone?`<span>Телефон: <a href="tel:${esc(phoneHref)}">${esc(s.phone)}</a></span>`:''}${s.email?`<span>Email: <a href="mailto:${esc(s.email)}">${esc(s.email)}</a></span>`:''}${s.address?`<span>Адрес: ${esc(s.address)}</span>`:''}${s.deliveryDays?`<span>Доставка: ${esc(s.deliveryDays)}</span>`:''}${Number(s.minOrder||0)>0?`<span>Минимальный заказ: <b>${fmt(s.minOrder)}</b></span>`:''}</div><div class="sup-stats"><div><small>Продуктов</small><b>${products.length}</b></div><div><small>Закупок</small><b>${stats.count}</b></div><div><small>Последняя</small><b>${dateLabel(stats.last)}</b></div></div>${s.notes?`<div class="sup-notes">${esc(s.notes)}</div>`:''}<div class="sup-actions"><button class="outline" type="button" onclick="sunSupplierProducts('${esc(s.id)}')">Продукты</button><button class="primary" type="button" onclick="sunEditSupplier('${esc(s.id)}')">Изменить</button></div></article>`;
@ -2816,6 +2823,7 @@ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID !== 'function') {
btn.onclick=()=>{document.querySelectorAll('.view').forEach(v=>v.classList.remove('on'));view.classList.add('on');[...nav.querySelectorAll('button')].forEach(b=>b.classList.remove('on'));btn.classList.add('on');render();}; btn.onclick=()=>{document.querySelectorAll('.view').forEach(v=>v.classList.remove('on'));view.classList.add('on');[...nav.querySelectorAll('button')].forEach(b=>b.classList.remove('on'));btn.classList.add('on');render();};
window.addEventListener('sun:stock-changed',()=>{if(view.classList.contains('on'))render();}); window.addEventListener('sun:stock-changed',()=>{if(view.classList.contains('on'))render();});
window.sunRenderSuppliers=render; window.sunRenderSuppliers=render;
window.addEventListener('caterium:training-catalog-changed',()=>{suppliers=readArray(KEY);if(view.classList.contains('on'))render();});
render(); render();
})(); })();
@ -4955,13 +4963,13 @@ window.SunQR = {
if (orderMatches.length) groups.push({name:'Заказы', items:orderMatches}); if (orderMatches.length) groups.push({name:'Заказы', items:orderMatches});
const clientMatches = clientIndex().filter(c => [c.name,c.phone,...c.addresses].join(' ').toLowerCase().includes(q)).slice(0,10).map(c => ({title:c.name, meta:`${c.phone || 'без телефона'} · заказов: ${c.orders.length}`, action:'client', id:encodeURIComponent(c.key)})); const clientMatches = clientIndex().filter(c => [c.name,c.phone,...c.addresses].join(' ').toLowerCase().includes(q)).slice(0,10).map(c => ({title:c.name, meta:`${c.phone || 'без телефона'} · заказов: ${c.orders.length}`, action:'client', id:encodeURIComponent(c.key)}));
if (clientMatches.length) groups.push({name:'Клиенты', items:clientMatches}); if (clientMatches.length) groups.push({name:'Клиенты', items:clientMatches});
const boxMatches = appBoxes().filter(b => b.hidden !== true && [b.name,b.catalogSection,(b.ingredients||[]).map(x=>x[0]).join(' ')].join(' ').toLowerCase().includes(q)).slice(0,12).map(b=>({title:b.name,meta:`${moneyFmt(window.CateriumPricing.price(b))} · ${b.catalogSection || 'каталог'}`,action:'box',id:b.id})); const boxMatches = appBoxes().filter(b => (window.CateriumTrainingCatalog?.visible(b)??(b.hidden !== true)) && [b.name,b.catalogSection,(b.ingredients||[]).map(x=>x[0]).join(' ')].join(' ').toLowerCase().includes(q)).slice(0,12).map(b=>({title:b.name,meta:`${moneyFmt(window.CateriumPricing.price(b))} · ${b.catalogSection || 'каталог'}`,action:'box',id:b.id}));
if (boxMatches.length) groups.push({name:'Каталог',items:boxMatches}); if (boxMatches.length) groups.push({name:'Каталог',items:boxMatches});
const stockMatches = getStock().filter(p => [p.name,p.unit].join(' ').toLowerCase().includes(q)).slice(0,12).map(p=>({title:p.name,meta:`Остаток ${numFmt(p.qty)} ${p.unit} · ${moneyFmt(p.cost)}`,action:'stock',id:p.id})); const stockMatches = getStock().filter(p=>window.CateriumTrainingCatalog?.visible(p,'stock')??true).filter(p => [p.name,p.unit].join(' ').toLowerCase().includes(q)).slice(0,12).map(p=>({title:p.name,meta:`Остаток ${numFmt(p.qty)} ${p.unit} · ${moneyFmt(p.cost)}`,action:'stock',id:p.id}));
if (stockMatches.length) groups.push({name:'Склад',items:stockMatches}); if (stockMatches.length) groups.push({name:'Склад',items:stockMatches});
const employeeMatches = getEmployees().filter(e=>[e.name,e.role,e.phone,e.note].join(' ').toLowerCase().includes(q)).slice(0,10).map(e=>({title:e.name,meta:`${e.role || 'сотрудник'} · ${e.phone || 'без телефона'}`,action:'employee',id:e.id})); const employeeMatches = getEmployees().filter(e=>[e.name,e.role,e.phone,e.note].join(' ').toLowerCase().includes(q)).slice(0,10).map(e=>({title:e.name,meta:`${e.role || 'сотрудник'} · ${e.phone || 'без телефона'}`,action:'employee',id:e.id}));
if(employeeMatches.length)groups.push({name:'Команда',items:employeeMatches}); if(employeeMatches.length)groups.push({name:'Команда',items:employeeMatches});
const supplierMatches = getSuppliers().filter(s=>[s.name,s.contact,s.phone,s.email,s.address].join(' ').toLowerCase().includes(q)).slice(0,10).map(s=>({title:s.name,meta:`${s.contact || ''} ${s.phone || ''}`.trim() || 'поставщик',action:'supplier',id:s.id})); const supplierMatches = getSuppliers().filter(s=>window.CateriumTrainingCatalog?.visible(s,'suppliers')??true).filter(s=>[s.name,s.contact,s.phone,s.email,s.address].join(' ').toLowerCase().includes(q)).slice(0,10).map(s=>({title:s.name,meta:`${s.contact || ''} ${s.phone || ''}`.trim() || 'поставщик',action:'supplier',id:s.id}));
if(supplierMatches.length)groups.push({name:'Поставщики',items:supplierMatches}); if(supplierMatches.length)groups.push({name:'Поставщики',items:supplierMatches});
return groups; return groups;
} }
@ -7684,7 +7692,7 @@ button.sun-busy-safe:after{content:"";width:12px;height:12px;margin-left:7px;bor
return Object.entries(map).find(([name])=>title.startsWith(name))?.[1]??0; return Object.entries(map).find(([name])=>title.startsWith(name))?.[1]??0;
} }
function categoryName(cat){return ({0:'Боксы',1:'Посуда',2:'Дополнения',3:'Напитки',4:'Доставка',5:'Премиум'})[cat]||'Каталог'} function categoryName(cat){return ({0:'Боксы',1:'Посуда',2:'Дополнения',3:'Напитки',4:'Доставка',5:'Премиум'})[cat]||'Каталог'}
function getCatalogItems(cat){try{return (Array.isArray(boxes)?boxes:[]).filter(x=>x.hidden!==true&&Number(x.category||0)===Number(cat))}catch(_){return[]}} function getCatalogItems(cat){try{return (Array.isArray(boxes)?boxes:[]).filter(x=>(window.CateriumTrainingCatalog?.visible(x)??(x.hidden!==true))&&Number(x.category||0)===Number(cat))}catch(_){return[]}}
function ensureCatalogPdfButton(){ function ensureCatalogPdfButton(){
const head=document.querySelector('#new .catalog .catalog-head');const configure=qa('button',head||document).find(b=>b.textContent.trim()==='Настроить');if(!head||!configure)return; const head=document.querySelector('#new .catalog .catalog-head');const configure=qa('button',head||document).find(b=>b.textContent.trim()==='Настроить');if(!head||!configure)return;
let btn=$('sunCatalogPdfButton');if(!btn){btn=document.createElement('button');btn.id='sunCatalogPdfButton';btn.type='button';btn.className='outline sun-catalog-pdf-button';btn.textContent='Скачать PDF';btn.title='Текущий раздел: одна позиция на одной странице';configure.insertAdjacentElement('beforebegin',btn);} let btn=$('sunCatalogPdfButton');if(!btn){btn=document.createElement('button');btn.id='sunCatalogPdfButton';btn.type='button';btn.className='outline sun-catalog-pdf-button';btn.textContent='Скачать PDF';btn.title='Текущий раздел: одна позиция на одной странице';configure.insertAdjacentElement('beforebegin',btn);}
@ -8455,7 +8463,7 @@ body #sunBlankSettingsCardV3{display:none!important}
const hide=currentCategory()===4;btn.hidden=hide;btn.style.display=hide?'none':''; const hide=currentCategory()===4;btn.hidden=hide;btn.style.display=hide?'none':'';
} }
async function printLiveCatalog(){ async function printLiveCatalog(){
const cat=currentCategory(),items=allBoxes().filter(item=>item.hidden!==true&&Number(item.category||0)===Number(cat)); const cat=currentCategory(),items=allBoxes().filter(item=>(window.CateriumTrainingCatalog?.visible(item)??(item.hidden!==true))&&Number(item.category||0)===Number(cat));
if(!items.length){window.SunEnterprise?.toast?.('\u0412 \u044d\u0442\u043e\u043c \u0440\u0430\u0437\u0434\u0435\u043b\u0435 \u043d\u0435\u0442 \u043f\u043e\u0437\u0438\u0446\u0438\u0439.','warn');return} if(!items.length){window.SunEnterprise?.toast?.('\u0412 \u044d\u0442\u043e\u043c \u0440\u0430\u0437\u0434\u0435\u043b\u0435 \u043d\u0435\u0442 \u043f\u043e\u0437\u0438\u0446\u0438\u0439.','warn');return}
let root=$('sunLiveCatalogPrintRoot');if(!root){root=document.createElement('div');root.id='sunLiveCatalogPrintRoot';document.body.appendChild(root)} let root=$('sunLiveCatalogPrintRoot');if(!root){root=document.createElement('div');root.id='sunLiveCatalogPrintRoot';document.body.appendChild(root)}
const total=items.length,dateText=new Date().toLocaleDateString('ru-RU'); const total=items.length,dateText=new Date().toLocaleDateString('ru-RU');

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,7 @@
const CACHE='sun-catering-pwa-v110-20260918-ui-stability-20260919-client-menu-support-bot'; const CACHE='sun-catering-pwa-v110-20260918-ui-stability-20260919-client-menu-support-bot-training-catalog';
const VERSION='20260918-ui-stability'; const VERSION='20260918-ui-stability';
const CORE=[ const CORE=[
'./core/training-catalog.js?v=20260920-training',
'./core/catalog-pricing.js?v=20260919-client-menu','./core/client-menu.css?v=20260919-client-menu', './core/catalog-pricing.js?v=20260919-client-menu','./core/client-menu.css?v=20260919-client-menu',
'./vendor/supabase-2.112.4.min.js', './vendor/supabase-2.112.4.min.js',
`./core/help-center.js?v=${VERSION}`,`./core/help-center.css?v=${VERSION}`,`./help/knowledge-v1.json?v=${VERSION}`, `./core/help-center.js?v=${VERSION}`,`./core/help-center.css?v=${VERSION}`,`./help/knowledge-v1.json?v=${VERSION}`,
@ -12,6 +13,7 @@ const CORE=[
'./offer-templates/thumb-light.jpg','./offer-templates/thumb-editorial-grid.jpg','./offer-templates/thumb-midnight-glass.jpg','./offer-templates/thumb-emerald-gold.jpg' './offer-templates/thumb-light.jpg','./offer-templates/thumb-editorial-grid.jpg','./offer-templates/thumb-midnight-glass.jpg','./offer-templates/thumb-emerald-gold.jpg'
]; ];
const CRITICAL_FRESH=new Set([ const CRITICAL_FRESH=new Set([
'/core/training-catalog.js',
'/core/catalog-pricing.js','/core/client-menu.css', '/core/catalog-pricing.js','/core/client-menu.css',
'/core/help-center.js','/core/help-center.css','/help/knowledge-v1.json', '/core/help-center.js','/core/help-center.css','/help/knowledge-v1.json',
'/core/mobile-order.js','/core/cloud-transport.js','/core/trial-demo.js','/core/proposal-layout.js', '/core/mobile-order.js','/core/cloud-transport.js','/core/trial-demo.js','/core/proposal-layout.js',

View File

@ -2,13 +2,13 @@ import { defineConfig, devices } from '@playwright/test';
import {fileURLToPath} from 'node:url'; import {fileURLToPath} from 'node:url';
export default defineConfig({ export default defineConfig({
testDir:'.', testDir:'.',
testMatch:['mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs','banquet-menu.spec.mjs','calendar-print.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','trial-demo.spec.mjs','proposal-quality.spec.mjs'], testMatch:['training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs','banquet-menu.spec.mjs','calendar-print.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','trial-demo.spec.mjs','proposal-quality.spec.mjs'],
timeout:30000, timeout:30000,
use:{baseURL:'http://127.0.0.1:4173'}, use:{baseURL:'http://127.0.0.1:4173'},
webServer:{command:'npx http-server public -p 4173 -c-1',cwd:fileURLToPath(new URL('../',import.meta.url)),port:4173,reuseExistingServer:true}, webServer:{command:'npx http-server public -p 4173 -c-1',cwd:fileURLToPath(new URL('../',import.meta.url)),port:4173,reuseExistingServer:true},
projects:[ projects:[
{name:'iphone-pdf',testMatch:['proposal-quality.spec.mjs'],grep:/all six selections|transparent wide|an actual offer downloads/,use:{...devices['iPhone 13'],serviceWorkers:'block'}}, {name:'iphone-pdf',testMatch:['proposal-quality.spec.mjs'],grep:/all six selections|transparent wide|an actual offer downloads/,use:{...devices['iPhone 13'],serviceWorkers:'block'}},
{name:'iphone-webkit',testMatch:['mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','account-access.spec.mjs','calendar-print.spec.mjs'],use:{...devices['iPhone 13'],serviceWorkers:'block'}}, {name:'iphone-webkit',testMatch:['training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','account-access.spec.mjs','calendar-print.spec.mjs'],use:{...devices['iPhone 13'],serviceWorkers:'block'}},
{name:'desktop',use:{...devices['Desktop Chrome']}}, {name:'desktop',use:{...devices['Desktop Chrome']}},
{name:'mobile-390',use:{viewport:{width:390,height:844},isMobile:true,hasTouch:true}} {name:'mobile-390',use:{viewport:{width:390,height:844},isMobile:true,hasTouch:true}}
] ]

View File

@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import {chromium,expect} from '@playwright/test';
import {trainingInit} from './training-catalog-fixture.mjs';
// Exercise the actual published assets with a synthetic profile. All traffic
// except same-origin static GETs is blocked, so no real workspace is touched.
const base=new URL(process.env.TIMEWEB_BASE_URL||'https://app.caterium.ru');
if(base.protocol!=='https:')throw new Error('Published UI verification requires HTTPS');
const output='production-ui-results';await fs.mkdir(output,{recursive:true});
const browser=await chromium.launch(),results=[];
try{
for(const width of [390,1440]){
const context=await browser.newContext({viewport:{width,height:900},serviceWorkers:'block'});
try{
await context.route('**/*',route=>{const r=route.request(),u=new URL(r.url());return r.method()==='GET'&&u.origin===base.origin&&!u.pathname.startsWith('/api/')?route.continue():route.abort();});
await context.addInitScript(trainingInit);
const page=await context.newPage();page.on('dialog',d=>d.accept());
await page.goto(base.href,{waitUntil:'domcontentloaded',timeout:60000});
await expect(page.locator('body > header')).toBeVisible({timeout:20000});
await page.waitForFunction(()=>window.CateriumDataV1773&&window.SunOpsUXV1762&&window.CateriumTrialDemo&&window.CateriumTrainingCatalog);
const nav=page.locator('header nav'),toggle=page.locator('#ctTrainingEnabled');
const settings=async()=>{await nav.getByRole('button',{name:'Настройки',exact:true}).click();await expect(toggle).toBeVisible();};
await settings();await expect(toggle).not.toBeChecked();
const before=await page.evaluate(()=>({orders:localStorage.sunOrders,box:boxes.find(b=>b.id==='own-box'),stock:JSON.parse(localStorage.sunStock).find(p=>p.id==='own-stock')}));
await toggle.check();await expect(toggle).toBeEnabled();
await expect.poll(()=>page.evaluate(()=>CateriumTrainingCatalog.enabled())).toBe(true);
assert.equal(await page.evaluate(()=>boxes.length),45);
await page.locator('#ctTrainingSettings').scrollIntoViewIfNeeded();
await page.screenshot({path:`${output}/training-settings-${width}.png`,animations:'disabled'});
await nav.getByRole('button',{name:'Меню',exact:true}).click();await expect(page.locator('[data-menu-item-v1762]')).toHaveCount(11);
await expect(page.locator('[data-menu-item-v1762="demo-v1-bruschetta-tomato"] .ct-training-badge')).toHaveText('Учебное');
await page.screenshot({path:`${output}/training-catalog-${width}.png`,animations:'disabled'});
await settings();await toggle.uncheck();await expect(toggle).toBeEnabled();
await expect.poll(()=>page.evaluate(()=>CateriumTrainingCatalog.enabled())).toBe(false);
await nav.getByRole('button',{name:'Меню',exact:true}).click();await expect(page.locator('[data-menu-item-v1762]')).toHaveCount(1);
const after=await page.evaluate(()=>({orders:localStorage.sunOrders,box:boxes.find(b=>b.id==='own-box'),stock:JSON.parse(localStorage.sunStock).find(p=>p.id==='own-stock')}));
assert.deepEqual(after,before);
assert.equal(await page.evaluate(()=>boxes.length),45);
await settings();await toggle.check();await expect(toggle).toBeEnabled();
assert.equal(await page.evaluate(()=>boxes.length),45);
results.push({width,defaultOff:true,readyExamples:44,hideRestoresOwnCatalog:true,repeatDoesNotDuplicate:true,ordersAndOwnDataPreserved:true});
console.log(`PASS published training catalog ${width}px: opt-in, complete examples, hide/show, preserved own data and orders`);
}finally{await context.close();}
}
await fs.writeFile(`${output}/training-catalog-result.json`,JSON.stringify({base:base.href,checkedAt:new Date().toISOString(),backend:'synthetic, network-blocked',results},null,2));
}finally{await browser.close();}

View File

@ -0,0 +1,17 @@
export function trainingInit(){
const ws='training-workspace',user={id:'training-user',email:'training@example.invalid',email_confirmed_at:'2026-01-01T00:00:00Z'};
localStorage.setItem('sunCloudV2Config',JSON.stringify({workspaceId:ws,localWorkspaceId:ws,tenantStorageReady:true,autoSync:false}));
if(!localStorage.getItem('training-fixture-ready')){
localStorage.setItem('sunBoxes',JSON.stringify([{id:'own-box',name:'Мой настоящий бокс',category:0,price:1700,weight:'900 г',pieces:12,ingredients:[['Томаты',1,'кг']]}]));
localStorage.setItem('sunOrders',JSON.stringify([{id:1,event:'Настоящий заказ',date:'2099-12-20',time:'12:00',status:'Новый',lines:[{id:'own-box',qty:2,price:1500}],total:3000}]));
localStorage.setItem('sunStock',JSON.stringify([{id:'own-stock',name:'Томаты',unit:'кг',cost:380,qty:12,min:1,supplierId:'own-supplier'}]));
localStorage.setItem('sunSuppliers',JSON.stringify([{id:'own-supplier',name:'Мой поставщик',active:true,phone:'70000000000'}]));
localStorage.setItem('training-fixture-ready','1');
}
const features=Object.fromEntries('orders calendar 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'.split(' ').map(k=>[k,true]));
window.supabase={createClient:()=>({
auth:{onAuthStateChange:()=>({data:{subscription:{unsubscribe(){}}}}),getSession:async()=>({data:{session:{user}},error:null}),getUser:async()=>({data:{user},error:null})},
rpc:async name=>({data:name==='sun_my_workspaces'?[{id:ws,name:'Тестовая компания',role:'admin',is_active:true,permissions:{}}]:name==='sun_subscription_snapshot'?{plan_id:'full',plan_name:'Полный',status:'active',access_mode:'full',features}:name==='sun_is_platform_admin'?false:name==='caterium_trial_demo_status'?{canInstall:false,canUpgrade:false}:null,error:null}),
channel:()=>({on(){return this},subscribe(){return this}}),removeChannel(){}
})};
}

View File

@ -0,0 +1,96 @@
import {test,expect} from '@playwright/test';
import {trainingInit} from './training-catalog-fixture.mjs';
import {demo} from '../ops/demo/trial-demo-data.mjs';
test.use({serviceWorkers:'block'});
const nav=page=>page.locator('header nav');
const setting=page=>page.locator('#ctTrainingEnabled');
async function settings(page){await nav(page).getByRole('button',{name:'Настройки',exact:true}).click();await expect(setting(page)).toBeVisible();}
async function boot(page){
page.on('dialog',d=>d.accept());
await page.route('**/*',route=>{const r=route.request(),u=new URL(r.url());return r.method()==='GET'&&u.origin==='http://127.0.0.1:4173'&&!u.pathname.startsWith('/api/')?route.continue():route.abort();});
await page.addInitScript(trainingInit);
await page.goto('/index.html',{waitUntil:'domcontentloaded'});
await page.waitForFunction(()=>window.CateriumDataV1773&&window.SunOpsUXV1762&&window.CateriumTrialDemo&&window.__cateriumOrderEnhancementsV1775);
await expect(page.locator('body > header')).toBeVisible();await settings(page);
}
async function toggle(page,on){await setting(page).setChecked(on);await expect(setting(page)).toBeEnabled();await expect.poll(()=>page.evaluate(()=>CateriumTrainingCatalog.enabled())).toBe(on);}
const state=page=>page.evaluate(()=>Object.fromEntries(['sunBoxes','sunStock','sunSuppliers','sunOrders','sunStockMoves','sunTrialDemoV1'].map(k=>[k,localStorage.getItem(k)])));
const own=page=>page.evaluate(()=>({box:JSON.parse(localStorage.sunBoxes).find(x=>x.id==='own-box'),stock:JSON.parse(localStorage.sunStock).find(x=>x.id==='own-stock'),supplier:JSON.parse(localStorage.sunSuppliers).find(x=>x.id==='own-supplier'),orders:localStorage.sunOrders,moves:localStorage.getItem('sunStockMoves')}));
test('training is opt-in in an existing paid profile, adds complete examples, hides them and preserves originals',async({page},info)=>{
test.setTimeout(60000); // Full multi-page round trip plus reload in iPhone WebKit.
await boot(page);await expect(setting(page)).not.toBeChecked();
const before=await own(page);expect(await page.evaluate(()=>boxes.length)).toBe(1);
await toggle(page,true);expect(await page.evaluate(()=>boxes.length)).toBe(45);
expect(await own(page)).toEqual(before);
const source=await page.evaluate(()=>boxes.find(x=>x.id==='demo-v1-bruschetta-tomato'));
expect(source.photo).toBeTruthy();expect(source.composition.length).toBeGreaterThan(0);expect(source.ttk.rows.length).toBeGreaterThan(0);expect(source.ingredients.some(i=>i[0]==='Учебное: Томаты')).toBe(true);
await page.locator('#ctTrainingSettings').scrollIntoViewIfNeeded();
await page.screenshot({path:info.outputPath('training-settings.png'),animations:'disabled'});
await nav(page).getByRole('button',{name:'Меню',exact:true}).click();await expect(page.locator('[data-menu-item-v1762]')).toHaveCount(11);
await expect(page.locator('[data-menu-item-v1762="demo-v1-bruschetta-tomato"] .ct-training-badge')).toHaveText('Учебное');
await nav(page).getByRole('button',{name:'Новый заказ',exact:true}).click();
await page.getByRole('button',{name:'Банкетное меню',exact:true}).click();await expect(page.locator('[data-banquet-row]')).toHaveCount(18);
await settings(page);await toggle(page,false);
await nav(page).getByRole('button',{name:'Меню',exact:true}).click();await page.locator('[data-menu-cat-v1762="0"]').click();await expect(page.locator('[data-menu-item-v1762]')).toHaveCount(1);
await nav(page).getByRole('button',{name:'Новый заказ',exact:true}).click();await page.getByRole('button',{name:'Банкетное меню',exact:true}).click();await expect(page.locator('[data-banquet-row]')).toHaveCount(0);await page.getByRole('button',{name:'Боксы',exact:true}).click();await expect(page.locator('#tiles .tile:not(.add)')).toHaveCount(1);await expect(page.locator('#ctTrialDemo')).toHaveCount(0);
await nav(page).getByRole('button',{name:'Склад',exact:true}).click();await expect(page.locator('#stockContent')).not.toContainText('Учебное:');
await nav(page).getByRole('button',{name:'Поставщики',exact:true}).click();await expect(page.locator('#supList')).not.toContainText('Демо:');
expect(await own(page)).toEqual(before);expect(await page.evaluate(()=>boxes.length)).toBe(45);
await page.reload();await page.waitForFunction(()=>window.SunOpsUXV1762&&window.CateriumDataV1773);await settings(page);await expect(setting(page)).not.toBeChecked();await toggle(page,true);
expect(await page.evaluate(()=>boxes.length)).toBe(45);expect(await own(page)).toEqual(before);
});
test('TTKs, trial order and preparation keep their complete records after hiding and restore without resetting edits',async({page})=>{
await boot(page);await toggle(page,true);
await nav(page).getByRole('button',{name:'Новый заказ',exact:true}).click();await expect(page.locator('#ctTrialDemo')).toBeVisible();
await page.evaluate(()=>{window.ctGuideEvents=[];const card=document.getElementById('ctTrialDemo');new MutationObserver(rs=>{ctGuideEvents.push(...rs.map(r=>({type:r.type,attribute:r.attributeName,open:card.querySelector('details')?.open,signature:card.dataset.signature})));}).observe(card,{childList:true,subtree:true,attributes:true,attributeFilter:['open','data-signature']});});
await page.locator('#ctTrialDemo summary').click();await page.locator('[data-demo-ttk]').click();await expect(page.locator('#ctDemoTtk')).toContainText('Брутто');await expect(page.locator('#ctDemoTtk')).toContainText('840 г');await page.getByRole('button',{name:'Закрыть ТТК',exact:true}).click();
await page.locator('[data-demo-order]').click();await expect(page.locator('#ctDemoMessage')).toContainText('9 700 ₽');
const order=await page.evaluate(()=>orders.find(o=>o.demo===true));expect(order.autoCompletionDisabled).toBe(true);
await page.evaluate(()=>{const items=CateriumDataV1773.catalog.list();const box=items.find(b=>b.id==='demo-v1-bruschetta-tomato');box.price=2222;box.name='Мой учебный рецепт';CateriumDataV1773.catalog.replace(items,{persistLocal:false});const stock=JSON.parse(localStorage.sunStock);stock.find(s=>s.id==='demo-v1-stock-baguette').qty=7;localStorage.setItem('sunStock',JSON.stringify(stock));});
const before=await state(page);await settings(page);await toggle(page,false);
expect(await page.evaluate(id=>orders.find(o=>o.id===id),order.id)).toEqual(order);
expect(await page.evaluate(()=>CateriumDataV1773.catalog.get('demo-v1-bruschetta-tomato').ttk.rows.length)).toBeGreaterThan(0);
await toggle(page,true);
const after=await state(page);for(const k of ['sunBoxes','sunOrders','sunStock','sunSuppliers','sunStockMoves'])expect(after[k]).toBe(before[k]);
});
test('old automatically seeded boxes are hidden until opt-in and their customised prices are not overwritten',async({page})=>{
await boot(page);
await page.evaluate(d=>{d.boxes[0].price=4321;CateriumDataV1773.catalog.replace([...CateriumDataV1773.catalog.list(),...d.boxes],{persistLocal:false});localStorage.setItem('sunTrialDemoV1',JSON.stringify({version:1,batch:d.batch,scenario:d.scenario}));CateriumTrainingCatalog.refresh();},demo);
await nav(page).getByRole('button',{name:'Меню',exact:true}).click();await expect(page.locator('[data-menu-item-v1762]')).toHaveCount(1);
await settings(page);await toggle(page,true);expect(await page.evaluate(()=>boxes.find(b=>b.id==='demo-v1-bruschetta-tomato').price)).toBe(4321);
});
test('the checkbox is profile- and company-specific; read-only and support access do not gain write rights',async({page})=>{
await boot(page);await toggle(page,true);const before=await state(page);
await page.evaluate(()=>{window.savedSession=SunCloudV2.getSession;SunCloudV2.getSession=()=>({user:{id:'another-user'}});CateriumTrainingCatalog.refresh();});await expect(setting(page)).not.toBeChecked();
await page.evaluate(()=>{SunCloudV2.getSession=window.savedSession;window.savedWorkspace=SunCloudV2.getWorkspace;SunCloudV2.getWorkspace=()=>({id:'another-workspace'});CateriumTrainingCatalog.refresh();});await expect(setting(page)).not.toBeChecked();
// Keep read access, deny writes. Revoking read access too correctly triggers
// the existing RBAC cache redaction and is not a training-catalog mutation.
await page.evaluate(()=>{SunCloudV2.getWorkspace=window.savedWorkspace;window.savedPermission=SunCloudV2.hasPermission;SunCloudV2.hasPermission=p=>p==='app.read'||p.endsWith('.view');CateriumTrainingCatalog.refresh();});await expect(setting(page)).toBeDisabled();
expect(await page.evaluate(()=>CateriumTrainingCatalog.setEnabled(false).then(()=>false,()=>true))).toBe(true);
await page.evaluate(()=>{SunCloudV2.hasPermission=window.savedPermission;SunCloudV2.isSupportMode=()=>true;CateriumTrainingCatalog.refresh();});await expect(setting(page)).toBeDisabled();
expect(await state(page)).toEqual(before);
});
test('failed example download leaves the catalog, inventory and preference unchanged and can be retried',async({page})=>{
await boot(page);const before=await state(page);
await page.route('**/demo/catalog-v1.json?**',route=>route.fulfill({status:503,body:'temporarily unavailable'}));
// A fast rejected download correctly reverts the checkbox before check()
// could assert a checked state. Click and assert the intended error state.
await setting(page).click();await expect(page.locator('#ctTrainingStatus')).toContainText('Не удалось загрузить');await expect(setting(page)).not.toBeChecked();await expect(setting(page)).toBeEnabled();expect(await state(page)).toEqual(before);
await page.unroute('**/demo/catalog-v1.json?**');await toggle(page,true);expect(await page.evaluate(()=>boxes.length)).toBe(45);
});
test('a delayed seed cannot be applied to another company after switching',async({page})=>{
await boot(page);const before=await state(page);let release,requested=false;const wait=new Promise(resolve=>{release=resolve;});
await page.route('**/demo/catalog-v1.json?**',async route=>{requested=true;await wait;await route.continue();});
await setting(page).check();await expect.poll(()=>requested).toBe(true);
await page.evaluate(()=>{window.dispatchEvent(new Event('sun:cloud-tenant-changing'));SunCloudV2.getWorkspace=()=>({id:'new-company'});window.dispatchEvent(new Event('sun:cloud-state-applied'));});release();
await expect(setting(page)).not.toBeChecked();await page.waitForTimeout(200);expect(await state(page)).toEqual(before);
});
test.afterEach(async({page},info)=>{if(info.status!==info.expectedStatus){const events=await page.evaluate(()=>window.ctGuideEvents||[]).catch(()=>[]);await info.attach('training-guide-events',{body:JSON.stringify(events, null, 2),contentType:'application/json'});console.log('GUIDE EVENTS',JSON.stringify(events));}});

View File

@ -23,7 +23,7 @@ async function ready(page){
await page.evaluate(d=>{ await page.evaluate(d=>{
window.SunCloudV2={getSession:()=>({user:{id:'test'}}),getWorkspace:()=>({id:'test',role:'admin',permissions:{}}),getClient:()=>({rpc:async(name)=>({data:name==='sun_subscription_snapshot'?{access_mode:'full',status:'trialing',plan_name:'Полный',features:Object.fromEntries(['orders','shopping','stock','catalog_view','catalog_edit','suppliers','settings','clients','calendar','print'].map(k=>[k,true]))}:name==='caterium_trial_demo_status'?{eligible:true,installed:true,empty:false,canInstall:false}:null})}),status:()=>({connected:true,signedIn:true,membershipsLoaded:true}),hasPermission:()=>true}; window.SunCloudV2={getSession:()=>({user:{id:'test'}}),getWorkspace:()=>({id:'test',role:'admin',permissions:{}}),getClient:()=>({rpc:async(name)=>({data:name==='sun_subscription_snapshot'?{access_mode:'full',status:'trialing',plan_name:'Полный',features:Object.fromEntries(['orders','shopping','stock','catalog_view','catalog_edit','suppliers','settings','clients','calendar','print'].map(k=>[k,true]))}:name==='caterium_trial_demo_status'?{eligible:true,installed:true,empty:false,canInstall:false}:null})}),status:()=>({connected:true,signedIn:true,membershipsLoaded:true}),hasPermission:()=>true};
// Hydrate AFTER stock UI boot: the real cloud path must update its closure. // Hydrate AFTER stock UI boot: the real cloud path must update its closure.
for(const [key,value] of Object.entries({sunBoxes:d.boxes,sunStock:d.stock,sunSuppliers:d.suppliers,sunStockMoves:[],sunOrders:[],sunTrialDemoV1:{version:1,batch:d.batch,scenario:d.scenario}}))localStorage.setItem(key,JSON.stringify(value)); for(const [key,value] of Object.entries({sunBoxes:d.boxes,sunStock:d.stock,sunSuppliers:d.suppliers,sunStockMoves:[],sunOrders:[],sunTrialDemoV1:{version:1,batch:d.batch,scenario:d.scenario,trainingCatalog:{workspaceId:'test',profiles:{test:true}}}}))localStorage.setItem(key,JSON.stringify(value));
boxes=structuredClone(d.boxes);orders=[];window.dispatchEvent(new Event('sun:cloud-state-applied'));window.dispatchEvent(new Event('sun:cloud-permissions-changed'));window.render();window.CateriumTrialDemo.render(); boxes=structuredClone(d.boxes);orders=[];window.dispatchEvent(new Event('sun:cloud-state-applied'));window.dispatchEvent(new Event('sun:cloud-permissions-changed'));window.render();window.CateriumTrialDemo.render();
},completeDemo); },completeDemo);
await expect(page.locator('#ctTrialDemo')).toBeVisible(); await expect(page.locator('#ctTrialDemo')).toBeVisible();

View File

@ -194,7 +194,9 @@ test('full app stays stable across settings, menu, calendar and delayed backgrou
await page.goto('/index.html',{waitUntil:'domcontentloaded'}); await page.goto('/index.html',{waitUntil:'domcontentloaded'});
await expect(page.locator('body > header')).toBeVisible({timeout:20000}); await expect(page.locator('body > header')).toBeVisible({timeout:20000});
await page.waitForFunction(()=>window.SunOpsUXV1762&&window.SunSaaSV16?.getSnapshot()); await page.waitForFunction(()=>window.SunOpsUXV1762&&window.SunSaaSV16?.getSnapshot());
await expect.poll(()=>servedRpcs.has('caterium_trial_demo_status')).toBe(true); await page.waitForFunction(()=>window.CateriumTrialDemo&&window.CateriumTrainingCatalog);
expect(await page.evaluate(()=>CateriumTrainingCatalog.enabled())).toBe(false);
expect(servedRpcs.has('caterium_trial_demo_status')).toBe(false);
const nav=page.locator('header nav'); const nav=page.locator('header nav');
for(const name of ['Настройки','Меню','Календарь','Заказы','Настройки']){ for(const name of ['Настройки','Меню','Календарь','Заказы','Настройки']){
await nav.getByRole('button',{name,exact:true}).click(); await nav.getByRole('button',{name,exact:true}).click();