The big "Учебный каталог включён" card with its buttons sat on top of the order screen. While training mode is on, the order screen now shows only "Включён учебный режим · отключить в настройках"; the link opens Settings at the training switch. The guide (trial order, stock, purchasing, TTK) moves into the "Обучение и знакомство" settings card, so nothing is lost. The invitation card for companies with an empty catalog is unchanged. Specs updated for the new placement, plus a check that the notice link lands on (and focuses) the switch. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
168 lines
17 KiB
JavaScript
168 lines
17 KiB
JavaScript
/* 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(()=>{});});
|
||
// The trial guide (order, stock, TTK) is mounted inside this card while training is on.
|
||
queueMicrotask(()=>window.CateriumTrialDemo?.render?.());
|
||
}
|
||
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();
|
||
})();
|