From 424bf8ce87d732ed1a49ad3faa92106c39f00e5e Mon Sep 17 00:00:00 2001 From: pavlov346346-source Date: Sat, 19 Sep 2026 09:34:50 +0300 Subject: [PATCH] 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. --- .github/workflows/deploy-timeweb.yml | 9 +- .../releases/2026-09-19-CLIENTS-PROMOTIONS.md | 30 ++++ package.json | 2 +- public/app-runtime.js | 43 +++--- public/core/banquet-menu.js | 6 +- public/core/catalog-pricing.js | 112 +++++++++++++++ public/core/client-menu.css | 30 ++++ public/core/data-layer-v1773.js | 2 +- public/core/ops-ux-v1762.js | 13 +- public/core/performance.js | 2 +- public/core/trial-demo.js | 4 +- public/index.html | 110 +++++++-------- public/legacy/bootstrap.js | 2 +- public/service-worker.js | 4 +- tests/client-menu.spec.mjs | 131 ++++++++++++++++++ tests/playwright.config.mjs | 4 +- tests/production-ui-smoke.mjs | 38 ++++- 17 files changed, 443 insertions(+), 99 deletions(-) create mode 100644 docs/releases/2026-09-19-CLIENTS-PROMOTIONS.md create mode 100644 public/core/catalog-pricing.js create mode 100644 public/core/client-menu.css create mode 100644 tests/client-menu.spec.mjs 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 `
${esc(String(item.name||'').replace(/^(?:Фуршетный\s+)?бокс\s*№\s*(\d+)\s*(?:[—–-]\s*)?/i,'№ $1 '))}${isBox&&(item.weight||inferBoxPieces(item))?`${item.weight?`Вес: ${esc(item.weight)}`:''}${item.weight&&inferBoxPieces(item)?' · ':''}${inferBoxPieces(item)?`Количество: ${inferBoxPieces(item)} шт.`:''}`:''}
${money(price*qty)}
`}).join(''); + 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 `
${esc(String(item.name||'').replace(/^(?:Фуршетный\s+)?бокс\s*№\s*(\d+)\s*(?:[—–-]\s*)?/i,'№ $1 '))}${isBox&&(item.weight||inferBoxPieces(item))?`${item.weight?`Вес: ${esc(item.weight)}`:''}${item.weight&&inferBoxPieces(item)?' · ':''}${inferBoxPieces(item)?`Количество: ${inferBoxPieces(item)} шт.`:''}`:''}
${money(price*qty)}
`}).join(''); $('lines').innerHTML=`
НаименованиеКол.ЦенаСтоимость
${rows}
Стоимость позиций${money(baseTotal(draft))}
`; } - window.sunOrderLinePriceChanged=(index,input)=>{const line=draft.lines?.[index];if(!line)return;line.price=Math.max(0,Math.round(Number(input.value||0)));const row=input.closest('.sun-order-line-row'),sum=row?.querySelector('.sun-order-line-sum b');if(sum)sum.textContent=money(line.price*Math.max(0,Number(line.qty||0)));const total=$('lines')?.querySelector('.sun-order-line-total b');if(total)total.textContent=money(baseTotal(draft));updateOrderSummary();}; + window.sunOrderLinePriceChanged=(index,input)=>{const line=draft.lines?.[index];if(!line)return;const value=Number(String(input.value||0).replace(',','.'));if(!Number.isFinite(value)){input.value=String(lineUnitPrice(line));return;}line.price=Math.max(0,Math.round(value*100)/100);const row=input.closest('.sun-order-line-row'),sum=row?.querySelector('.sun-order-line-sum b');if(sum)sum.textContent=money(line.price*Math.max(0,Number(line.qty||0)));const total=$('lines')?.querySelector('.sun-order-line-total b');if(total)total.textContent=money(baseTotal(draft));updateOrderSummary();}; function boxNumber(item){const explicit=Number(item?.boxNumber);if(Number.isInteger(explicit)&&explicit>=0)return explicit;const name=String(item?.name||'');const match=name.match(/(?:бокс\s*)?№\s*0*(\d+)/i)||name.match(/^\s*№\s*0*(\d+)/i);return match?Number(match[1]):null} function wantedBoxNumber(query){const raw=String(query||'').trim();if(!raw)return null;const match=raw.match(/^(?:бокс\s*)?(?:№\s*)?0*(\d+)\s*$/i);return match?Number(match[1]):null} function itemMatches(item,query){if(!query)return true;if(Number(activeCat)===0){const wanted=wantedBoxNumber(query);return wanted!==null&&boxNumber(item)===wanted}const q=query.trim().toLowerCase();const parts=[item.name,item.catalogSection,item.weight,...(Array.isArray(item.composition)?item.composition:[]),...(Array.isArray(item.ingredients)?item.ingredients.map(x=>x?.[0]):[])];return parts.join(' ').toLowerCase().includes(q)} @@ -440,19 +440,24 @@ window.SUN_LEGACY_CATALOG_V175=[]; function banquetCatalog(){return boxes.filter(item=>item.hidden!==true&&Number(item.category)===BANQUET_CATEGORY)} window.toggleBanquetItem=id=>{window.CateriumBanquet.toggle(draft,banquetCatalog(),String(id));renderCatalogV5();}; function renderBanquetView(items){return window.CateriumBanquet.render({items,catalog:banquetCatalog(),draft})} - function renderCatalogV5(){ + function renderCatalogV5({catalogOnly=false}={}){ $('new')?.classList.toggle('sun-banquet-active',Number(activeCat)===BANQUET_CATEGORY); renderCategoryTabs();ensureCatalogTools();const catalogSearch=$('sunCatalogSearch');if(catalogSearch){const boxMode=[0,5].includes(Number(activeCat));catalogSearch.placeholder=boxMode?'Поиск по № бокса':'Поиск по позиции';catalogSearch.inputMode=boxMode?'numeric':'search';}const title=document.querySelector('#new .catalog h1');if(title)title.textContent=catName(activeCat); const all=boxes.filter(item=>item.hidden!==true&&Number(item.category||0)===Number(activeCat)),items=all.filter(item=>itemMatches(item,catalogQuery));const cat=catById(activeCat)||{name:'Каталог',prep:true}; - if(Number(activeCat)===BANQUET_CATEGORY){$('tiles').innerHTML=renderBanquetView(items);window.CateriumBanquet.bind($('tiles'),{catalog:banquetCatalog(),draft,rerender:renderCatalogV5});renderOrderLines();updateOrderSummary();return;} - const cards=items.map(item=>``).join(''); + if(Number(activeCat)===BANQUET_CATEGORY){$('tiles').innerHTML=renderBanquetView(items);window.CateriumBanquet.bind($('tiles'),{catalog:banquetCatalog(),draft,rerender:renderCatalogV5});if(!catalogOnly){renderOrderLines();updateOrderSummary();}return;} + const cards=items.map(item=>``).join(''); const addText=Number(activeCat)===0?'Добавить бокс':(Number(activeCat)===5?'Добавить премиум':'Добавить позицию'); const empty=items.length?'':`
${catalogQuery?'По вашему запросу ничего не найдено.':`В разделе «${esc(cat.name)}» пока нет позиций.`}
`; $('tiles').innerHTML=`${cards}${empty}`; - renderOrderLines();updateOrderSummary(); + if(!catalogOnly){renderOrderLines();updateOrderSummary();} } + window.addEventListener('caterium:catalog-prices-changed',()=>{ + if($('new')?.classList.contains('on'))renderCatalogV5({catalogOnly:true}); + if($('manager')?.classList.contains('on'))renderManagerV5(); + }); + window.CateriumPricing?.watch(()=>boxes); window.render=renderCatalogV5; - window.add=id=>{const item=boxes.find(x=>String(x.id)===String(id));if(!item||item.hidden===true)return;let line=(draft.lines||[]).find(x=>String(x.id)===String(id));if(line)line.qty=Math.max(0,Number(line.qty||0))+1;else{if(!Array.isArray(draft.lines))draft.lines=[];draft.lines.push({id:item.id,qty:1,price:Math.max(0,Number(item.price||0))})}renderCatalogV5();}; + window.add=id=>{const item=boxes.find(x=>String(x.id)===String(id));if(!item||item.hidden===true)return;let line=(draft.lines||[]).find(x=>String(x.id)===String(id));if(line)line.qty=Math.max(0,Number(line.qty||0))+1;else{if(!Array.isArray(draft.lines))draft.lines=[];draft.lines.push({id:item.id,qty:1,price:Math.max(0,window.CateriumPricing.price(item))})}renderCatalogV5();}; window.qty=(id,n)=>{if(!Array.isArray(draft.lines))draft.lines=[];const next=Math.max(0,Number(n||0));if(next<1)draft.lines=draft.lines.filter(x=>String(x.id)!==String(id));else{const line=draft.lines.find(x=>String(x.id)===String(id));if(line)line.qty=next}renderCatalogV5();}; function ensureManagerToolbar(){ @@ -462,16 +467,16 @@ window.SUN_LEGACY_CATALOG_V175=[]; function renderManagerV5(){ ensureManagerToolbar();const managerSearch=$('sunManagerSearch');if(managerSearch){const boxMode=[0,5].includes(Number(activeCat));managerSearch.placeholder=boxMode?'Поиск по № бокса':'Поиск по позиции';managerSearch.inputMode=boxMode?'numeric':'search';}const title=document.querySelector('#manager .dialog-head h2'),add=document.querySelector('#manager .sun-manager-toolbar .primary')||document.querySelector('#manager .primary');if(title)title.textContent=catName(activeCat);if(add){add.textContent=Number(activeCat)===0?'Добавить бокс':(Number(activeCat)===5?'Добавить премиум':'Добавить позицию');add.onclick=()=>window.editBox(null)} const items=boxes.filter(i=>i.hidden!==true&&Number(i.category||0)===Number(activeCat)&&itemMatches(i,managerQuery)),cat=catById(activeCat)||{prep:true}; - $('managerList').innerHTML=items.length?items.map(item=>``).join(''):'

Ничего не найдено.

'; + $('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?`
${esc(menu.name)} · горячее — одно блюдо на выбор
`:''}
${available.map(([id,label])=>``).join('')}
-
${visible.length?[...sections].map(([section,rows])=>`

${esc(section)}${rows.some(i=>i.banquet?.choiceGroup)?'Одно блюдо на выбор в каждом пакете':''}

${rows.map(i=>`
${i.demo&&i.ttk?``:''}
`).join('')}
`).join(''):`

${catalog.length?'По вашему запросу блюда не найдены.':'В банкетном меню пока нет блюд. Добавьте свои позиции.'}

`}
+
${visible.length?[...sections].map(([section,rows])=>`

${esc(section)}${rows.some(i=>i.banquet?.choiceGroup)?'Одно блюдо на выбор в каждом пакете':''}

${rows.map(i=>`
${i.demo&&i.ttk?``:''}
`).join('')}
`).join(''):`

${catalog.length?'По вашему запросу блюда не найдены.':'В банкетном меню пока нет блюд. Добавьте свои позиции.'}

`}
`; } function bind(root,context){ diff --git a/public/core/catalog-pricing.js b/public/core/catalog-pricing.js new file mode 100644 index 0000000..eccac6a --- /dev/null +++ b/public/core/catalog-pricing.js @@ -0,0 +1,112 @@ +/* Catalog prices are derived, never overwritten by a timer. Stored order lines + keep their own price snapshots. Promotion dates are absolute UTC instants. */ +(()=>{ + 'use strict'; + const DAY=86400000,MAX_TIMER=2147480000; + const number=v=>typeof v==='number'?v:Number(String(v??'').trim().replace(',','.')); + const cents=v=>Math.round((v+Number.EPSILON)*100)/100; + const validPrice=v=>Number.isFinite(number(v))&&number(v)>=0; + const money=v=>`${Number(v).toLocaleString('ru-RU',{maximumFractionDigits:2})} ₽`; + const stamp=v=>typeof v==='string'&&v.trim()?Date.parse(v):NaN; + const $=id=>document.getElementById(id); + function basePrice(item){ + const current=validPrice(item?.price)?cents(number(item.price)):0; + // Compatibility only: old records stored the discounted price in price. + return !item?.promotion&&validPrice(item?.oldPrice)&&number(item.oldPrice)>current?cents(number(item.oldPrice)):current; + } + function promotion(item){ + if(item?.promotion&&typeof item.promotion==='object')return item.promotion; + const base=basePrice(item),current=validPrice(item?.price)?cents(number(item.price)):0; + return base>current?{type:'amount',value:cents(base-current),startsAt:null,endsAt:null}:null; + } + function quote(item,now=Date.now()){ + const base=basePrice(item),p=promotion(item),value=number(p?.value); + const start=p?.startsAt==null?-Infinity:stamp(p.startsAt),end=p?.endsAt==null?Infinity:stamp(p.endsAt); + const valid=Boolean(p&&['percent','amount'].includes(p.type)&&Number.isFinite(value)&&value>0&&base>0&&value<=(p.type==='percent'?100:base)&&!Number.isNaN(start)&&!Number.isNaN(end)&&end>start); + const active=valid&&now>=start&&now=end}; + } + const price=(item,now)=>quote(item,now).price; + function linePrice(line,item,now){return line?.price!==undefined&&line.price!==null&&String(line.price).trim()!==''&&validPrice(line.price)?cents(number(line.price)):price(item,now)} + const badge=(item)=>quote(item).active?'Акция':''; + const localTime=iso=>{const d=new Date(iso);if(!Number.isFinite(d.getTime()))return '';return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}T${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`}; + let editorOriginal=null,getter=null,timer=0,lastSignature=''; + function loadEditor(item){ + const p=promotion(item),q=quote(item); + editorOriginal=p?{...p}:null; + if(!$('ctPromotionEnabled'))return; + $('boxPrice').value=basePrice(item); + $('ctPromotionEnabled').checked=Boolean(p&&!q.expired); + $('ctDiscountType').value=p?.type==='amount'?'amount':'percent'; + $('ctDiscountValue').value=p?.value??''; + const keep=$('ctPromotionDuration').querySelector('[value="keep"]'); + keep.hidden=!(p?.endsAt&&!q.expired); + $('ctPromotionDuration').value=p&&!q.expired?(p.endsAt?'keep':'none'):'7'; + $('ctPromotionEnd').value=p?.endsAt?localTime(p.endsAt):''; + $('ctPromotionError').textContent=''; + updateEditor(); + } + function readEditor(now=Date.now()){ + const base=number($('boxPrice')?.value); + if(!Number.isFinite(base)||base<0)return {error:'Укажите обычную цену: число не меньше нуля.',field:'boxPrice'}; + if(!$('ctPromotionEnabled')?.checked)return {base:cents(base),promotion:null}; + const type=$('ctDiscountType').value,raw=$('ctDiscountValue').value,value=number(raw); + if(!raw.trim()||!Number.isFinite(value)||cents(value)<=0)return {error:'Укажите скидку больше нуля.',field:'ctDiscountValue'}; + if(base<=0||value>(type==='percent'?100:base))return {error:type==='percent'?'Скидка должна быть не больше 100%, а обычная цена — больше нуля.':'Скидка не может превышать обычную цену.',field:'ctDiscountValue'}; + const duration=$('ctPromotionDuration').value; + let startsAt=editorOriginal?.startsAt||new Date(now).toISOString(),endsAt=null; + if(duration==='keep')endsAt=editorOriginal?.endsAt; + else if(duration==='custom'){ + const rawEnd=$('ctPromotionEnd').value,t=new Date(rawEnd).getTime(); + if(!rawEnd||!Number.isFinite(t))return {error:'Выберите дату и время окончания акции.',field:'ctPromotionEnd'}; + endsAt=new Date(t).toISOString();startsAt=new Date(now).toISOString(); + }else if(duration!=='none'){ + if(!['1','7','14','30'].includes(duration))return {error:'Выберите срок акции.',field:'ctPromotionDuration'}; + startsAt=new Date(now).toISOString();endsAt=new Date(now+Number(duration)*DAY).toISOString(); + } + if(endsAt&&stamp(endsAt)<=now)return {error:'Акция уже закончилась. Выберите новый срок или выключите её.',field:'ctPromotionDuration'}; + return {base:cents(base),promotion:{type,value:cents(value),startsAt,endsAt}}; + } + function updateEditor(){ + const enabled=$('ctPromotionEnabled')?.checked,fields=$('ctPromotionFields');if(!fields)return; + fields.hidden=!enabled; + $('ctPromotionEndLabel').hidden=$('ctPromotionDuration').value!=='custom'; + const type=$('ctDiscountType').value; + $('ctDiscountCaption').textContent=type==='percent'?'Скидка, %':'Скидка, ₽'; + $('ctDiscountValue').max=type==='percent'?'100':String(number($('boxPrice').value)||0); + const data=readEditor(),preview=$('ctPromotionPreview'); + if(data.error){preview.textContent=enabled?data.error:'';return;} + const q=quote({price:data.base,promotion:data.promotion}); + const ending=data.promotion?.endsAt?` До ${new Date(data.promotion.endsAt).toLocaleString('ru-RU',{day:'numeric',month:'long',hour:'2-digit',minute:'2-digit'})}. Затем снова ${money(data.base)}.`:' Без ограничения срока.'; + preview.textContent=enabled?`Цена по акции: ${money(q.price)}.${ending}`:`Обычная цена: ${money(data.base)}.`; + } + function saveEditor(item){ + const data=readEditor(); + if(data.error){$('ctPromotionError').textContent=data.error;$(data.field)?.focus();return false;} + item.price=data.base; + if(data.promotion)item.promotion=data.promotion;else delete item.promotion; + delete item.oldPrice;delete item.sale; + $('ctPromotionError').textContent='';return true; + } + function refresh(){ + clearTimeout(timer);timer=0;if(!getter)return; + const now=Date.now(),items=getter()||[]; + const signature=JSON.stringify(items.map(i=>[String(i.id),price(i,now),quote(i,now).active])); + if(signature!==lastSignature){lastSignature=signature;window.dispatchEvent(new CustomEvent('caterium:catalog-prices-changed'));} + let next=Infinity; + for(const item of items){const p=promotion(item);for(const t of [stamp(p?.startsAt),stamp(p?.endsAt)])if(t>now)next=Math.min(next,t);} + if(Number.isFinite(next))timer=setTimeout(refresh,Math.min(MAX_TIMER,Math.max(1,next-Date.now()+10))); + } + function watch(catalogGetter){getter=catalogGetter;refresh();} + function boot(){ + $('ctPromotionEditor')?.addEventListener('input',()=>{if($('ctPromotionError'))$('ctPromotionError').textContent='';updateEditor();}); + $('ctPromotionEditor')?.addEventListener('change',updateEditor); + $('boxPrice')?.addEventListener('input',updateEditor); + window.addEventListener('sun:cloud-state-applied',refresh); + window.addEventListener('focus',refresh); + document.addEventListener('visibilitychange',()=>{if(!document.hidden)refresh();}); + window.addEventListener('sun:cloud-tenant-changing',()=>{clearTimeout(timer);timer=0;lastSignature='';editorOriginal=null;}); + } + window.CateriumPricing=Object.freeze({basePrice,promotion,quote,price,linePrice,badge,loadEditor,saveEditor,refresh,watch}); + if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot(); +})(); diff --git a/public/core/client-menu.css b/public/core/client-menu.css new file mode 100644 index 0000000..5a02a21 --- /dev/null +++ b/public/core/client-menu.css @@ -0,0 +1,30 @@ +/* Compact client summaries. Full addresses, orders and loyalty remain in the + existing detail dialog. The entire summary is a keyboard-accessible button. */ +#clients #client-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:8px;align-items:start} +#clients .client-card{min-width:0;margin:0;padding:0;border-radius:10px;overflow:hidden;background:var(--sun-ui-card,#fff);border:1px solid var(--sun-ui-border,#d9dde0);box-shadow:none} +#clients .client-open{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 8px;align-content:center;width:100%;min-height:82px;margin:0;padding:10px 12px;border:0;border-radius:9px;background:transparent;color:var(--sun-ui-text,#24211d);text-align:left;font:inherit;cursor:pointer} +#clients .client-open:hover{background:color-mix(in srgb,var(--sun-ui-accent,#c99a32) 8%,var(--sun-ui-card,#fff))} +#clients .client-open:focus-visible{outline:2px solid var(--sun-ui-accent,#c99a32);outline-offset:-3px} +#clients .client-name{font-size:14px;line-height:18px;font-weight:700;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +#clients .client-phone{grid-column:1/-1;font-size:12px;line-height:15px;color:var(--sun-ui-muted,#68717a);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +#clients .client-brief{grid-column:1/-1;font-size:12px;line-height:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +#clients .client-loyalty-compact{align-self:center;padding:2px 5px;border-radius:6px;background:#f1ecff;color:#5b49ad;font-size:11px;line-height:14px;font-weight:700} +#clients .client-open-arrow{align-self:center;font-size:18px;line-height:18px;color:var(--sun-ui-muted,#68717a)} +#ctPromotionEditor{min-width:0;padding:12px;margin:12px 0;border:1px solid var(--sun-ui-border,#deded6);border-radius:12px;background:var(--sun-ui-card,#fff)} +#ctPromotionEditor legend{font-weight:700;font-size:14px;padding:0 5px} +#ctPromotionEditor .ct-promotion-toggle{display:flex;flex-direction:row;align-items:center;gap:8px;margin:0;font-size:14px;cursor:pointer} +#ctPromotionEditor #ctPromotionEnabled{width:18px!important;height:18px;min-height:0;margin:0;flex:0 0 18px} +#ctPromotionFields{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:12px} +#ctPromotionFields label{display:flex;flex-direction:column;gap:5px;margin:0;min-width:0} +#ctPromotionFields input,#ctPromotionFields select{width:100%;min-width:0;min-height:40px;box-sizing:border-box;font:inherit} +#ctPromotionFields .ct-promotion-wide{grid-column:1/-1} +#ctPromotionEditor [hidden]{display:none!important} +#ctPromotionEditor .ct-promotion-note{font-size:11px;line-height:1.4;color:var(--sun-ui-muted,#68717a);margin:7px 0 0} +#ctPromotionPreview{font-size:13px;line-height:1.45;margin:10px 0 0;color:var(--sun-ui-text,#24211d)} +#ctPromotionError{font-size:13px;line-height:1.4;color:#a12f24;margin:8px 0 0} +#ctPromotionError:empty{display:none} +.ct-promotion-badge{display:inline-flex;align-items:center;align-self:start;width:auto!important;padding:3px 7px;border-radius:999px;background:#b9473f;color:#fff!important;font-size:10px!important;line-height:1.2!important;font-weight:800!important;white-space:nowrap;letter-spacing:.02em} +#tiles .tile>.ct-promotion-badge{position:absolute;top:7px;right:7px;z-index:2} +.sun-menu-row-price .ct-promotion-badge{display:table;margin:4px 0 0 auto} +.ct-banquet-name .ct-promotion-badge{margin-left:6px;vertical-align:middle} +@media(max-width:480px){#clients #client-list{grid-template-columns:minmax(0,1fr)}#clients .client-open{min-height:80px}#ctPromotionFields{grid-template-columns:minmax(0,1fr)}} diff --git a/public/core/data-layer-v1773.js b/public/core/data-layer-v1773.js index b9482e7..cca3763 100644 --- a/public/core/data-layer-v1773.js +++ b/public/core/data-layer-v1773.js @@ -92,7 +92,7 @@ const orderStamp=order=>`${String(order?.date||'').padStart(10,'0')}T${String(order?.time||'').padStart(5,'0')}`; function orderTotal(order){ const explicit=Number(order?.total);if(Number.isFinite(explicit))return explicit; - const catalog=getCatalog();return (Array.isArray(order?.lines)?order.lines:[]).reduce((sum,line)=>{const item=catalog.find(x=>String(x?.id)===String(line?.id));return sum+Number(item?.price||0)*Number(line?.qty||0)},0); + const catalog=getCatalog();return (Array.isArray(order?.lines)?order.lines:[]).reduce((sum,line)=>{const item=catalog.find(x=>String(x?.id)===String(line?.id));return sum+(window.CateriumPricing?.linePrice(line,item)??Number(line?.price??item?.price??0))*Number(line?.qty||0)},0); } function readObject(key){const value=storage.read(key,{});return value&&typeof value==='object'&&!Array.isArray(value)?value:{}} function cacheCore(profile){return {key:profile.key,name:String(profile.name||''),phone:String(profile.phone||''),latestAddress:String(profile.latestAddress||''),loyalty:profile.loyalty?clone(profile.loyalty):null,communication:profile.communication?clone(profile.communication):null,serverVersion:Number(profile.serverVersion||0)||null,serverUpdatedAt:String(profile.serverUpdatedAt||'')}} diff --git a/public/core/ops-ux-v1762.js b/public/core/ops-ux-v1762.js index 54cbcc7..a2e3bec 100644 --- a/public/core/ops-ux-v1762.js +++ b/public/core/ops-ux-v1762.js @@ -20,7 +20,7 @@ const qa=(s,r=document)=>[...r.querySelectorAll(s)]; const esc=v=>window.SunSafe?.escapeHTML?window.SunSafe.escapeHTML(String(v??'')):String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const toast=(text,type='info',ms=4500)=>{try{return window.SunEnterprise?.toast?.(text,type,ms)}catch(_){}console[type==='error'?'error':'log'](text)}; - const money=n=>`${Math.round(Number(n||0)).toLocaleString('ru-RU')} ₽`; + const money=n=>`${Number(n||0).toLocaleString('ru-RU',{maximumFractionDigits:2})} ₽`; const readJson=(key,fallback)=>{try{const v=JSON.parse(localStorage.getItem(key)||'');return v??fallback}catch(_){return fallback}}; const writeJson=(key,value)=>localStorage.setItem(key,JSON.stringify(value)); const orders=()=>{const v=readJson('sunOrders',[]);return Array.isArray(v)?v:[]}; @@ -103,7 +103,7 @@ function orderTotal(order){ if(Number.isFinite(Number(order?.total))&&Number(order.total)>0)return Number(order.total); const catalog=new Map(boxes().map(x=>[String(x.id),x])); - return (order?.lines||[]).reduce((sum,line)=>{const live=catalog.get(String(line.id));const p=Number.isFinite(Number(line.price))?Number(line.price):Number(live?.price||0);return sum+p*Math.max(0,Number(line.qty||0));},0); + return (order?.lines||[]).reduce((sum,line)=>{const live=catalog.get(String(line.id));const p=Number.isFinite(Number(line.price))?Number(line.price):(window.CateriumPricing?.price(live)??Number(live?.price||0));return sum+p*Math.max(0,Number(line.qty||0));},0); } function showCalendarDay(cell){ const date=calendarCellDate(cell);if(!date)return; @@ -127,7 +127,7 @@ function showReadOnlyMenuItem(item){ restoreEditorDialog();const pane=$('sunMenuDetailV1762');if(!pane)return; const comp=Array.isArray(item.composition)?item.composition.filter(Boolean):[],ingredients=Array.isArray(item.ingredients)?item.ingredients:[]; - pane.innerHTML=`

