caterium-app/public/core/trial-demo.js
pavlov346346-source 424bf8ce87
Compact clients and explicit timed menu promotions (#31)
Add compact accessible client summaries and menu discounts in percent or rubles with explicit durations. Preserve ordinary catalog prices and order line snapshots; derive current prices and expire promotions automatically without background writes. Keep fractional prices, legacy sale compatibility, and regression coverage. Full pull-request QA passed. Publication remains gated by full main QA and byte-for-byte production asset and UI verification.
2026-09-19 09:34:50 +03:00

101 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(()=>{
'use strict';
if(window.CateriumTrialDemo)return;
const KEY='sunTrialDemoV1',BATCH='trial-demo-v1';
const $=id=>document.getElementById(id);
const esc=x=>String(x??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const money=x=>new Intl.NumberFormat('ru-RU',{maximumFractionDigits:2}).format(Number(x)||0)+' ₽';
const num=x=>new Intl.NumberFormat('ru-RU',{maximumFractionDigits:3}).format(Number(x)||0);
const read=(key,fallback)=>{try{return JSON.parse(localStorage.getItem(key))??fallback}catch(_){return fallback}};
const cloud=()=>window.SunCloudV2;
const data=()=>window.CateriumDataV1773;
const catalog=()=>data()?.catalog.list()||read('sunBoxes',[]);
const installed=()=>read(KEY,{})?.version===1;
const signedIn=()=>Boolean(cloud()?.getSession?.()?.user&&cloud()?.getWorkspace?.()?.id);
const writable=()=>signedIn()&&!cloud()?.isSupportMode?.()&&cloud()?.hasPermission?.('orders.create')&&window.SunSaaSV16?.isWritable?.()!==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 message(text){const el=$('ctDemoMessage');if(el)el.textContent=text;}
async function refreshStatus(){
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(){
const host=$('new');if(!host)return;
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;}
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()});
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>
<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>
${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>`:
`<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>`;
card.querySelector('[data-demo-install]')?.addEventListener('click',install);
card.querySelector('[data-demo-order]')?.addEventListener('click',createOrder);
card.querySelector('[data-demo-ttk]')?.addEventListener('click',()=>showTTK($('ctDemoBox').value));
card.querySelectorAll('[data-demo-go]').forEach(b=>b.addEventListener('click',()=>navigate(b.dataset.demoGo)));
}
async function install(){
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(){
if(!writable()||!installed())return;
const repo=data()?.orders;if(!repo){message('Приложение ещё загружается. Повторите через несколько секунд.');return;}
const all=repo.list(),existing=all.find(o=>o.demoBatch===BATCH);
if(existing){message(`Пробный заказ №${existing.id} уже создан. Его можно открыть во вкладке «Заказы».`);return;}
const spec=read(KEY,{}).scenario?.lines,items=catalog();
if(!Array.isArray(spec)||spec.length!==3){message('Сценарий недоступен. Добавьте боксы в заказ самостоятельно.');return;}
const lines=spec.map(l=>{const box=items.find(b=>String(b.id)===String(l.id));return box?{id:box.id,name:box.name,qty:l.qty,price:(window.CateriumPricing?.price(box)??Number(box?.price||0))}:null});
if(lines.some(l=>!l)){message('Некоторые демо-боксы удалены. Добавьте оставшиеся в заказ самостоятельно.');return;}
const total=lines.reduce((s,l)=>s+l.price*l.qty,0),day=new Date();day.setDate(day.getDate()+1);
const date=`${day.getFullYear()}-${String(day.getMonth()+1).padStart(2,'0')}-${String(day.getDate()).padStart(2,'0')}`;
const id=Math.max(0,...all.map(o=>Number(o.id)||0))+1;
repo.replace([...all,{id,event:'Демо: фуршет',date,time:'12:00',contact:'',phone:'',address:'',note:'Учебный заказ для проверки склада и закупки. Автозавершение отключено.',status:'Новый',lines,total,prepayment:0,balance:total,demo:true,demoBatch:BATCH,autoCompletionDisabled:true}],{reason:'trial-demo.order'});
window.renderOrders?.();window.sunRefreshStock?.();
message(`Заказ №${id} на ${money(total)} создан на завтра. Откройте «Закупки», чтобы увидеть нехватку продуктов.`);
}
function showTTK(id){
const box=catalog().find(b=>String(b.id)===String(id));if(!box?.ttk)return;
$('ctDemoTtk')?.remove();const t=box.ttk,dialog=document.createElement('dialog');dialog.id='ctDemoTtk';
const changed=JSON.stringify(box.ingredients)!==JSON.stringify(t.rows.map(r=>[r.name,r.gross,r.unit]));
dialog.innerHTML=`<div class="ct-demo-heading"><div><small>${esc(t.number)} · ${esc(t.basis)}</small><h2>${esc(box.name)}</h2></div><button class="outline" data-ttk-close aria-label="Закрыть ТТК">×</button></div>
<p>${changed?'Исходный выход':'Выход'}: <b>${num(t.outputGrams)} г${Number(box.category)===6?' · 1 порция':t.pieces?` · ${num(t.pieces)} шт.`:''}</b> · ${Number(box.category)===6?'Цена порции':'Цена бокса'}: <b>${money((window.CateriumPricing?.price(box)??Number(box?.price||0)))}</b></p>
${changed?'<p class="ct-demo-notice">Состав изменён. Закупка и списание используют текущий состав из редактора. Ниже показана исходная учебная ТТК.</p>':''}
<div class="ct-demo-table"><table><thead><tr><th>Продукт / упаковка</th><th>Ед.</th><th>Брутто</th><th>Нетто</th><th>Цена за ед.</th><th>Сумма</th></tr></thead><tbody>${t.rows.map(r=>`<tr><td>${esc(r.name)}</td><td>${esc(r.unit)}</td><td>${num(r.gross)}</td><td>${num(r.net)}</td><td>${money(r.unitCost)}</td><td>${money(r.gross*r.unitCost)}</td></tr>`).join('')}</tbody></table></div>
<p><b>Продукты и упаковка: ${money(t.ingredientCost)}</b></p><p class="hint">Учебные закупочные цены. Работа, доставка и накладные расходы не включены. Расход со склада считается по брутто.</p>
${t.waterGrams?`<p class="hint">В выходе учтено ${num(t.waterGrams)} г воды, поглощённой при приготовлении. Вода не списывается со склада.</p>`:''}
<h3>Приготовление и сборка</h3><ol>${t.steps.map(s=>`<li>${esc(s)}</li>`).join('')}</ol><p><b>Аллергены:</b> ${t.allergens.length?t.allergens.map(esc).join(', '):'в учебном составе не указаны; уточните по продуктам поставщика'}.</p><p class="hint">${esc(t.note)}</p>
<div class="ct-demo-actions"><button class="outline" data-ttk-edit>Изменить состав и цену</button><button class="primary" data-ttk-close>Закрыть</button></div>`;
document.body.append(dialog);dialog.querySelectorAll('[data-ttk-close]').forEach(b=>b.onclick=()=>dialog.close());
dialog.querySelector('[data-ttk-edit]').onclick=()=>{dialog.close();window.editBox?.(box.id)};
dialog.addEventListener('close',()=>dialog.remove(),{once:true});dialog.showModal();
}
function reset(){epoch++;pending=null;status=null;statusWorkspace='';$('ctTrialDemo')?.remove();$('ctDemoTtk')?.remove();}
function boot(){
const style=document.createElement('style');style.textContent=`#ctTrialDemo{margin:0 0 18px;padding:18px 22px}#ctTrialDemo p{font-size:13px;line-height:1.5;margin:6px 0 12px;max-width:900px}.ct-demo-heading{display:flex;justify-content:space-between;gap:14px;align-items:flex-start}.ct-demo-heading strong{font-size:18px}.ct-demo-heading h2{font-size:22px;margin:8px 0}.ct-demo-badge{border:1px solid #c3d4bd;border-radius:18px;padding:5px 10px;font-size:12px;white-space:nowrap}.ct-demo-actions{display:flex;gap:10px;align-items:end;flex-wrap:wrap;margin:12px 0}.ct-demo-actions label{min-width:0;max-width:100%;flex:1}.ct-demo-actions select{width:100%;font:inherit;padding:9px}.ct-demo-actions button{max-width:100%}#ctTrialDemo details{font-size:13px;margin-top:14px}#ctTrialDemo summary{cursor:pointer}#ctDemoMessage:empty{display:none}#ctDemoTtk{width:min(860px,calc(100vw - 24px));max-height:90dvh;box-sizing:border-box;overflow:auto;border:1px solid #d4c8b0;border-radius:18px;padding:24px;background:var(--card,#fff);color:var(--text,#27241e)}#ctDemoTtk::backdrop{background:#0008}.ct-demo-table{overflow-x:auto}.ct-demo-table table{border-collapse:collapse;min-width:580px;width:100%;font-size:13px}.ct-demo-table td,.ct-demo-table th{text-align:right;padding:9px 7px;border-bottom:1px solid #ddd}.ct-demo-table th:first-child,.ct-demo-table td:first-child{text-align:left}#ctDemoTtk li{margin:8px 0;line-height:1.5}.ct-demo-notice{background:#fff3d6;color:#604918;padding:12px;border-radius:8px}@media(max-width:600px){#ctTrialDemo{padding:14px}.ct-demo-heading{flex-wrap:wrap}.ct-demo-actions>button{flex:1 1 auto}.ct-demo-actions label{flex-basis:100%}#ctDemoTtk{padding:16px}}@media print{#ctTrialDemo{display:none!important}}`;
document.head.append(style);
window.addEventListener('sun:cloud-tenant-changing',reset);
window.addEventListener('sun:cloud-permissions-changed',()=>{render();refreshStatus()});
window.addEventListener('sun:cloud-state-applied',()=>{render();refreshStatus()});
window.addEventListener('sun:subscription-changed',()=>{statusWorkspace='';render();refreshStatus()});
document.addEventListener('click',e=>{if(e.target.closest('header nav button')){render();refreshStatus()}});
data()?.subscribe('catalog',render);setTimeout(()=>{render();refreshStatus()},1400);
}
window.CateriumTrialDemo={render,refreshStatus,showTTK};
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot();
})();