diff --git a/.github/workflows/deploy-timeweb.yml b/.github/workflows/deploy-timeweb.yml index 1a0d99e..3767346 100644 --- a/.github/workflows/deploy-timeweb.yml +++ b/.github/workflows/deploy-timeweb.yml @@ -47,6 +47,13 @@ jobs: core/login-signature-v1776.css core/help-center.js core/help-center.css + core/catalog-pricing.js + core/client-menu.css + core/ops-ux-v1762.js + core/banquet-menu.js + core/data-layer-v1773.js + core/trial-demo.js + legacy/bootstrap.js caterium-mark-light.svg service-worker.js ) @@ -102,7 +109,7 @@ jobs: node-version: 22 - run: npm ci - run: npx playwright install --with-deps chromium - - name: Check the published loading screen and Help icon + - name: Check the published loading screen, Help, clients and promotions timeout-minutes: 4 run: node tests/production-ui-smoke.mjs - name: Save production UI verification diff --git a/docs/releases/2026-09-19-CLIENTS-PROMOTIONS.md b/docs/releases/2026-09-19-CLIENTS-PROMOTIONS.md new file mode 100644 index 0000000..8cba562 --- /dev/null +++ b/docs/releases/2026-09-19-CLIENTS-PROMOTIONS.md @@ -0,0 +1,30 @@ +# Compact clients and scheduled menu promotions + +Client summaries are compact, whole-card keyboard-accessible buttons. Name, +phone, order count, turnover and loyalty discount remain visible; full addresses, +orders and loyalty settings remain in the existing detail dialog. Search is retained. + +Menu editing now uses the ordinary price plus an explicit percentage or ruble +discount, not a manually entered previous price. Duration presets (1, 7, 14, 30 +days), an exact local date/time, and unlimited duration are supported. Existing +finite promotions keep their original deadline when an unrelated field is edited. +The editor validates values and previews the effective price and return deadline. + +`price` stores the ordinary price. `promotion` stores type, value, startsAt and +endsAt (UTC instants). A shared pure price resolver calculates the effective price; +at the deadline it returns the base price and no promotion badge. No background +server write, open tab or cron is required. An open tab schedules the next boundary +and rechecks after focus, visibility and cloud refresh. Saved order line price +snapshots are not recalculated. The expiry event never writes orders or catalog. +The client device clock is used, just like the existing app scheduling. + +Legacy price/oldPrice records retain their existing effective price and convert +only when explicitly saved. JSON and spreadsheet catalog round-trips keep promotion +metadata. Full JSON cloud payloads already preserve these fields; no migration, +new permissions, database mutation or cross-project change is required. + +Regression coverage: compact client geometry/search/details; percentage and ruble +validation; legacy conversion; fractional prices; exact deadline and reload; +unchanged saved orders; foreground expiry without losing an unsaved editor. The +production smoke uses fetched public assets with a synthetic, network-blocked +workspace; no customer account or database is used for this verification. diff --git a/package.json b/package.json index 98a821c..576332e 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "17.7.3", "type": "module", "scripts": { - "check:syntax": "node --check public/app-runtime.js && node --check public/service-worker.js && node --check public/legacy/bootstrap.js && node --check public/core/sun-safe.js && node --check public/core/account-center-v1780.js && node --check public/core/performance.js && node --check public/core/auth-security-v1774.js && node --check public/core/trial-promo-developer-v181.js && node --check public/core/order-enhancements-v1775.js && node --check public/core/data-layer-v1773.js && node --check public/core/server-automation-v1770.js && node --check public/core/hotfix-v1763.js && node --check public/core/ops-ux-v1762.js && node --check public/core/ux-fixes-v1764.js && node --check public/core/pdf-engine.js && node --check public/core/classic-offer-pdf-v1767.js && node --check public/core/signature-offer-pdf-v18.js && node --check public/core/developer-console-v1768.js && node --check public/core/offer-workspace-v1769.js && node --check public/core/brand-theme.js && node --check public/core/company-branding.js && node --check public/core/import-archive.js && node --check public/core/access-policy.js && node --check public/core/banquet-menu.js && node --check public/core/cloud-transport.js && node --check public/core/trial-demo.js && node --check public/core/proposal-layout.js && node --check public/core/mobile-order.js && node --check public/core/help-center.js", + "check:syntax": "node --check public/core/catalog-pricing.js && node --check public/app-runtime.js && node --check public/service-worker.js && node --check public/legacy/bootstrap.js && node --check public/core/sun-safe.js && node --check public/core/account-center-v1780.js && node --check public/core/performance.js && node --check public/core/auth-security-v1774.js && node --check public/core/trial-promo-developer-v181.js && node --check public/core/order-enhancements-v1775.js && node --check public/core/data-layer-v1773.js && node --check public/core/server-automation-v1770.js && node --check public/core/hotfix-v1763.js && node --check public/core/ops-ux-v1762.js && node --check public/core/ux-fixes-v1764.js && node --check public/core/pdf-engine.js && node --check public/core/classic-offer-pdf-v1767.js && node --check public/core/signature-offer-pdf-v18.js && node --check public/core/developer-console-v1768.js && node --check public/core/offer-workspace-v1769.js && node --check public/core/brand-theme.js && node --check public/core/company-branding.js && node --check public/core/import-archive.js && node --check public/core/access-policy.js && node --check public/core/banquet-menu.js && node --check public/core/cloud-transport.js && node --check public/core/trial-demo.js && node --check public/core/proposal-layout.js && node --check public/core/mobile-order.js && node --check public/core/help-center.js", "test:static": "node tests/static-security.mjs && node tests/auth-security-v1774.mjs && node tests/employee-create-v1774.mjs && node tests/html-integrity-v1774.mjs && node tests/edge-security-v1774.mjs && node tests/branding-v1774.mjs && node tests/order-enhancements-v1775.mjs", "check:release": "node tests/release-check.mjs", "check:deploy": "npm run check:syntax && npm run test:static && npm run check:release && node tests/backend-cutover.mjs && npm run test:db", diff --git a/public/app-runtime.js b/public/app-runtime.js index 07ba176..e24cdb5 100644 --- a/public/app-runtime.js +++ b/public/app-runtime.js @@ -113,7 +113,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; const titleY=717; ctx.fillStyle='#1f1f1d';ctx.font='800 36px Arial, sans-serif'; const nameLines=wrapText(ctx,item.name||'',650,2);drawTextLines(ctx,nameLines,85,titleY,41); - ctx.textAlign='right';ctx.fillStyle='#c99a32';ctx.font='800 37px Arial, sans-serif';ctx.fillText(money(item.price||0),965,titleY);ctx.textAlign='left'; + ctx.textAlign='right';ctx.fillStyle='#c99a32';ctx.font='800 37px Arial, sans-serif';ctx.fillText(money(window.CateriumPricing.price(item)),965,titleY);ctx.textAlign='left'; const factsY=820; ctx.fillStyle='#77736d';ctx.font='800 14px Arial, sans-serif';ctx.fillText('ВЕС',85,factsY);ctx.fillText('КОЛ-ВО',525,factsY); @@ -217,7 +217,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; const $=id=>document.getElementById(id); const qa=(sel,root=document)=>[...root.querySelectorAll(sel)]; const esc=window.SunSafe.escapeHTML; - const money=v=>`${Math.round(Number(v||0)).toLocaleString('ru-RU')} ₽`; + const money=v=>`${Number(v||0).toLocaleString('ru-RU',{maximumFractionDigits:2})} ₽`; const toast=(m,t='success')=>{try{return window.SunEnterprise?.toast?.(m,t)}catch(_){}; if(t==='warn'||t==='error')console.warn(m);}; const style=document.createElement('style'); @@ -384,7 +384,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; let search=$('sunCatalogSearch');if(!search){search=document.createElement('input');search.id='sunCatalogSearch';search.className='sun-catalog-search';search.type='search';search.placeholder='Поиск по № бокса';search.inputMode='numeric';search.oninput=()=>{catalogQuery=search.value;renderCatalogV5();};configure.insertAdjacentElement('beforebegin',search)} let tabs=$('sunCatalogTabsButton');if(!tabs){tabs=document.createElement('button');tabs.id='sunCatalogTabsButton';tabs.className='outline sun-catalog-tabs-button';tabs.type='button';tabs.textContent='Вкладки';tabs.onclick=window.sunOpenCategoryManager;configure.insertAdjacentElement('beforebegin',tabs)} } - function lineUnitPrice(line){const direct=Number(line?.price);if(Number.isFinite(direct)&&direct>=0)return direct;const item=boxes.find(x=>String(x.id)===String(line?.id));return Math.max(0,Number(item?.price||0));} + function lineUnitPrice(line){const item=boxes.find(x=>String(x.id)===String(line?.id));return window.CateriumPricing.linePrice(line,item);} function baseTotal(order){return (order?.lines||[]).reduce((s,l)=>s+lineUnitPrice(l)*Math.max(0,Number(l.qty||0)),0)} window.sunBaseOrderTotal=baseTotal; function parseWeightGrams(value){const raw=String(value??'').trim().toLowerCase().replace(',','.');if(!raw)return 0;const m=raw.match(/(\d+(?:\.\d+)?)/);if(!m)return 0;let n=Math.max(0,Number(m[1]||0));if(/кг/.test(raw))n*=1000;return Math.round(n)} @@ -420,7 +420,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; function syncBanquetEditor(category,item){ const fields=ensureBanquetEditor();if(!fields)return;const isBanquet=Number(category)===BANQUET_CATEGORY; fields.section.style.display=isBanquet?'flex':'none';fields.weight.style.display=isBanquet?'flex':'none'; - const caption=$('boxPriceCaption');if(caption)caption.textContent=isBanquet?'Цена на 1 гостя, ₽':'Цена, ₽'; + const caption=$('boxPriceCaption');if(caption)caption.textContent=isBanquet?'Обычная цена на 1 гостя, ₽':'Обычная цена, ₽'; window.CateriumBanquet?.editor(category,item,boxes.filter(i=>i.hidden!==true&&Number(i.category)===6)); if(isBanquet){if($('banquetSection'))$('banquetSection').value=item?.catalogSection||'';if($('banquetWeightGrams'))$('banquetWeightGrams').value=parseWeightGrams(item?.weight)||''} } @@ -429,10 +429,10 @@ window.SUN_LEGACY_CATALOG_V175=[]; function renderOrderLines(){ if(!$('lines'))return; if(!(draft.lines||[]).length){$('lines').innerHTML='
Выберите позиции из каталога.
';return} - const rows=draft.lines.map((line,index)=>{const item=boxes.find(x=>String(x.id)===String(line.id));if(!item)return'';const qty=Math.max(1,Number(line.qty||1)),price=lineUnitPrice(line),isBox=[0,5,6].includes(Number(item.category||0));return `Ничего не найдено.
'; + $('managerList').innerHTML=items.length?items.map(item=>``).join(''):'Ничего не найдено.
'; } window.openManager=()=>{managerQuery='';ensureManagerToolbar();if($('sunManagerSearch'))$('sunManagerSearch').value='';renderManagerV5();window.modal?.('manager');}; window.editBox=id=>{ edited=id?structuredClone(boxes.find(x=>String(x.id)===String(id))):{id:'',name:Number(activeCat)===0?'Новый бокс':(Number(activeCat)===5?'Новая премиум позиция':'Новая позиция'),category:Number(activeCat),price:0,oldPrice:0,ingredients:[]};if(!edited)return; - if(!Array.isArray(edited.ingredients))edited.ingredients=[];normalizeBoxItem(edited);syncEditorCategoryOptions();$('itemCategory').value=String(edited.category??activeCat);$('boxName').value=edited.name||'';$('boxPrice').value=Number(edited.price||0);if($('boxOldPrice'))$('boxOldPrice').value=Number(edited.oldPrice||0);if($('boxCompositionText'))$('boxCompositionText').value=(Array.isArray(edited.composition)?edited.composition:[]).join('\n');syncBoxWeightEditor(edited.category??activeCat,edited);syncBanquetEditor(edited.category??activeCat,edited);$('editPhoto').style.display=edited.photo?'block':'none';$('editPhoto').src=edited.photo||'';$('deleteBox').style.display=edited.id?'block':'none'; + if(!Array.isArray(edited.ingredients))edited.ingredients=[];normalizeBoxItem(edited);syncEditorCategoryOptions();$('itemCategory').value=String(edited.category??activeCat);$('boxName').value=edited.name||'';window.CateriumPricing.loadEditor(edited);if($('boxCompositionText'))$('boxCompositionText').value=(Array.isArray(edited.composition)?edited.composition:[]).join('\n');syncBoxWeightEditor(edited.category??activeCat,edited);syncBanquetEditor(edited.category??activeCat,edited);$('editPhoto').style.display=edited.photo?'block':'none';$('editPhoto').src=edited.photo||'';$('deleteBox').style.display=edited.id?'block':'none'; const cat=catById(edited.category)||{name:'Каталог',prep:true};$('editTitle').textContent=edited.id?`Изменить: ${cat.name}`:(Number(edited.category)===0?'Новый бокс':(Number(edited.category)===5?'Новая премиум позиция':'Новая позиция'));const zone=document.querySelector('#editor .ingredient-zone');if(zone)zone.style.display=cat.prep?'block':'none';window.renderIngredients?.();window.modal?.('editor'); }; if($('itemCategory'))$('itemCategory').onchange=function(){if(!edited)return;edited.category=Number(this.value);const c=catById(edited.category)||{name:'Каталог',prep:true};const zone=document.querySelector('#editor .ingredient-zone');if(zone)zone.style.display=c.prep?'block':'none';syncBoxWeightEditor(edited.category,edited);syncBanquetEditor(edited.category,edited);$('editTitle').textContent=edited.id?`Изменить: ${c.name}`:(Number(edited.category)===0?'Новый бокс':(Number(edited.category)===5?'Новая премиум позиция':'Новая позиция'));}; - window.saveBox=()=>{if(!edited)return;edited.name=$('boxName').value.trim();edited.price=Math.max(0,Number($('boxPrice').value||0));edited.oldPrice=Math.max(0,Number($('boxOldPrice')?.value||0));if(!(edited.oldPrice>edited.price))delete edited.oldPrice;edited.sale=Boolean(edited.oldPrice&&edited.oldPrice>edited.price);edited.category=Number($('itemCategory').value);if([0,5].includes(edited.category)){const grams=Math.max(0,Math.round(Number($('boxWeightGrams')?.value||0)));edited.weight=grams?`${grams} г`:'';edited.pieces=Math.max(0,Math.round(Number($('boxPiecesCount')?.value||0)));}if(edited.category===BANQUET_CATEGORY){edited.catalogSection=($('banquetSection')?.value||'').trim();const grams=Math.max(0,Math.round(Number($('banquetWeightGrams')?.value||0)));edited.weight=grams?`${grams} г`:'';}window.CateriumBanquet?.saveEditor(edited,boxes.filter(i=>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)return alert('Введите название.');if(!edited.id){edited.id=(typeof sunUUID==='function'?sunUUID():`item-${Date.now()}`);boxes.push(edited)}else{const i=boxes.findIndex(x=>String(x.id)===String(edited.id));if(i>=0)boxes[i]=edited}if(!catById(edited.category)?.hidden)activeCat=edited.category;persist();closeModal('editor');closeModal('manager');renderCatalogV5();renderOrders?.();window.sunClientOfferCatalogChanged?.(edited.id);}; + window.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.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)})} @@ -881,7 +886,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; const $=id=>document.getElementById(id); const qa=(sel,root=document)=>[...root.querySelectorAll(sel)]; const esc=window.SunSafe.escapeHTML; - const money=v=>`${Math.round(Number(v||0)).toLocaleString('ru-RU')} ₽`; + const money=v=>`${Number(v||0).toLocaleString('ru-RU',{maximumFractionDigits:2})} ₽`; const num=v=>Math.max(0,Number(v||0)); const toast=(m,t='success')=>{try{return window.SunEnterprise?.toast?.(m,t)}catch(_){} if(t==='error'||t==='warn')console.warn(m);}; // Document logos are supplied by the selected company. @@ -925,7 +930,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; const getDraft=()=>{try{return typeof draft!=='undefined'?draft:null}catch(_){return null}}; const catName=id=>{try{return window.sunCatalogCategoryName?.(id)||({0:'Боксы',1:'Посуда и упаковка',2:'Дополнительно',3:'Напитки',4:'Доставка',5:'Премиум'}[Number(id)]||'Каталог')}catch(_){return 'Каталог'}}; - function linePrice(line,item){const p=Number(line?.price);return Number.isFinite(p)&&p>=0?p:num(item?.price)} + function linePrice(line,item){const p=Number(line?.price);return Number.isFinite(p)&&p>=0?p:num(window.CateriumPricing.price(item))} function discountAmount(value,type,base){const n=num(value);return Math.min(base,type==='amount'?n:base*Math.min(100,n)/100)} function priceCalc(order){ const all=getBoxes(); @@ -1795,7 +1800,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; const $=id=>document.getElementById(id); const qa=(sel,root=document)=>[...root.querySelectorAll(sel)]; const esc=window.SunSafe.escapeHTML; - const money=v=>`${Math.round(Number(v||0)).toLocaleString('ru-RU')} \u20bd`; + const money=v=>`${Number(v||0).toLocaleString('ru-RU',{maximumFractionDigits:2})} \u20bd`; const txt={ gen:'\u0421\u0433\u0435\u043d\u0435\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043f\u0440\u0435\u0434\u043b\u043e\u0436\u0435\u043d\u0438\u0435', title:'\u0421\u0433\u0435\u043d\u0435\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043f\u0440\u0435\u0434\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u043a\u043b\u0438\u0435\u043d\u0442\u0443', @@ -1836,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)} const out=new Map();for(const [id,st] of stats)out.set(id,(maxOrders?st.orders/maxOrders:0)*.78+(maxQty?st.qty/maxQty:0)*.22);return out; } - function foodCandidates(){const pop=popularityMap();return getBoxes().filter(x=>Number(x?.category||0)===0&&x?.hidden!==true&&Number(x?.price||0)>0&&parseWeight(x?.weight)>0&&!/\u0433\u043e\u0442\u043e\u0432\u044b\u0435\s+\u043c\u0435\u043d\u044e/i.test(String(x?.catalogSection||''))).map(x=>({raw:x,id:String(x.id),name:String(x.name||''),section:String(x.catalogSection||'\u0411\u043e\u043a\u0441\u044b'),price:Number(x.price||0),grams:parseWeight(x.weight),portions:portions(x),photo:String(x.photo||(window.CateriumBranding.documentImage())),popularity:Number(pop.get(String(x.id))||0)}));} + function 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 hashNoise(text,seed){let h=2166136261^(seed*2654435761);for(const c of String(text)){h^=c.charCodeAt(0);h=Math.imul(h,16777619)}return ((h>>>0)%10000)/10000;} function stateKey(s){return [...s.q.entries()].sort((a,b)=>a[0].localeCompare(b[0])).map(([id,q])=>id+':'+q).join('|')} function score(s,t,seed){ @@ -4101,7 +4106,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; const DEFAULT_SOURCES=['Заявка с сайта','CaterMe','Avito','Рекомендация','Повторный клиент','Соцсети','Другое']; const getDraft=()=>{try{return draft}catch(_){return null}}; const getOrders=()=>{try{return Array.isArray(orders)?orders:[]}catch(_){try{const x=JSON.parse(localStorage.getItem('sunOrders')||'[]');return Array.isArray(x)?x:[]}catch(__){return[]}}}; - const money=v=>typeof window.money==='function'?window.money(v):`${Math.round(Number(v||0)).toLocaleString('ru-RU')} ₽`; + const money=v=>typeof window.money==='function'?window.money(v):`${Number(v||0).toLocaleString('ru-RU',{maximumFractionDigits:2})} ₽`; const toast=(m,t='success')=>{try{if(window.SunEnterprise?.toast)return window.SunEnterprise.toast(m,t)}catch(_){} if(t==='warn'||t==='error')console.warn(m)}; const style=document.createElement('style');style.id='sun-order-crm-v1-style';style.textContent=` /* The precise-address fields existed in an older module but were hidden by a later polish layer. */ diff --git a/public/core/banquet-menu.js b/public/core/banquet-menu.js index f6ede83..a654c57 100644 --- a/public/core/banquet-menu.js +++ b/public/core/banquet-menu.js @@ -3,13 +3,13 @@ const groups=[['cold','Холодные закуски'],['salads','Салаты'],['starters','Горячие закуски'],['main','Горячее'],['sides','Гарниры'],['desserts','Десерты'],['fruit','Фрукты и ягоды'],['bread','Хлеб и масло'],['other','Другие блюда']]; let packageId='all',groupId='all'; const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); - const money=v=>Math.round(Number(v)||0).toLocaleString('ru-RU')+' ₽'; + const money=v=>Number(v||0).toLocaleString('ru-RU',{maximumFractionDigits:2})+' ₽'; const grams=item=>{const value=String(item?.weight||'').replace(',','.');return Math.round((parseFloat(value)||0)*(/кг/i.test(value)?1000:1))}; const group=item=>{const s=String(item?.catalogSection||'').toLowerCase();return /салат/.test(s)?'salads':/(горяч|тёпл|тепл).*закуск/.test(s)?'starters':/горяч|основн/.test(s)?'main':/гарнир/.test(s)?'sides':/десерт|сладк/.test(s)?'desserts':/фрукт|ягод/.test(s)?'fruit':/хлеб|масло|булоч/.test(s)?'bread':/рыб|мясн|сыр|овощ|холод|закуск/.test(s)?'cold':'other'}; function packages(catalog){const result=new Map();for(const i of catalog){const b=i.banquet;if(b?.packageId&&!result.has(b.packageId))result.set(b.packageId,{id:b.packageId,name:b.packageName||b.packageId,price:b.packagePrice,grams:b.packageGrams})}return [...result.values()]} function selection(draft,catalog){if(!Array.isArray(draft.banquetSelection)){const ids=new Set(catalog.map(i=>String(i.id)));draft.banquetSelection=(draft.lines||[]).filter(l=>ids.has(String(l.id))).map(l=>String(l.id))}return new Set(draft.banquetSelection.map(String))} const guestCount=draft=>Math.max(1,Math.round(Number(draft.guestsCount)||1)); - const unitPrice=(item,draft)=>{const line=(draft.lines||[]).find(l=>String(l.id)===String(item.id));return line&&Number.isFinite(Number(line.price))?Math.max(0,Number(line.price)):Number(item.price)||0}; + const unitPrice=(item,draft)=>{const line=(draft.lines||[]).find(l=>String(l.id)===String(item.id));return line&&Number.isFinite(Number(line.price))?Math.max(0,Number(line.price)):(window.CateriumPricing?.price(item)??Number(item?.price||0))}; function totals(items,draft){return {price:items.reduce((n,i)=>n+unitPrice(i,draft),0),grams:items.reduce((n,i)=>n+grams(i),0)}} function chooseMenu(draft,catalog,id){const rows=catalog.filter(i=>i.banquet?.packageId===id),used=new Set();draft.banquetSelection=rows.filter(i=>{const choice=i.banquet?.choiceGroup;if(!choice)return true;if(used.has(choice))return false;const defaults=rows.some(r=>r.banquet?.choiceGroup===choice&&r.banquet.defaultChoice);if(defaults&&!i.banquet.defaultChoice)return false;used.add(choice);return true}).map(i=>String(i.id));packageId=id;groupId='all'} function toggle(draft,catalog,id){const ids=selection(draft,catalog),item=catalog.find(i=>String(i.id)===id);if(!item)return;if(ids.has(id))ids.delete(id);else{const choice=item.banquet?.choiceGroup;if(choice)for(const old of catalog)if(old.banquet?.packageId===item.banquet.packageId&&old.banquet?.choiceGroup===choice)ids.delete(String(old.id));ids.add(id)}draft.banquetSelection=[...ids]} @@ -29,7 +29,7 @@ ${estimates?'≈ Веса и цены отдельных блюд рассчитаны приблизительно по общей стоимости меню. Их можно изменить в карточке блюда.
':''} ${menu?`${catalog.length?'По вашему запросу блюда не найдены.':'В банкетном меню пока нет блюд. Добавьте свои позиции.'}
`}${catalog.length?'По вашему запросу блюда не найдены.':'В банкетном меню пока нет блюд. Добавьте свои позиции.'}
`}${esc(o.event||'Заказ')}${o.guestsCount?` · ${esc(o.guestsCount)} гостей`:''}
Клиент: ${esc(o.contact||'—')} · ${esc(o.phone||'—')}
Адрес: ${esc(o.address||'—')}
${o.delivery?`Доставка: ${esc(money(o.delivery))}
`:''}${o.courierNote?`| Позиция | Кол. | Цена | Сумма |
|---|---|---|---|
| ${esc(x.name)} | ${x.qty} | ${esc(money(x.price))} | ${esc(money(x.sum))} |
| Позиции не указаны. | |||
${changed?'Исходный выход':'Выход'}: ${num(t.outputGrams)} г${Number(box.category)===6?' · 1 порция':t.pieces?` · ${num(t.pieces)} шт.`:''} · ${Number(box.category)===6?'Цена порции':'Цена бокса'}: ${money(box.price)}
+${changed?'Исходный выход':'Выход'}: ${num(t.outputGrams)} г${Number(box.category)===6?' · 1 порция':t.pieces?` · ${num(t.pieces)} шт.`:''} · ${Number(box.category)===6?'Цена порции':'Цена бокса'}: ${money((window.CateriumPricing?.price(box)??Number(box?.price||0)))}
${changed?'Состав изменён. Закупка и списание используют текущий состав из редактора. Ниже показана исходная учебная ТТК.
':''}| Продукт / упаковка | Ед. | Брутто | Нетто | Цена за ед. | Сумма |
|---|---|---|---|---|---|
| ${esc(r.name)} | ${esc(r.unit)} | ${num(r.gross)} | ${num(r.net)} | ${money(r.unitCost)} | ${money(r.gross*r.unitCost)} |
Продукты и упаковка: ${money(t.ingredientCost)}
Учебные закупочные цены. Работа, доставка и накладные расходы не включены. Расход со склада считается по брутто.
diff --git a/public/index.html b/public/index.html index 5146810..f51a714 100644 --- a/public/index.html +++ b/public/index.html @@ -1,4 +1,4 @@ - - + diff --git a/public/legacy/bootstrap.js b/public/legacy/bootstrap.js index 1c662f8..9f288bb 100644 --- a/public/legacy/bootstrap.js +++ b/public/legacy/bootstrap.js @@ -1 +1 @@ -const sunEsc=window.SunSafe.escapeHTML,sunAttr=window.SunSafe.escapeAttr,sunId=window.SunSafe.idToken,sunImg=window.SunSafe.safeImageSrc,sunInt=v=>Number.isFinite(Number(v))?Math.trunc(Number(v)):0;let boxes=JSON.parse(localStorage.sunBoxes||'null')||[],orders=JSON.parse(localStorage.sunOrders||'[]'),draft={id:0,lines:[]},edited=null;date.value=new Date().toISOString().slice(0,10);for(let i=0;i<48;i++){let t=String(i>>1).padStart(2,'0')+':'+(i%2?'30':'00');time.add(new Option(t,t));}time.value='12:00';function persist(){localStorage.sunBoxes=JSON.stringify(boxes);localStorage.sunOrders=JSON.stringify(orders)}function dataLayer(){return window.CateriumDataV1773||window.CateriumDataV1772||window.CateriumDataV1771||window.CateriumData||window.CateriumDataV1770||null}function persistOrders(reason){let d=dataLayer();if(d?.orders?.replace){d.orders.replace(orders,{persistLocal:true,reason:reason||'legacy.orders'});return}persist()}function persistCatalog(reason){let d=dataLayer();if(d?.catalog?.replace){d.catalog.replace(boxes,{persistLocal:true,reason:reason||'legacy.catalog'});return}persist()}function show(id,b){document.querySelectorAll('.view').forEach(x=>x.classList.remove('on'));document.getElementById(id).classList.add('on');document.querySelectorAll('nav button').forEach(x=>x.classList.remove('on'));b.classList.add('on');renderOrders();renderStats()}function money(n){return Number(n||0).toLocaleString('ru-RU')+' ₽'}function calcOrderTotal(){return draft.lines.reduce((sum,l)=>{let b=boxes.find(x=>x.id==l.id);return sum+(Number(b?.price||0)*Number(l.qty||0))},0)}function updateOrderSummary(){let total=calcOrderTotal(),paid=Math.max(0,Number(prepayment.value||0)),rest=Math.max(0,total-paid);orderTotal.value=total;balance.value=rest;summaryTotal.textContent=money(total);summaryPrepayment.textContent=money(paid);summaryBalance.textContent=money(rest)}function render(){tiles.innerHTML=''+boxes.map(b=>``).join('');lines.innerHTML=draft.lines.length?draft.lines.map(l=>{let b=boxes.find(x=>x.id==l.id);return `Выберите боксы слева.
';updateOrderSummary()}function add(id){let l=draft.lines.find(x=>x.id==id);l?l.qty++:draft.lines.push({id,qty:1});render()}function qty(id,n){if(n<1)draft.lines=draft.lines.filter(x=>x.id!=id);else draft.lines.find(x=>x.id==id).qty=n;render()}function details(on){orderForm.style.display=on?'none':'block';const detailsPanel=document.getElementById('details');detailsPanel.style.display=on?'block':'none';orderTab.classList.toggle('on',!on);detailTab.classList.toggle('on',on)}function saveOrder(){if(!draft.lines.length)return alert('Добавьте хотя бы один бокс.');draft.event=document.getElementById('event').value;draft.date=date.value;draft.time=time.value;draft.contact=contact.value;draft.phone=phone.value;draft.address=address.value;draft.note=note.value;draft.total=calcOrderTotal();draft.prepayment=Math.max(0,Number(prepayment.value||0));draft.balance=Math.max(0,draft.total-draft.prepayment);if(!draft.status)draft.status='Новый';if(!draft.id)draft.id=Math.max(0,...orders.map(x=>x.id))+1;let n=orders.findIndex(x=>x.id==draft.id);n<0?orders.push(structuredClone(draft)):orders[n]=structuredClone(draft);persistOrders('order.save');deleteOrderBtn.style.display='inline-block';renderOrders();updateOrderSummary();alert('Заказ сохранён.');}function orderTotalValue(o){if(Number.isFinite(Number(o.total)))return Number(o.total);return (o.lines||[]).reduce((sum,l)=>{let b=boxes.find(x=>x.id==l.id);return sum+Number(b?.price||0)*Number(l.qty||0)},0)}function renderOrders(){ordersList.innerHTML=orders.length?orders.map(o=>`Сохранённых заказов пока нет.
'}function status(id,s){orders.find(x=>x.id==id).status=s;persistOrders('order.status')}function openOrder(id){draft=structuredClone(orders.find(x=>x.id==id));document.getElementById('event').value=draft.event;date.value=draft.date;time.value=draft.time;contact.value=draft.contact||'';phone.value=draft.phone||'';address.value=draft.address||'';note.value=draft.note||'';prepayment.value=Number(draft.prepayment||0);deleteOrderBtn.style.display='inline-block';show('new',[...document.querySelectorAll('nav button')].find(x=>x.textContent.trim()==='Новый заказ')||document.querySelector('nav button'));render()}function resetDraft(){draft={id:0,lines:[]};document.getElementById('event').value='';date.value=new Date().toISOString().slice(0,10);time.value='12:00';contact.value='';phone.value='';address.value='';note.value='';prepayment.value=0;deleteOrderBtn.style.display='none';render()}function deleteOrderById(id){let o=orders.find(x=>x.id==id);if(!o||!confirm(`Удалить заказ №${id} «${o.event}»?`))return;orders=orders.filter(x=>x.id!=id);persistOrders('order.delete');renderOrders();if(draft.id==id)resetDraft()}function deleteOrder(){if(!draft.id)return;deleteOrderById(draft.id);show('orders',[...document.querySelectorAll('nav button')].find(x=>x.textContent.trim()==='Заказы')||document.querySelectorAll('nav button')[1])}function renderStats(){statsList.innerHTML=boxes.map(b=>`${sunEsc(b.name)} — ${orders.filter(o=>o.status!='Отменён').reduce((n,o)=>n+(o.lines.find(l=>l.id==b.id)?.qty||0),0)} заказано
`).join('')}function modal(x,on=true){document.getElementById(x).classList.toggle('on',on)}function closeModal(x){modal(x,false)}function openManager(){modal('manager');managerList.innerHTML=boxes.map(b=>``).join('')}function editBox(id){edited=id?structuredClone(boxes.find(x=>x.id==id)):{id:'',name:'Новый бокс',price:0,ingredients:[]};editTitle.textContent=edited.id?'Изменить бокс':'Новый бокс';boxName.value=edited.name;boxPrice.value=Number(edited.price||0);editPhoto.style.display=edited.photo?'block':'none';editPhoto.src=sunImg(edited.photo)||'';deleteBox.style.display=edited.id?'block':'none';modal('editor');renderIngredients()}function readPhoto(x){let r=new FileReader();r.onload=()=>{edited.photo=sunImg(r.result);editPhoto.src=edited.photo||'';editPhoto.style.display='block'};r.readAsDataURL(x.files[0])}function renderIngredients(){ingredients.innerHTML=edited.ingredients.map((x,i)=>``).join('')}function addIngredient(){edited.ingredients.push(['Новый продукт',1,'шт.']);renderIngredients()}function saveBox(){edited.name=boxName.value.trim();edited.price=Math.max(0,Number(boxPrice.value||0));if(!edited.name)return alert('Введите название.');if(!edited.id){edited.id=sunUUID();boxes.push(edited)}else boxes[boxes.findIndex(x=>x.id==edited.id)]=edited;persistCatalog('catalog.save');closeModal('editor');closeModal('manager');render();renderOrders();window.sunClientOfferCatalogChanged?.(edited.id)}function removeBox(){if(confirm('Удалить бокс?')){boxes=boxes.filter(x=>x.id!=edited.id);persistCatalog('catalog.delete');closeModal('editor');closeModal('manager');render()}}function openPreview(){let m={};draft.lines.forEach(l=>boxes.find(b=>b.id==l.id).ingredients.forEach(x=>{let k=x[0]+'|'+x[2];m[k]=(m[k]||0)+x[1]*l.qty}));prepTitle.textContent='Заготовки для: '+document.getElementById('event').value;prep.innerHTML=Object.entries(m).map(([k,v])=>{let [p,u]=k.split('|');return `${sunEsc(p)}${sunEsc(v)} ${sunEsc(u)}
`}).join('');modal('preview')}render();updateOrderSummary(); \ No newline at end of file +const sunEsc=window.SunSafe.escapeHTML,sunAttr=window.SunSafe.escapeAttr,sunId=window.SunSafe.idToken,sunImg=window.SunSafe.safeImageSrc,sunInt=v=>Number.isFinite(Number(v))?Math.trunc(Number(v)):0;let boxes=JSON.parse(localStorage.sunBoxes||'null')||[],orders=JSON.parse(localStorage.sunOrders||'[]'),draft={id:0,lines:[]},edited=null;date.value=new Date().toISOString().slice(0,10);for(let i=0;i<48;i++){let t=String(i>>1).padStart(2,'0')+':'+(i%2?'30':'00');time.add(new Option(t,t));}time.value='12:00';function persist(){localStorage.sunBoxes=JSON.stringify(boxes);localStorage.sunOrders=JSON.stringify(orders)}function dataLayer(){return window.CateriumDataV1773||window.CateriumDataV1772||window.CateriumDataV1771||window.CateriumData||window.CateriumDataV1770||null}function persistOrders(reason){let d=dataLayer();if(d?.orders?.replace){d.orders.replace(orders,{persistLocal:true,reason:reason||'legacy.orders'});return}persist()}function persistCatalog(reason){let d=dataLayer();if(d?.catalog?.replace){d.catalog.replace(boxes,{persistLocal:true,reason:reason||'legacy.catalog'});return}persist()}function show(id,b){document.querySelectorAll('.view').forEach(x=>x.classList.remove('on'));document.getElementById(id).classList.add('on');document.querySelectorAll('nav button').forEach(x=>x.classList.remove('on'));b.classList.add('on');renderOrders();renderStats()}function money(n){return Number(n||0).toLocaleString('ru-RU')+' ₽'}function calcOrderTotal(){return draft.lines.reduce((sum,l)=>{let b=boxes.find(x=>x.id==l.id);return sum+(window.CateriumPricing.linePrice(l,b)*Number(l.qty||0))},0)}function updateOrderSummary(){let total=calcOrderTotal(),paid=Math.max(0,Number(prepayment.value||0)),rest=Math.max(0,total-paid);orderTotal.value=total;balance.value=rest;summaryTotal.textContent=money(total);summaryPrepayment.textContent=money(paid);summaryBalance.textContent=money(rest)}function render(){tiles.innerHTML=''+boxes.map(b=>``).join('');lines.innerHTML=draft.lines.length?draft.lines.map(l=>{let b=boxes.find(x=>x.id==l.id);return `Выберите боксы слева.
';updateOrderSummary()}function add(id){let l=draft.lines.find(x=>x.id==id);l?l.qty++:draft.lines.push({id,qty:1});render()}function qty(id,n){if(n<1)draft.lines=draft.lines.filter(x=>x.id!=id);else draft.lines.find(x=>x.id==id).qty=n;render()}function details(on){orderForm.style.display=on?'none':'block';const detailsPanel=document.getElementById('details');detailsPanel.style.display=on?'block':'none';orderTab.classList.toggle('on',!on);detailTab.classList.toggle('on',on)}function saveOrder(){if(!draft.lines.length)return alert('Добавьте хотя бы один бокс.');draft.event=document.getElementById('event').value;draft.date=date.value;draft.time=time.value;draft.contact=contact.value;draft.phone=phone.value;draft.address=address.value;draft.note=note.value;draft.total=calcOrderTotal();draft.prepayment=Math.max(0,Number(prepayment.value||0));draft.balance=Math.max(0,draft.total-draft.prepayment);if(!draft.status)draft.status='Новый';if(!draft.id)draft.id=Math.max(0,...orders.map(x=>x.id))+1;let n=orders.findIndex(x=>x.id==draft.id);n<0?orders.push(structuredClone(draft)):orders[n]=structuredClone(draft);persistOrders('order.save');deleteOrderBtn.style.display='inline-block';renderOrders();updateOrderSummary();alert('Заказ сохранён.');}function orderTotalValue(o){if(Number.isFinite(Number(o.total)))return Number(o.total);return (o.lines||[]).reduce((sum,l)=>{let b=boxes.find(x=>x.id==l.id);return sum+window.CateriumPricing.linePrice(l,b)*Number(l.qty||0)},0)}function renderOrders(){ordersList.innerHTML=orders.length?orders.map(o=>`Сохранённых заказов пока нет.
'}function status(id,s){orders.find(x=>x.id==id).status=s;persistOrders('order.status')}function openOrder(id){draft=structuredClone(orders.find(x=>x.id==id));document.getElementById('event').value=draft.event;date.value=draft.date;time.value=draft.time;contact.value=draft.contact||'';phone.value=draft.phone||'';address.value=draft.address||'';note.value=draft.note||'';prepayment.value=Number(draft.prepayment||0);deleteOrderBtn.style.display='inline-block';show('new',[...document.querySelectorAll('nav button')].find(x=>x.textContent.trim()==='Новый заказ')||document.querySelector('nav button'));render()}function resetDraft(){draft={id:0,lines:[]};document.getElementById('event').value='';date.value=new Date().toISOString().slice(0,10);time.value='12:00';contact.value='';phone.value='';address.value='';note.value='';prepayment.value=0;deleteOrderBtn.style.display='none';render()}function deleteOrderById(id){let o=orders.find(x=>x.id==id);if(!o||!confirm(`Удалить заказ №${id} «${o.event}»?`))return;orders=orders.filter(x=>x.id!=id);persistOrders('order.delete');renderOrders();if(draft.id==id)resetDraft()}function deleteOrder(){if(!draft.id)return;deleteOrderById(draft.id);show('orders',[...document.querySelectorAll('nav button')].find(x=>x.textContent.trim()==='Заказы')||document.querySelectorAll('nav button')[1])}function renderStats(){statsList.innerHTML=boxes.map(b=>`${sunEsc(b.name)} — ${orders.filter(o=>o.status!='Отменён').reduce((n,o)=>n+(o.lines.find(l=>l.id==b.id)?.qty||0),0)} заказано
`).join('')}function modal(x,on=true){document.getElementById(x).classList.toggle('on',on)}function closeModal(x){modal(x,false)}function openManager(){modal('manager');managerList.innerHTML=boxes.map(b=>``).join('')}function editBox(id){edited=id?structuredClone(boxes.find(x=>x.id==id)):{id:'',name:'Новый бокс',price:0,ingredients:[]};editTitle.textContent=edited.id?'Изменить бокс':'Новый бокс';boxName.value=edited.name;boxPrice.value=Number(edited.price||0);editPhoto.style.display=edited.photo?'block':'none';editPhoto.src=sunImg(edited.photo)||'';deleteBox.style.display=edited.id?'block':'none';modal('editor');renderIngredients()}function readPhoto(x){let r=new FileReader();r.onload=()=>{edited.photo=sunImg(r.result);editPhoto.src=edited.photo||'';editPhoto.style.display='block'};r.readAsDataURL(x.files[0])}function renderIngredients(){ingredients.innerHTML=edited.ingredients.map((x,i)=>``).join('')}function addIngredient(){edited.ingredients.push(['Новый продукт',1,'шт.']);renderIngredients()}function saveBox(){edited.name=boxName.value.trim();edited.price=Math.max(0,Number(boxPrice.value||0));if(!edited.name)return alert('Введите название.');if(!edited.id){edited.id=sunUUID();boxes.push(edited)}else boxes[boxes.findIndex(x=>x.id==edited.id)]=edited;persistCatalog('catalog.save');closeModal('editor');closeModal('manager');render();renderOrders();window.sunClientOfferCatalogChanged?.(edited.id)}function removeBox(){if(confirm('Удалить бокс?')){boxes=boxes.filter(x=>x.id!=edited.id);persistCatalog('catalog.delete');closeModal('editor');closeModal('manager');render()}}function openPreview(){let m={};draft.lines.forEach(l=>boxes.find(b=>b.id==l.id).ingredients.forEach(x=>{let k=x[0]+'|'+x[2];m[k]=(m[k]||0)+x[1]*l.qty}));prepTitle.textContent='Заготовки для: '+document.getElementById('event').value;prep.innerHTML=Object.entries(m).map(([k,v])=>{let [p,u]=k.split('|');return `${sunEsc(p)}${sunEsc(v)} ${sunEsc(u)}
`}).join('');modal('preview')}render();updateOrderSummary(); \ No newline at end of file diff --git a/public/service-worker.js b/public/service-worker.js index f2f8c45..2915cba 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,6 +1,7 @@ -const CACHE='sun-catering-pwa-v110-20260918-ui-stability'; +const CACHE='sun-catering-pwa-v110-20260918-ui-stability-20260919-client-menu'; const VERSION='20260918-ui-stability'; const CORE=[ + './core/catalog-pricing.js?v=20260919-client-menu','./core/client-menu.css?v=20260919-client-menu', './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}`, './','./index.html',`./core/mobile-order.js?v=${VERSION}`,`./core/proposal-layout.js?v=${VERSION}`,'./fonts/Manrope.ttf','./fonts/PlayfairDisplay.ttf','./fonts/PlayfairDisplay-Italic.ttf',`./core/trial-demo.js?v=${VERSION}`,`./core/cloud-transport.js?v=${VERSION}`,`./core/banquet-menu.js?v=${VERSION}`,`./core/access-policy.js?v=${VERSION}`,`./core/import-archive.js?v=${VERSION}`,`./core/company-branding.js?v=${VERSION}`,`./core/signature-offer-pdf-v18.js?v=${VERSION}`,`./core/brand-theme.js?v=${VERSION}`, @@ -11,6 +12,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' ]; const CRITICAL_FRESH=new Set([ + '/core/catalog-pricing.js','/core/client-menu.css', '/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/banquet-menu.js','/core/access-policy.js','/core/import-archive.js','/core/company-branding.js','/core/brand-theme.js','/core/sun-safe.js','/core/performance.js','/core/account-center-v1780.js','/core/login-signature-v1776.js','/core/login-signature-v1776.css','/core/auth-security-v1774.js','/legacy/bootstrap.js','/app-runtime.js' diff --git a/tests/client-menu.spec.mjs b/tests/client-menu.spec.mjs new file mode 100644 index 0000000..6b9c70d --- /dev/null +++ b/tests/client-menu.spec.mjs @@ -0,0 +1,131 @@ +import fs from 'node:fs'; +import {test,expect} from '@playwright/test'; + +const NOW='2026-09-19T09:00:00.000Z',WEEK=7*86400000; +const sample={id:'pricing-box',category:0,name:'Праздничный бокс',price:1000,weight:'600 г',pieces:10,ingredients:[['Томаты',1,'кг']]}; +async function fixture(page){ + page.on('dialog',d=>d.accept()); + await page.route('https://**',r=>r.abort()); + await page.route('**/api/index.php*',r=>r.abort()); + await page.addInitScript(item=>{ + const ws='pricing-workspace',user={id:'pricing-user',email:'pricing@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('sunBoxes'))localStorage.setItem('sunBoxes',JSON.stringify([item])); + if(!localStorage.getItem('sunOrders'))localStorage.setItem('sunOrders',JSON.stringify(Array.from({length:32},(_,i)=>({id:i+1,contact:`Клиент ${String(i+1).padStart(2,'0')}`,phone:`+7 900 000 ${String(i).padStart(4,'0')}`,address:`Адрес клиента ${i}`,event:'Фуршет',date:'2099-12-20',time:'12:00',status:'Новый',lines:[{id:item.id,qty:1,price:1000}],total:1000,prepayment:0})))); + 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(){} + })}; + },sample); + await page.clock.setFixedTime(new Date(NOW)); + await page.goto('/index.html',{waitUntil:'domcontentloaded'}); + await expect(page.locator('body > header')).toBeVisible(); + await page.waitForFunction(()=>window.SunOpsUXV1762&&window.CateriumDataV1773&&window.__cateriumOrderEnhancementsV1775); +} +async function edit(page){ + await page.locator('header nav').getByRole('button',{name:'Меню',exact:true}).click(); + await page.locator('[data-menu-item-v1762="pricing-box"]').click(); + await expect(page.locator('#ctPromotionEditor')).toBeVisible(); +} +const saved=page=>page.evaluate(()=>JSON.parse(localStorage.sunBoxes).find(i=>i.id==='pricing-box')); + +// Pure policy: no backend, no timer is needed to expire a closed application's menu. +test('promotion policy validates amounts and dates and never mutates base prices or order snapshots',async({page})=>{ + await page.setContent(''); + await page.addScriptTag({content:fs.readFileSync('public/core/catalog-pricing.js','utf8')}); + const values=await page.evaluate(()=>{ + const P=CateriumPricing,now=Date.parse('2026-09-19T09:00:00Z'),end=new Date(now+7*86400000).toISOString(); + const item={price:999,promotion:{type:'percent',value:12.5,startsAt:new Date(now).toISOString(),endsAt:end}},before=JSON.stringify(item); + return {start:P.price(item,now),beforeEnd:P.price(item,Date.parse(end)-1),atEnd:P.price(item,Date.parse(end)),future:P.price(item,now-1),unchanged:before===JSON.stringify(item),snapshot:P.linePrice({price:700},item,Date.parse(end)),free:P.price({price:999,promotion:{type:'percent',value:100}},now),badAmount:P.price({price:999,promotion:{type:'amount',value:1000}},now),badDate:P.price({price:999,promotion:{type:'amount',value:100,endsAt:'bad'}},now),legacy:P.price({price:800,oldPrice:1000},now),legacyBase:P.basePrice({price:800,oldPrice:1000}),zero:P.linePrice({price:0},item,now)}; + }); + expect(values).toEqual({start:874.13,beforeEnd:874.13,atEnd:999,future:999,unchanged:true,snapshot:700,free:0,badAmount:999,badDate:999,legacy:800,legacyBase:1000,zero:0}); +}); + +test('compact clients retain search, accessible opening and full details',async({page},info)=>{ + await fixture(page); + await page.locator('header nav').getByRole('button',{name:'Клиенты',exact:true}).click(); + const cards=page.locator('#client-list .client-card');await expect(cards).toHaveCount(32); + const size=await cards.first().boundingBox();expect(size.height).toBeLessThanOrEqual(96);expect(size.width).toBeGreaterThanOrEqual(200); + expect(await page.locator('#client-list').evaluate(el=>el.scrollWidth<=el.clientWidth)).toBe(true); + await page.screenshot({path:info.outputPath('compact-clients.png')}); + await page.locator('#client-search').fill('Клиент 07');await expect(cards).toHaveCount(1); + await page.locator('.client-open').click();await expect(page.locator('#client-card-title')).toHaveText('Клиент 07'); + await expect(page.locator('#client-card-content')).toContainText('Адрес клиента 6'); +}); + +test('percent discount lasts a week, survives reload and expires without changing saved orders',async({page},info)=>{ + await fixture(page);await edit(page); + await page.locator('#ctPromotionEnabled').check();await page.locator('#ctDiscountValue').fill('15'); + await expect(page.locator('#ctPromotionDuration')).toHaveValue('7'); + await expect(page.locator('#ctPromotionPreview')).toContainText('850 ₽'); + await page.screenshot({path:info.outputPath('promotion-editor.png')}); + await page.locator('#saveBoxButton').click(); + await expect(page.locator('[data-menu-item-v1762="pricing-box"]')).toContainText('850 ₽'); + const item=await saved(page);expect(item.price).toBe(1000);expect(item.promotion).toEqual({type:'percent',value:15,startsAt:NOW,endsAt:new Date(Date.parse(NOW)+WEEK).toISOString()});expect(item.oldPrice).toBeUndefined(); + await page.locator('header nav').getByRole('button',{name:'Новый заказ',exact:true}).click(); + await page.locator('#tiles .tile:not(.add)').click();expect(await page.evaluate(()=>draft.lines[0].price)).toBe(850); + await page.evaluate(()=>{document.getElementById('event').value='Акционный заказ';document.getElementById('date').value='2099-12-20';window.saveOrder();}); + const order=await page.evaluate(()=>JSON.parse(localStorage.sunOrders).at(-1));expect(order.lines[0].price).toBe(850); + await page.reload();await page.waitForFunction(()=>window.SunOpsUXV1762&&window.CateriumDataV1773);await edit(page); + await expect(page.locator('#boxPrice')).toHaveValue('1000');await expect(page.locator('#ctPromotionDuration')).toHaveValue('keep'); + await page.locator('#boxName').fill('Бокс с прежним сроком');await page.locator('#saveBoxButton').click(); + expect((await saved(page)).promotion.endsAt).toBe(item.promotion.endsAt); + await page.clock.setFixedTime(new Date(Date.parse(NOW)+WEEK)); + await page.evaluate(()=>window.dispatchEvent(new Event('focus'))); + await expect(page.locator('[data-menu-item-v1762="pricing-box"]')).toContainText('1 000 ₽'); + await expect(page.locator('[data-menu-item-v1762="pricing-box"] .ct-promotion-badge')).toHaveCount(0); + expect(await page.evaluate(id=>JSON.parse(localStorage.sunOrders).find(o=>o.id===id),order.id)).toEqual(order); + await page.reload();await page.waitForFunction(()=>window.SunOpsUXV1762);await page.evaluate(()=>window.resetDraft()); + await page.locator('header nav').getByRole('button',{name:'Новый заказ',exact:true}).click(); + await page.locator('#tiles .tile:not(.add)').click();expect(await page.evaluate(()=>draft.lines[0].price)).toBe(1000); +}); + +test('ruble discount supports an exact end, invalid values do not close or save the editor, disabling restores base',async({page})=>{ + await fixture(page);await edit(page); + await page.locator('#ctPromotionEnabled').check();await page.locator('#ctDiscountType').selectOption('amount'); + await page.locator('#ctDiscountValue').fill('1500');await page.locator('#saveBoxButton').click(); + await expect(page.locator('#ctPromotionError')).toContainText('не может превышать');await expect(page.locator('#saveBoxButton')).toBeVisible();expect((await saved(page)).promotion).toBeUndefined(); + await page.locator('#ctDiscountValue').fill('250');await page.locator('#ctPromotionDuration').selectOption('custom'); + await page.locator('#ctPromotionEnd').fill('2026-09-18T09:00');await page.locator('#saveBoxButton').click();await expect(page.locator('#ctPromotionError')).toContainText('закончилась'); + await page.locator('#ctPromotionEnd').fill('2026-10-01T12:00');await page.locator('#saveBoxButton').click(); + await expect(page.locator('[data-menu-item-v1762="pricing-box"]')).toContainText('750 ₽'); + await expect(page.locator('[data-menu-item-v1762="pricing-box"] .ct-promotion-badge')).toHaveText('Акция'); + await expect(page.locator('[data-menu-item-v1762="pricing-box"] s')).toHaveCount(0); + const item=await saved(page);expect(item.promotion.type).toBe('amount');expect(item.promotion.value).toBe(250);expect(item.promotion.endsAt).toBeTruthy(); + await edit(page);await page.locator('#ctPromotionEnabled').uncheck();await page.locator('#saveBoxButton').click(); + expect((await saved(page)).price).toBe(1000);expect((await saved(page)).promotion).toBeUndefined(); + await expect(page.locator('[data-menu-item-v1762="pricing-box"] .ct-promotion-badge')).toHaveCount(0); +}); + +test('timer expires an open catalog and menu while keeping an unsaved editor intact',async({page})=>{ + await fixture(page);await edit(page); + // Use real timers and a near boundary to check the actual scheduling path. + const end=await page.evaluate(()=>{ + const now=Date.now();boxes[0].promotion={type:'amount',value:100,startsAt:new Date(now).toISOString(),endsAt:new Date(now+1000).toISOString()};persist();CateriumPricing.refresh();return now+1000; + }); + await page.locator('#boxName').fill('Несохранённое название');await page.locator('#boxName').focus(); + await page.clock.setFixedTime(new Date(end)); + await expect(page.locator('[data-menu-item-v1762="pricing-box"] .ct-promotion-badge')).toHaveCount(0,{timeout:5000}); + await expect(page.locator('#boxName')).toHaveValue('Несохранённое название');await expect(page.locator('#boxName')).toBeFocused(); + expect((await saved(page)).price).toBe(1000); +}); + + +test('legacy sale converts without changing its current price and fractional discounts keep cents',async({page})=>{ + await fixture(page); + await page.evaluate(()=>{boxes[0].price=800;boxes[0].oldPrice=1000;boxes[0].sale=true;persist();CateriumPricing.refresh();}); + await edit(page); + await expect(page.locator('#boxPrice')).toHaveValue('1000'); + await expect(page.locator('#ctDiscountType')).toHaveValue('amount'); + await expect(page.locator('#ctDiscountValue')).toHaveValue('200'); + await expect(page.locator('#ctPromotionDuration')).toHaveValue('none'); + await page.locator('#saveBoxButton').click(); + const migrated=await saved(page);expect(migrated.price).toBe(1000);expect(migrated.oldPrice).toBeUndefined();expect(migrated.promotion.value).toBe(200); + await edit(page);await page.locator('#boxPrice').fill('999');await page.locator('#ctDiscountType').selectOption('percent');await page.locator('#ctDiscountValue').fill('12.5');await page.locator('#saveBoxButton').click(); + await page.locator('header nav').getByRole('button',{name:'Новый заказ',exact:true}).click();await page.locator('#tiles .tile:not(.add)').click(); + expect(await page.evaluate(()=>draft.lines[0].price)).toBe(874.13); + await expect(page.locator('#tiles .tile:not(.add) .tile-price')).toContainText('874,13'); + expect(await page.evaluate(()=>window.sunBaseOrderTotal(draft))).toBe(874.13); +}); diff --git a/tests/playwright.config.mjs b/tests/playwright.config.mjs index 9c12877..216620c 100644 --- a/tests/playwright.config.mjs +++ b/tests/playwright.config.mjs @@ -2,13 +2,13 @@ import { defineConfig, devices } from '@playwright/test'; import {fileURLToPath} from 'node:url'; export default defineConfig({ testDir:'.', - testMatch:['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:['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, 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}, 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-webkit',testMatch:['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:['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:'mobile-390',use:{viewport:{width:390,height:844},isMobile:true,hasTouch:true}} ] diff --git a/tests/production-ui-smoke.mjs b/tests/production-ui-smoke.mjs index 05de96d..70730f2 100644 --- a/tests/production-ui-smoke.mjs +++ b/tests/production-ui-smoke.mjs @@ -22,13 +22,15 @@ try{ }); await context.addInitScript(()=>{ const workspaceId='ui-smoke-workspace'; + localStorage.setItem('sunBoxes',JSON.stringify([{id:'ui-smoke-box',name:'Праздничный бокс',category:0,price:1000,ingredients:[],weight:'600 г',pieces:12}])); + localStorage.setItem('sunOrders',JSON.stringify(Array.from({length:24},(_,i)=>({id:i+1,contact:`Клиент ${String(i+1).padStart(2,'0')}`,phone:`+7 900 000 ${String(i).padStart(4,'0')}`,event:'Фуршет',address:'Адрес клиента',date:'2099-12-20',time:'12:00',status:'Новый',lines:[{id:'ui-smoke-box',qty:1,price:1000}],total:1000})))); const user={id:'ui-smoke-user',email:'ui-smoke@example.invalid',email_confirmed_at:'2026-01-01T00:00:00Z'}; localStorage.setItem('sunCloudV2Config',JSON.stringify({workspaceId,localWorkspaceId:workspaceId,tenantStorageReady:true,autoSync:false})); window.supabase={createClient:()=>({ auth:{onAuthStateChange:()=>({data:{subscription:{unsubscribe(){}}}}),getSession:async()=>({data:{session:{user}},error:null}),getUser:async()=>({data:{user},error:null})}, rpc:async name=>{ if(name==='sun_my_workspaces')return new Promise(resolve=>{window.finishUiSmokeWorkspace=()=>resolve({data:[{id:workspaceId,name:'UI verification',role:'admin',is_active:true,permissions:{}}],error:null});}); - if(name==='sun_subscription_snapshot')return {data:{plan_id:'full',plan_name:'Full',status:'active',access_mode:'full',features:{}},error:null}; + if(name==='sun_subscription_snapshot')return {data:{plan_id:'full',plan_name:'Full',status:'active',access_mode:'full',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]))},error:null}; if(name==='caterium_trial_demo_status')return {data:{canInstall:false,canUpgrade:false},error:null}; if(name==='sun_is_platform_admin')return {data:false,error:null}; return {data:null,error:null}; @@ -37,6 +39,7 @@ try{ })}; }); const page=await context.newPage(); + await page.clock.setFixedTime(new Date()); await page.goto(base.href,{waitUntil:'domcontentloaded',timeout:60000}); const gate=page.locator('#sunCloudAuthGateV3'); await expect(gate).toHaveAttribute('data-auth-state','loading',{timeout:20000}); @@ -71,8 +74,37 @@ try{ await expect.poll(()=>page.locator('#ctHelpResults details').count()).toBeGreaterThan(0); await page.locator('#ctHelpClose').click(); await expect(page.locator('#ctHelpDialog')).not.toBeVisible(); - results.push({width,quietLoading:true,automaticOpen:true,helpDialog:true,icon}); - console.log(`PASS published UI ${width}px: quiet loading, automatic open, Help dialog${icon?', native question-circle icon':''}`); + await page.waitForFunction(()=>window.SunOpsUXV1762&&window.CateriumDataV1773&&window.CateriumPricing); + const nav=page.locator('header nav'); + await nav.getByRole('button',{name:'Клиенты',exact:true}).click(); + const clients=page.locator('#client-list .client-card'); + await expect(clients).toHaveCount(24); + const clientSize=await clients.first().boundingBox(); + assert(clientSize.height<=96); + await page.screenshot({path:`${output}/clients-${width}.png`}); + await nav.getByRole('button',{name:'Меню',exact:true}).click(); + await page.locator('[data-menu-item-v1762="ui-smoke-box"]').click(); + await page.locator('#ctPromotionEnabled').check(); + await page.locator('#ctDiscountType').selectOption('percent'); + await page.locator('#ctDiscountValue').fill('15'); + await expect(page.locator('#ctPromotionPreview')).toContainText('850 ₽'); + await page.locator('#ctPromotionEditor').screenshot({path:`${output}/promotion-editor-${width}.png`}); + await page.locator('#saveBoxButton').click(); + const row=page.locator('[data-menu-item-v1762="ui-smoke-box"]'); + await expect(row).toContainText('850 ₽'); + await expect(row.locator('.ct-promotion-badge')).toHaveText('Акция'); + await row.screenshot({path:`${output}/promotion-card-${width}.png`}); + const beforeOrders=await page.evaluate(()=>localStorage.sunOrders); + const promo=await page.evaluate(()=>JSON.parse(localStorage.sunBoxes)[0]); + assert.equal(promo.price,1000); + assert.equal(Date.parse(promo.promotion.endsAt)-Date.parse(promo.promotion.startsAt),7*86400000); + await page.clock.setFixedTime(new Date(promo.promotion.endsAt)); + await page.evaluate(()=>window.dispatchEvent(new Event('focus'))); + await expect(row).toContainText('1 000 ₽'); + await expect(row.locator('.ct-promotion-badge')).toHaveCount(0); + assert.equal(await page.evaluate(()=>localStorage.sunOrders),beforeOrders); + results.push({width,quietLoading:true,automaticOpen:true,helpDialog:true,icon,clientSize,clients:24,percentPromotion:true,automaticExpiry:true,savedOrdersUnchanged:true}); + console.log(`PASS published UI ${width}px: quiet loading, automatic open, Help dialog, compact clients and timed discount${icon?', native question-circle icon':''}`); }finally{await context.close();} } await fs.writeFile(`${output}/result.json`,JSON.stringify({base:base.href,checkedAt:new Date().toISOString(),backend:'mocked and network-blocked',results},null,2));