${esc(item.name||'Позиция')}только просмотр

${item.photo?``:''}
Категория${esc(currentCategory().label)}
Цена${esc(money(item.price||0))}
Вес${esc(item.weight||'—')}
Количество${Number(item.pieces||0)||'—'}${item.pieces?' шт.':''}
Состав для клиента${comp.length?`
    ${comp.map(x=>`
  • ${esc(x)}
  • `).join('')}
`:'

Не заполнен.

'}
Состав / ТТК${ingredients.length?`
    ${ingredients.map(x=>`
  • ${esc(x?.[0]||'')} — ${esc(x?.[1]??'')} ${esc(x?.[2]||'')}
  • `).join('')}
`:'

Не заполнен.

'}
`; + pane.innerHTML=`

${esc(item.name||'Позиция')}только просмотр

${item.photo?``:''}
Категория${esc(currentCategory().label)}
Цена${esc(money((window.CateriumPricing?.price(item)??Number(item?.price||0))))}
Вес${esc(item.weight||'—')}
Количество${Number(item.pieces||0)||'—'}${item.pieces?' шт.':''}
Состав для клиента${comp.length?`
    ${comp.map(x=>`
  • ${esc(x)}
  • `).join('')}
`:'

Не заполнен.

'}
Состав / ТТК${ingredients.length?`
    ${ingredients.map(x=>`
  • ${esc(x?.[0]||'')} — ${esc(x?.[1]??'')} ${esc(x?.[2]||'')}
  • `).join('')}
`:'

Не заполнен.

'}
`; } function dockEditor(){ if(!menuActive||!menuView?.classList.contains('on'))return; @@ -139,7 +139,7 @@ if(!menuView)return;const list=$('sunMenuListV1762'),count=$('sunMenuCountV1762');if(!list)return; qa('[data-menu-cat-v1762]',menuView).forEach(b=>b.classList.toggle('on',Number(b.dataset.menuCatV1762)===menuCategory)); const items=menuItems();if(count)count.textContent=`${items.length} поз.`; - list.innerHTML=items.length?items.map(item=>``).join(''):'
В этом разделе ничего не найдено.
'; + list.innerHTML=items.length?items.map(item=>``).join(''):'
В этом разделе ничего не найдено.
'; qa('[data-menu-item-v1762]',list).forEach(b=>b.onclick=()=>{const item=boxes().find(x=>String(x.id)===String(b.dataset.menuItemV1762));if(!item)return;if(menuCanEdit()){window.editBox?.(item.id);setTimeout(dockEditor,0)}else showReadOnlyMenuItem(item)}); } function patchMenuLegacyEditor(){ @@ -147,7 +147,7 @@ originalModal=window.modal;originalCloseModal=window.closeModal;originalSaveBox=window.saveBox;originalRemoveBox=window.removeBox; if(typeof originalModal==='function')window.modal=function(id,...args){if(menuActive&&(id==='editor'||id==='manager')){if(id==='editor')setTimeout(dockEditor,0);return;}return originalModal.call(this,id,...args)}; if(typeof originalCloseModal==='function')window.closeModal=function(id,...args){if(menuActive&&(id==='editor'||id==='manager')){if(id==='editor')emptyMenuDetail('Изменения сохранены. Выберите следующую позицию.');return;}return originalCloseModal.call(this,id,...args)}; - if(typeof originalSaveBox==='function')window.saveBox=function(...args){const r=originalSaveBox.apply(this,args);if(menuActive)setTimeout(()=>{renderMenuList();emptyMenuDetail('Изменения сохранены. Выберите позицию для продолжения.');},0);return r}; + if(typeof originalSaveBox==='function')window.saveBox=function(...args){const r=originalSaveBox.apply(this,args);if(menuActive&&r!==false)setTimeout(()=>{renderMenuList();emptyMenuDetail('Изменения сохранены. Выберите позицию для продолжения.');},0);return r}; if(typeof originalRemoveBox==='function')window.removeBox=function(...args){const r=originalRemoveBox.apply(this,args);if(menuActive)setTimeout(()=>{renderMenuList();emptyMenuDetail('Позиция удалена.');},0);return r}; } function leaveMenu(){if(!menuActive)return;menuActive=false;restoreEditorDialog();} @@ -218,7 +218,7 @@ const list=routeOrders(),distance=routeDistance(list,start),summary=qa('.route-summary>div',view).find(x=>/Примерно км/i.test(x.querySelector('small')?.textContent||''));if(summary?.querySelector('b'))summary.querySelector('b').textContent=distance.toLocaleString('ru-RU',{maximumFractionDigits:1}); } function routeOrderById(id){return orders().find(x=>String(x.id)===String(id))||null} - function routeOrderLines(order){const catalog=new Map(boxes().map(x=>[String(x.id),x]));return (order.lines||[]).map(line=>{const item=catalog.get(String(line.id)),qty=Math.max(0,Number(line.qty||0)),price=Number.isFinite(Number(line.price))?Number(line.price):Number(item?.price||0);return {name:item?.name||line.name||`Позиция ${line.id}`,qty,price,sum:qty*price}}).filter(x=>x.qty>0)} + function routeOrderLines(order){const catalog=new Map(boxes().map(x=>[String(x.id),x]));return (order.lines||[]).map(line=>{const item=catalog.get(String(line.id)),qty=Math.max(0,Number(line.qty||0)),price=Number.isFinite(Number(line.price))?Number(line.price):(window.CateriumPricing?.price(item)??Number(item?.price||0));return {name:item?.name||line.name||`Позиция ${line.id}`,qty,price,sum:qty*price}}).filter(x=>x.qty>0)} function showRouteOrder(id){ const o=routeOrderById(id);if(!o)return toast('Заказ не найден.','warn');const lines=routeOrderLines(o),total=orderTotal(o),paid=Math.max(0,Number(o.prepayment||0)),balance=Math.max(0,total-paid),editable=!support()&&can('orders.edit'); const body=`
Дата и время${esc(fmtDate(o.date))} · ${esc(o.time||'—')}
Статус${esc(o.status||'Новый')}
Сумма${esc(money(total))}
Остаток${esc(money(balance))}

${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(o.courierNote)}
`:''}${o.note?`
Комментарий:
${esc(o.note)}
`:''}
Меню заказа
${lines.length?lines.map(x=>``).join(''):''}
ПозицияКол.ЦенаСумма
${esc(x.name)}${x.qty}${esc(money(x.price))}${esc(money(x.sum))}
Позиции не указаны.
`; @@ -253,6 +253,7 @@ installStyles();patchSupportPermissions();installMenuPage();maintainSupport();syncMenuPermission();enhanceRoutePage(); document.addEventListener('click',e=>{const more=e.target.closest('#calendar .cal-more');if(more){e.preventDefault();e.stopImmediatePropagation();showCalendarDay(more.closest('.cal-day'));return}interceptRouteActions(e)},true); window.addEventListener('sun:cloud-permissions-changed',()=>{patchSupportPermissions();maintainSupport();syncMenuPermission();if(menuActive){renderMenuList();emptyMenuDetail();}}); + window.addEventListener('caterium:catalog-prices-changed',()=>{if(menuActive)renderMenuList();}); window.addEventListener('sun:cloud-state-applied',()=>{maintainSupport();syncMenuPermission();if(menuActive)renderMenuList();setTimeout(enhanceRoutePage,80)}); const mo=new MutationObserver(records=>{let route=false,supportBanner=false;for(const r of records){for(const n of r.addedNodes){if(n.nodeType!==1)continue;if(n.id==='routeContent'||n.querySelector?.('#routeContent')||n.closest?.('#sun-routes-view'))route=true;if(n.id==='sunDevSupportBannerV22'||n.querySelector?.('#sunDevSupportBannerV22'))supportBanner=true;}}if(route)setTimeout(enhanceRoutePage,30);if(supportBanner)setTimeout(renderSupportStatus,20);});mo.observe(document.documentElement,{childList:true,subtree:true}); if(!maintenanceTimer)maintenanceTimer=setInterval(()=>{if(document.hidden)return;maintainSupport();syncMenuPermission();if($('sun-routes-view')?.classList.contains('on'))enhanceRoutePage();},8000); diff --git a/public/core/performance.js b/public/core/performance.js index 33671d1..48d68e3 100644 --- a/public/core/performance.js +++ b/public/core/performance.js @@ -1,7 +1,7 @@ (()=>{ 'use strict'; const VERSION='17.7.3'; - const RELEASE='20260918-ui-stability'; + const RELEASE='20260918-ui-stability-20260919-client-menu'; const hasStoredSession=()=>{try{return Object.keys(localStorage).some(k=>/^sb-.*-auth-token$/i.test(k)&&String(localStorage.getItem(k)||'').length>20)}catch(_){return false}}; function installAuthBoot(){ diff --git a/public/core/trial-demo.js b/public/core/trial-demo.js index b089fae..d1f55d5 100644 --- a/public/core/trial-demo.js +++ b/public/core/trial-demo.js @@ -59,7 +59,7 @@ 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:Number(box.price)||0}:null}); + 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')}`; @@ -73,7 +73,7 @@ $('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=`
${esc(t.number)} · ${esc(t.basis)}

${esc(box.name)}

-

${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?'

Состав изменён. Закупка и списание используют текущий состав из редактора. Ниже показана исходная учебная ТТК.

':''}
${t.rows.map(r=>``).join('')}
Продукт / упаковкаЕд.БруттоНеттоЦена за ед.Сумма
${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 `
${sunEsc(b.name)}${money(b.price||0)} × ${l.qty} = ${money(Number(b.price||0)*l.qty)}
`}).join(''):'

Выберите боксы слева.

';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=>`
№ ${sunInt(o.id)}${sunEsc(o.event)}${sunEsc(o.date)} · ${sunEsc(o.time)}${sunEsc(o.address||'—')}${money(orderTotalValue(o))}
`).join(''):'

Сохранённых заказов пока нет.

'}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 `
${sunEsc(b.name)}${money(window.CateriumPricing.price(b))} × ${l.qty} = ${money(window.CateriumPricing.price(b)*l.qty)}
`}).join(''):'

Выберите боксы слева.

';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=>`
№ ${sunInt(o.id)}${sunEsc(o.event)}${sunEsc(o.date)} · ${sunEsc(o.time)}${sunEsc(o.address||'—')}${money(orderTotalValue(o))}
`).join(''):'

Сохранённых заказов пока нет.

'}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));