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.
This commit is contained in:
pavlov346346-source 2026-09-19 09:34:50 +03:00 committed by GitHub
parent b6e4a1aaad
commit 424bf8ce87
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 443 additions and 99 deletions

View File

@ -47,6 +47,13 @@ jobs:
core/login-signature-v1776.css core/login-signature-v1776.css
core/help-center.js core/help-center.js
core/help-center.css 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 caterium-mark-light.svg
service-worker.js service-worker.js
) )
@ -102,7 +109,7 @@ jobs:
node-version: 22 node-version: 22
- run: npm ci - run: npm ci
- run: npx playwright install --with-deps chromium - 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 timeout-minutes: 4
run: node tests/production-ui-smoke.mjs run: node tests/production-ui-smoke.mjs
- name: Save production UI verification - name: Save production UI verification

View File

@ -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.

View File

@ -4,7 +4,7 @@
"version": "17.7.3", "version": "17.7.3",
"type": "module", "type": "module",
"scripts": { "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", "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: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", "check:deploy": "npm run check:syntax && npm run test:static && npm run check:release && node tests/backend-cutover.mjs && npm run test:db",

View File

@ -113,7 +113,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
const titleY=717; const titleY=717;
ctx.fillStyle='#1f1f1d';ctx.font='800 36px Arial, sans-serif'; ctx.fillStyle='#1f1f1d';ctx.font='800 36px Arial, sans-serif';
const nameLines=wrapText(ctx,item.name||'',650,2);drawTextLines(ctx,nameLines,85,titleY,41); 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; const factsY=820;
ctx.fillStyle='#77736d';ctx.font='800 14px Arial, sans-serif';ctx.fillText('ВЕС',85,factsY);ctx.fillText('КОЛ-ВО',525,factsY); 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 $=id=>document.getElementById(id);
const qa=(sel,root=document)=>[...root.querySelectorAll(sel)]; const qa=(sel,root=document)=>[...root.querySelectorAll(sel)];
const esc=window.SunSafe.escapeHTML; 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 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'); 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 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)} 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)} function baseTotal(order){return (order?.lines||[]).reduce((s,l)=>s+lineUnitPrice(l)*Math.max(0,Number(l.qty||0)),0)}
window.sunBaseOrderTotal=baseTotal; 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)} 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){ function syncBanquetEditor(category,item){
const fields=ensureBanquetEditor();if(!fields)return;const isBanquet=Number(category)===BANQUET_CATEGORY; const fields=ensureBanquetEditor();if(!fields)return;const isBanquet=Number(category)===BANQUET_CATEGORY;
fields.section.style.display=isBanquet?'flex':'none';fields.weight.style.display=isBanquet?'flex':'none'; fields.section.style.display=isBanquet?'flex':'none';fields.weight.style.display=isBanquet?'flex':'none';
const caption=$('boxPriceCaption');if(caption)caption.textContent=isBanquet?'Цена на 1 гостя, ₽':ена, ₽'; const caption=$('boxPriceCaption');if(caption)caption.textContent=isBanquet?'Обычная цена на 1 гостя, ₽':'Обычная цена, ₽';
window.CateriumBanquet?.editor(category,item,boxes.filter(i=>i.hidden!==true&&Number(i.category)===6)); window.CateriumBanquet?.editor(category,item,boxes.filter(i=>i.hidden!==true&&Number(i.category)===6));
if(isBanquet){if($('banquetSection'))$('banquetSection').value=item?.catalogSection||'';if($('banquetWeightGrams'))$('banquetWeightGrams').value=parseWeightGrams(item?.weight)||''} if(isBanquet){if($('banquetSection'))$('banquetSection').value=item?.catalogSection||'';if($('banquetWeightGrams'))$('banquetWeightGrams').value=parseWeightGrams(item?.weight)||''}
} }
@ -429,10 +429,10 @@ window.SUN_LEGACY_CATALOG_V175=[];
function renderOrderLines(){ function renderOrderLines(){
if(!$('lines'))return; if(!$('lines'))return;
if(!(draft.lines||[]).length){$('lines').innerHTML='<p class="empty">Выберите позиции из каталога.</p>';return} if(!(draft.lines||[]).length){$('lines').innerHTML='<p class="empty">Выберите позиции из каталога.</p>';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 `<div class="sun-order-line-row" data-line-index="${index}"><div class="sun-order-line-name" title="${esc(item.name)}">${esc(String(item.name||'').replace(/^(?:Фуршетный\s+)?бокс\s*№\s*(\d+)\s*(?:[—–-]\s*)?/i,'№ $1 '))}${isBox&&(item.weight||inferBoxPieces(item))?`<small>${item.weight?`<b>Вес:</b> ${esc(item.weight)}`:''}${item.weight&&inferBoxPieces(item)?' · ':''}${inferBoxPieces(item)?`<b>Количество:</b> ${inferBoxPieces(item)} шт.`:''}</small>`:''}</div><div class="sun-order-line-qty"><button type="button" onclick="qty('${esc(line.id)}',${qty-1})"></button><input type="number" min="1" value="${qty}" onchange="qty('${esc(line.id)}',+this.value)"><button type="button" onclick="qty('${esc(line.id)}',${qty+1})"></button></div><div class="sun-order-line-price"><input type="text" inputmode="numeric" pattern="[0-9]*" autocomplete="off" value="${Math.round(price)}" onchange="sunOrderLinePriceChanged(${index},this)"></div><div class="sun-order-line-sum"><b>${money(price*qty)}</b></div></div>`}).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 `<div class="sun-order-line-row" data-line-index="${index}"><div class="sun-order-line-name" title="${esc(item.name)}">${esc(String(item.name||'').replace(/^(?:Фуршетный\s+)?бокс\s*№\s*(\d+)\s*(?:[—–-]\s*)?/i,'№ $1 '))}${isBox&&(item.weight||inferBoxPieces(item))?`<small>${item.weight?`<b>Вес:</b> ${esc(item.weight)}`:''}${item.weight&&inferBoxPieces(item)?' · ':''}${inferBoxPieces(item)?`<b>Количество:</b> ${inferBoxPieces(item)} шт.`:''}</small>`:''}</div><div class="sun-order-line-qty"><button type="button" onclick="qty('${esc(line.id)}',${qty-1})"></button><input type="number" min="1" value="${qty}" onchange="qty('${esc(line.id)}',+this.value)"><button type="button" onclick="qty('${esc(line.id)}',${qty+1})"></button></div><div class="sun-order-line-price"><input type="text" inputmode="decimal" autocomplete="off" value="${price}" onchange="sunOrderLinePriceChanged(${index},this)"></div><div class="sun-order-line-sum"><b>${money(price*qty)}</b></div></div>`}).join('');
$('lines').innerHTML=`<div class="sun-order-lines-table"><div class="sun-order-line-head"><span>Наименование</span><span>Кол.</span><span>Цена</span><span>Стоимость</span></div><div class="sun-order-line-body">${rows}</div><div class="sun-order-line-total"><span>Стоимость позиций</span><b>${money(baseTotal(draft))}</b></div></div>`; $('lines').innerHTML=`<div class="sun-order-lines-table"><div class="sun-order-line-head"><span>Наименование</span><span>Кол.</span><span>Цена</span><span>Стоимость</span></div><div class="sun-order-line-body">${rows}</div><div class="sun-order-line-total"><span>Стоимость позиций</span><b>${money(baseTotal(draft))}</b></div></div>`;
} }
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 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 wantedBoxNumber(query){const raw=String(query||'').trim();if(!raw)return null;const match=raw.match(/^(?:бокс\s*)?(?:№\s*)?0*(\d+)\s*$/i);return match?Number(match[1]):null}
function itemMatches(item,query){if(!query)return true;if(Number(activeCat)===0){const wanted=wantedBoxNumber(query);return wanted!==null&&boxNumber(item)===wanted}const q=query.trim().toLowerCase();const parts=[item.name,item.catalogSection,item.weight,...(Array.isArray(item.composition)?item.composition:[]),...(Array.isArray(item.ingredients)?item.ingredients.map(x=>x?.[0]):[])];return parts.join(' ').toLowerCase().includes(q)} function itemMatches(item,query){if(!query)return true;if(Number(activeCat)===0){const wanted=wantedBoxNumber(query);return wanted!==null&&boxNumber(item)===wanted}const q=query.trim().toLowerCase();const parts=[item.name,item.catalogSection,item.weight,...(Array.isArray(item.composition)?item.composition:[]),...(Array.isArray(item.ingredients)?item.ingredients.map(x=>x?.[0]):[])];return parts.join(' ').toLowerCase().includes(q)}
@ -440,19 +440,24 @@ window.SUN_LEGACY_CATALOG_V175=[];
function banquetCatalog(){return boxes.filter(item=>item.hidden!==true&&Number(item.category)===BANQUET_CATEGORY)} 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();}; window.toggleBanquetItem=id=>{window.CateriumBanquet.toggle(draft,banquetCatalog(),String(id));renderCatalogV5();};
function renderBanquetView(items){return window.CateriumBanquet.render({items,catalog:banquetCatalog(),draft})} function renderBanquetView(items){return window.CateriumBanquet.render({items,catalog:banquetCatalog(),draft})}
function renderCatalogV5(){ function renderCatalogV5({catalogOnly=false}={}){
$('new')?.classList.toggle('sun-banquet-active',Number(activeCat)===BANQUET_CATEGORY); $('new')?.classList.toggle('sun-banquet-active',Number(activeCat)===BANQUET_CATEGORY);
renderCategoryTabs();ensureCatalogTools();const catalogSearch=$('sunCatalogSearch');if(catalogSearch){const boxMode=[0,5].includes(Number(activeCat));catalogSearch.placeholder=boxMode?'Поиск по № бокса':'Поиск по позиции';catalogSearch.inputMode=boxMode?'numeric':'search';}const title=document.querySelector('#new .catalog h1');if(title)title.textContent=catName(activeCat); renderCategoryTabs();ensureCatalogTools();const catalogSearch=$('sunCatalogSearch');if(catalogSearch){const boxMode=[0,5].includes(Number(activeCat));catalogSearch.placeholder=boxMode?'Поиск по № бокса':'Поиск по позиции';catalogSearch.inputMode=boxMode?'numeric':'search';}const title=document.querySelector('#new .catalog h1');if(title)title.textContent=catName(activeCat);
const all=boxes.filter(item=>item.hidden!==true&&Number(item.category||0)===Number(activeCat)),items=all.filter(item=>itemMatches(item,catalogQuery));const cat=catById(activeCat)||{name:'Каталог',prep:true}; const all=boxes.filter(item=>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;} if(Number(activeCat)===BANQUET_CATEGORY){$('tiles').innerHTML=renderBanquetView(items);window.CateriumBanquet.bind($('tiles'),{catalog:banquetCatalog(),draft,rerender:renderCatalogV5});if(!catalogOnly){renderOrderLines();updateOrderSummary();}return;}
const cards=items.map(item=>`<button class="tile" type="button" onclick="add('${esc(item.id)}')" title="Добавить в заказ">${item.photo?`<img src="${esc(window.SunSafe.imageAssetSrc(item.photo))}" alt="" loading="lazy" decoding="async" onerror="this.onerror=null;this.src='${window.CateriumBranding.logoHTML()}'">`:("<div class=\"ph\"><img src=\""+window.CateriumBranding.logoHTML()+"\" class=\"sun-ph-logo\" alt=\"Логотип "+window.CateriumBranding.nameHTML()+"\"></div>")}<span>${esc(item.name)}</span>${item.catalogSection?`<small class="tile-section">${esc(item.catalogSection)}</small>`:''}${(item.weight||inferBoxPieces(item))?`<small class="tile-meta">${item.weight?`Вес: ${esc(item.weight)}`:''}${item.weight&&inferBoxPieces(item)?' · ':''}${inferBoxPieces(item)?`${inferBoxPieces(item)} шт.`:''}</small>`:''}<span class="tile-price-wrap"><small class="tile-price">${money(item.price||0)}</small>${Number(item.oldPrice||0)>Number(item.price||0)?`<small class="old-price">${money(item.oldPrice)}</small>`:''}</span>${Number(item.oldPrice||0)>Number(item.price||0)?`<span class="sale-badge">АКЦИЯ</span>`:''}</button>`).join(''); const cards=items.map(item=>`<button class="tile" type="button" onclick="add('${esc(item.id)}')" title="Добавить в заказ">${item.photo?`<img src="${esc(window.SunSafe.imageAssetSrc(item.photo))}" alt="" loading="lazy" decoding="async" onerror="this.onerror=null;this.src='${window.CateriumBranding.logoHTML()}'">`:("<div class=\"ph\"><img src=\""+window.CateriumBranding.logoHTML()+"\" class=\"sun-ph-logo\" alt=\"Логотип "+window.CateriumBranding.nameHTML()+"\"></div>")}<span>${esc(item.name)}</span>${item.catalogSection?`<small class="tile-section">${esc(item.catalogSection)}</small>`:''}${(item.weight||inferBoxPieces(item))?`<small class="tile-meta">${item.weight?`Вес: ${esc(item.weight)}`:''}${item.weight&&inferBoxPieces(item)?' · ':''}${inferBoxPieces(item)?`${inferBoxPieces(item)} шт.`:''}</small>`:''}<span class="tile-price-wrap"><small class="tile-price">${money(window.CateriumPricing.price(item))}</small></span>${window.CateriumPricing.badge(item)}</button>`).join('');
const addText=Number(activeCat)===0?'Добавить бокс':(Number(activeCat)===5?'Добавить премиум':'Добавить позицию'); const addText=Number(activeCat)===0?'Добавить бокс':(Number(activeCat)===5?'Добавить премиум':'Добавить позицию');
const empty=items.length?'':`<div class="catalog-empty">${catalogQuery?'По вашему запросу ничего не найдено.':`В разделе «${esc(cat.name)}» пока нет позиций.`}</div>`; const empty=items.length?'':`<div class="catalog-empty">${catalogQuery?'По вашему запросу ничего не найдено.':`В разделе «${esc(cat.name)}» пока нет позиций.`}</div>`;
$('tiles').innerHTML=`<button class="tile add" type="button" onclick="editBox(null)"><br>${addText}</button>${cards}${empty}`; $('tiles').innerHTML=`<button class="tile add" type="button" onclick="editBox(null)"><br>${addText}</button>${cards}${empty}`;
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.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();}; window.qty=(id,n)=>{if(!Array.isArray(draft.lines))draft.lines=[];const next=Math.max(0,Number(n||0));if(next<1)draft.lines=draft.lines.filter(x=>String(x.id)!==String(id));else{const line=draft.lines.find(x=>String(x.id)===String(id));if(line)line.qty=next}renderCatalogV5();};
function ensureManagerToolbar(){ function ensureManagerToolbar(){
@ -462,16 +467,16 @@ window.SUN_LEGACY_CATALOG_V175=[];
function renderManagerV5(){ function renderManagerV5(){
ensureManagerToolbar();const managerSearch=$('sunManagerSearch');if(managerSearch){const boxMode=[0,5].includes(Number(activeCat));managerSearch.placeholder=boxMode?'Поиск по № бокса':'Поиск по позиции';managerSearch.inputMode=boxMode?'numeric':'search';}const title=document.querySelector('#manager .dialog-head h2'),add=document.querySelector('#manager .sun-manager-toolbar .primary')||document.querySelector('#manager .primary');if(title)title.textContent=catName(activeCat);if(add){add.textContent=Number(activeCat)===0?'Добавить бокс':(Number(activeCat)===5?'Добавить премиум':'Добавить позицию');add.onclick=()=>window.editBox(null)} ensureManagerToolbar();const managerSearch=$('sunManagerSearch');if(managerSearch){const boxMode=[0,5].includes(Number(activeCat));managerSearch.placeholder=boxMode?'Поиск по № бокса':'Поиск по позиции';managerSearch.inputMode=boxMode?'numeric':'search';}const title=document.querySelector('#manager .dialog-head h2'),add=document.querySelector('#manager .sun-manager-toolbar .primary')||document.querySelector('#manager .primary');if(title)title.textContent=catName(activeCat);if(add){add.textContent=Number(activeCat)===0?'Добавить бокс':(Number(activeCat)===5?'Добавить премиум':'Добавить позицию');add.onclick=()=>window.editBox(null)}
const items=boxes.filter(i=>i.hidden!==true&&Number(i.category||0)===Number(activeCat)&&itemMatches(i,managerQuery)),cat=catById(activeCat)||{prep:true}; const items=boxes.filter(i=>i.hidden!==true&&Number(i.category||0)===Number(activeCat)&&itemMatches(i,managerQuery)),cat=catById(activeCat)||{prep:true};
$('managerList').innerHTML=items.length?items.map(item=>`<button type="button" onclick="editBox('${esc(item.id)}')">${item.photo?`<img src="${esc(window.SunSafe.imageAssetSrc(item.photo))}" alt="" loading="lazy" decoding="async" onerror="this.onerror=null;this.src='${window.CateriumBranding.logoHTML()}'">`:("<div class=\"ph\"><img src=\""+window.CateriumBranding.logoHTML()+"\" class=\"sun-ph-logo\" alt=\"Логотип "+window.CateriumBranding.nameHTML()+"\"></div>")}<b>${esc(item.name)}</b><span class="manager-price-wrap"><small class="manager-card-price">${money(item.price||0)}</small>${Number(item.oldPrice||0)>Number(item.price||0)?`<small class="old-price">${money(item.oldPrice)}</small>`:''}</span>${(item.weight||inferBoxPieces(item))?`<small class="manager-card-meta">${item.weight?`Вес: ${esc(item.weight)}`:''}${item.weight&&inferBoxPieces(item)?' · ':''}${inferBoxPieces(item)?`${inferBoxPieces(item)} шт.`:''}</small>`:''}${cat.prep?`<small>${(item.ingredients||[]).length} позиций в составе</small>`:''}</button>`).join(''):'<p class="empty">Ничего не найдено.</p>'; $('managerList').innerHTML=items.length?items.map(item=>`<button type="button" onclick="editBox('${esc(item.id)}')">${item.photo?`<img src="${esc(window.SunSafe.imageAssetSrc(item.photo))}" alt="" loading="lazy" decoding="async" onerror="this.onerror=null;this.src='${window.CateriumBranding.logoHTML()}'">`:("<div class=\"ph\"><img src=\""+window.CateriumBranding.logoHTML()+"\" class=\"sun-ph-logo\" alt=\"Логотип "+window.CateriumBranding.nameHTML()+"\"></div>")}<b>${esc(item.name)}</b><span class="manager-price-wrap"><small class="manager-card-price">${money(window.CateriumPricing.price(item))}</small></span>${window.CateriumPricing.badge(item)}${(item.weight||inferBoxPieces(item))?`<small class="manager-card-meta">${item.weight?`Вес: ${esc(item.weight)}`:''}${item.weight&&inferBoxPieces(item)?' · ':''}${inferBoxPieces(item)?`${inferBoxPieces(item)} шт.`:''}</small>`:''}${cat.prep?`<small>${(item.ingredients||[]).length} позиций в составе</small>`:''}</button>`).join(''):'<p class="empty">Ничего не найдено.</p>';
} }
window.openManager=()=>{managerQuery='';ensureManagerToolbar();if($('sunManagerSearch'))$('sunManagerSearch').value='';renderManagerV5();window.modal?.('manager');}; window.openManager=()=>{managerQuery='';ensureManagerToolbar();if($('sunManagerSearch'))$('sunManagerSearch').value='';renderManagerV5();window.modal?.('manager');};
window.editBox=id=>{ 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; 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'); const cat=catById(edited.category)||{name:'Каталог',prep:true};$('editTitle').textContent=edited.id?`Изменить: ${cat.name}`:(Number(edited.category)===0?'Новый бокс':(Number(edited.category)===5?'Новая премиум позиция':'Новая позиция'));const zone=document.querySelector('#editor .ingredient-zone');if(zone)zone.style.display=cat.prep?'block':'none';window.renderIngredients?.();window.modal?.('editor');
}; };
if($('itemCategory'))$('itemCategory').onchange=function(){if(!edited)return;edited.category=Number(this.value);const c=catById(edited.category)||{name:'Каталог',prep:true};const zone=document.querySelector('#editor .ingredient-zone');if(zone)zone.style.display=c.prep?'block':'none';syncBoxWeightEditor(edited.category,edited);syncBanquetEditor(edited.category,edited);$('editTitle').textContent=edited.id?`Изменить: ${c.name}`:(Number(edited.category)===0?'Новый бокс':(Number(edited.category)===5?'Новая премиум позиция':'Новая позиция'));}; if($('itemCategory'))$('itemCategory').onchange=function(){if(!edited)return;edited.category=Number(this.value);const c=catById(edited.category)||{name:'Каталог',prep:true};const zone=document.querySelector('#editor .ingredient-zone');if(zone)zone.style.display=c.prep?'block':'none';syncBoxWeightEditor(edited.category,edited);syncBanquetEditor(edited.category,edited);$('editTitle').textContent=edited.id?`Изменить: ${c.name}`:(Number(edited.category)===0?'Новый бокс':(Number(edited.category)===5?'Новая премиум позиция':'Новая позиция'));};
window.saveBox=()=>{if(!edited)return;edited.name=$('boxName').value.trim();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?.();}; window.removeBox=()=>{if(!edited?.id||!confirm(`Удалить «${edited.name}» из каталога?`))return;boxes=boxes.filter(x=>String(x.id)!==String(edited.id));draft.lines=(draft.lines||[]).filter(x=>String(x.id)!==String(edited.id));persist();closeModal('editor');closeModal('manager');renderCatalogV5();renderOrders?.();};
function fileAsDataUrl(file){return new Promise((resolve,reject)=>{const reader=new FileReader();reader.onerror=()=>reject(reader.error||new Error('FileReader error'));reader.onload=()=>resolve(String(reader.result||''));reader.readAsDataURL(file)})} function fileAsDataUrl(file){return new Promise((resolve,reject)=>{const reader=new FileReader();reader.onerror=()=>reject(reader.error||new Error('FileReader error'));reader.onload=()=>resolve(String(reader.result||''));reader.readAsDataURL(file)})}
@ -881,7 +886,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
const $=id=>document.getElementById(id); const $=id=>document.getElementById(id);
const qa=(sel,root=document)=>[...root.querySelectorAll(sel)]; const qa=(sel,root=document)=>[...root.querySelectorAll(sel)];
const esc=window.SunSafe.escapeHTML; 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 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);}; 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. // 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 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 'Каталог'}}; 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 discountAmount(value,type,base){const n=num(value);return Math.min(base,type==='amount'?n:base*Math.min(100,n)/100)}
function priceCalc(order){ function priceCalc(order){
const all=getBoxes(); const all=getBoxes();
@ -1795,7 +1800,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
const $=id=>document.getElementById(id); const $=id=>document.getElementById(id);
const qa=(sel,root=document)=>[...root.querySelectorAll(sel)]; const qa=(sel,root=document)=>[...root.querySelectorAll(sel)];
const esc=window.SunSafe.escapeHTML; 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={ 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', 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', 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)} for(const st of stats.values()){maxOrders=Math.max(maxOrders,st.orders);maxQty=Math.max(maxQty,st.qty)}
const out=new Map();for(const [id,st] of stats)out.set(id,(maxOrders?st.orders/maxOrders:0)*.78+(maxQty?st.qty/maxQty:0)*.22);return out; const out=new Map();for(const [id,st] of stats)out.set(id,(maxOrders?st.orders/maxOrders:0)*.78+(maxQty?st.qty/maxQty:0)*.22);return out;
} }
function foodCandidates(){const pop=popularityMap();return getBoxes().filter(x=>Number(x?.category||0)===0&&x?.hidden!==true&&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 hashNoise(text,seed){let h=2166136261^(seed*2654435761);for(const c of String(text)){h^=c.charCodeAt(0);h=Math.imul(h,16777619)}return ((h>>>0)%10000)/10000;}
function stateKey(s){return [...s.q.entries()].sort((a,b)=>a[0].localeCompare(b[0])).map(([id,q])=>id+':'+q).join('|')} function stateKey(s){return [...s.q.entries()].sort((a,b)=>a[0].localeCompare(b[0])).map(([id,q])=>id+':'+q).join('|')}
function score(s,t,seed){ function score(s,t,seed){
@ -4101,7 +4106,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
const DEFAULT_SOURCES=['Заявка с сайта','CaterMe','Avito','Рекомендация','Повторный клиент','Соцсети','Другое']; const DEFAULT_SOURCES=['Заявка с сайта','CaterMe','Avito','Рекомендация','Повторный клиент','Соцсети','Другое'];
const getDraft=()=>{try{return draft}catch(_){return null}}; 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 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 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=` 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. */ /* The precise-address fields existed in an older module but were hidden by a later polish layer. */

View File

@ -3,13 +3,13 @@
const groups=[['cold','Холодные закуски'],['salads','Салаты'],['starters','Горячие закуски'],['main','Горячее'],['sides','Гарниры'],['desserts','Десерты'],['fruit','Фрукты и ягоды'],['bread','Хлеб и масло'],['other','Другие блюда']]; const groups=[['cold','Холодные закуски'],['salads','Салаты'],['starters','Горячие закуски'],['main','Горячее'],['sides','Гарниры'],['desserts','Десерты'],['fruit','Фрукты и ягоды'],['bread','Хлеб и масло'],['other','Другие блюда']];
let packageId='all',groupId='all'; let packageId='all',groupId='all';
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 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'}; 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 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))} 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 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 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 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]} 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?'<p class="ct-banquet-estimate">≈ Веса и цены отдельных блюд рассчитаны приблизительно по общей стоимости меню. Их можно изменить в карточке блюда.</p>':''} ${estimates?'<p class="ct-banquet-estimate">≈ Веса и цены отдельных блюд рассчитаны приблизительно по общей стоимости меню. Их можно изменить в карточке блюда.</p>':''}
${menu?`<div class="ct-banquet-preset"><span><b>${esc(menu.name)}</b> · горячее — одно блюдо на выбор</span><button class="outline" type="button" data-banquet-preset="${esc(menu.id)}">Выбрать меню целиком</button></div>`:''} ${menu?`<div class="ct-banquet-preset"><span><b>${esc(menu.name)}</b> · горячее — одно блюдо на выбор</span><button class="outline" type="button" data-banquet-preset="${esc(menu.id)}">Выбрать меню целиком</button></div>`:''}
<div class="ct-banquet-groups" role="group" aria-label="Разделы банкетного меню"><button type="button" data-banquet-group="all" aria-pressed="${groupId==='all'}">Все разделы <span>${candidates.length}</span></button>${available.map(([id,label])=>`<button type="button" data-banquet-group="${id}" aria-pressed="${groupId===id}">${label} <span>${candidates.filter(i=>group(i)===id).length}</span></button>`).join('')}</div> <div class="ct-banquet-groups" role="group" aria-label="Разделы банкетного меню"><button type="button" data-banquet-group="all" aria-pressed="${groupId==='all'}">Все разделы <span>${candidates.length}</span></button>${available.map(([id,label])=>`<button type="button" data-banquet-group="${id}" aria-pressed="${groupId===id}">${label} <span>${candidates.filter(i=>group(i)===id).length}</span></button>`).join('')}</div>
<div class="ct-banquet-layout"><div class="ct-banquet-dishes">${visible.length?[...sections].map(([section,rows])=>`<section class="ct-banquet-section"><h3>${esc(section)}${rows.some(i=>i.banquet?.choiceGroup)?'<small>Одно блюдо на выбор в каждом пакете</small>':''}</h3>${rows.map(i=>`<div class="ct-banquet-dish ${ids.has(String(i.id))?'selected':''}" data-banquet-row="${esc(i.id)}"><label><input type="checkbox" data-banquet-item="${esc(i.id)}" ${ids.has(String(i.id))?'checked':''}><span class="ct-banquet-name">${esc(i.name)}<small>${esc(i.banquet?.packageName||i.catalogSection||'')}${i.banquet?.estimated?' · ≈':''}</small></span><span class="ct-banquet-weight">${esc(i.weight||'—')}</span><b class="ct-banquet-price">${money(unitPrice(i,draft))}</b></label>${i.demo&&i.ttk?`<button type="button" data-banquet-ttk="${esc(i.id)}" aria-label="ТТК: ${esc(i.name)}" title="Открыть технологическую карту">ТТК</button>`:''}<button type="button" data-banquet-edit="${esc(i.id)}" aria-label="Изменить ${esc(i.name)}" title="Изменить блюдо">✎</button></div>`).join('')}</section>`).join(''):`<p class="catalog-empty">${catalog.length?'По вашему запросу блюда не найдены.':'В банкетном меню пока нет блюд. Добавьте свои позиции.'}</p>`}</div> <div class="ct-banquet-layout"><div class="ct-banquet-dishes">${visible.length?[...sections].map(([section,rows])=>`<section class="ct-banquet-section"><h3>${esc(section)}${rows.some(i=>i.banquet?.choiceGroup)?'<small>Одно блюдо на выбор в каждом пакете</small>':''}</h3>${rows.map(i=>`<div class="ct-banquet-dish ${ids.has(String(i.id))?'selected':''}" data-banquet-row="${esc(i.id)}"><label><input type="checkbox" data-banquet-item="${esc(i.id)}" ${ids.has(String(i.id))?'checked':''}><span class="ct-banquet-name">${esc(i.name)}${window.CateriumPricing?.badge(i)||''}<small>${esc(i.banquet?.packageName||i.catalogSection||'')}${i.banquet?.estimated?' · ≈':''}</small></span><span class="ct-banquet-weight">${esc(i.weight||'—')}</span><b class="ct-banquet-price">${money(unitPrice(i,draft))}</b></label>${i.demo&&i.ttk?`<button type="button" data-banquet-ttk="${esc(i.id)}" aria-label="ТТК: ${esc(i.name)}" title="Открыть технологическую карту">ТТК</button>`:''}<button type="button" data-banquet-edit="${esc(i.id)}" aria-label="Изменить ${esc(i.name)}" title="Изменить блюдо">✎</button></div>`).join('')}</section>`).join(''):`<p class="catalog-empty">${catalog.length?'По вашему запросу блюда не найдены.':'В банкетном меню пока нет блюд. Добавьте свои позиции.'}</p>`}</div>
<aside class="ct-banquet-summary"><span class="ct-eyebrow">Ваше меню</span><h3>${selected.length?`${selected.length} блюд`:'Пока пусто'}</h3><label>Количество гостей<input type="number" min="1" max="10000" step="1" data-banquet-guests value="${count}"></label><div class="ct-banquet-metric"><span>На одного гостя</span><b data-banquet-per-guest>${money(sum.price)}</b><small data-banquet-weight>${sum.grams.toLocaleString('ru-RU')} г</small></div><div class="ct-banquet-total"><span>На всех гостей</span><b data-banquet-total>${money(sum.price*count)}</b><small data-banquet-total-weight>${(sum.grams*count/1000).toLocaleString('ru-RU',{maximumFractionDigits:2})} кг</small></div><button class="primary" type="button" data-banquet-apply ${selected.length?'':'disabled'}>${hasExisting?'Обновить банкет в заказе':'Добавить меню в заказ'}</button>${hasExisting?'<small class="ct-banquet-help">Обновятся только банкетные блюда. Остальные позиции заказа сохранятся.</small>':''}${selected.length?`<details><summary>Выбрано: ${selected.length}</summary><ul>${selected.map(i=>`<li>${esc(i.name)}</li>`).join('')}</ul></details><button class="ct-banquet-clear" type="button" data-banquet-clear>Снять выбор блюд</button>`:'<p class="ct-banquet-help">Блюда из разных разделов остаются в общей подборке.</p>'}</aside></div></div>`; <aside class="ct-banquet-summary"><span class="ct-eyebrow">Ваше меню</span><h3>${selected.length?`${selected.length} блюд`:'Пока пусто'}</h3><label>Количество гостей<input type="number" min="1" max="10000" step="1" data-banquet-guests value="${count}"></label><div class="ct-banquet-metric"><span>На одного гостя</span><b data-banquet-per-guest>${money(sum.price)}</b><small data-banquet-weight>${sum.grams.toLocaleString('ru-RU')} г</small></div><div class="ct-banquet-total"><span>На всех гостей</span><b data-banquet-total>${money(sum.price*count)}</b><small data-banquet-total-weight>${(sum.grams*count/1000).toLocaleString('ru-RU',{maximumFractionDigits:2})} кг</small></div><button class="primary" type="button" data-banquet-apply ${selected.length?'':'disabled'}>${hasExisting?'Обновить банкет в заказе':'Добавить меню в заказ'}</button>${hasExisting?'<small class="ct-banquet-help">Обновятся только банкетные блюда. Остальные позиции заказа сохранятся.</small>':''}${selected.length?`<details><summary>Выбрано: ${selected.length}</summary><ul>${selected.map(i=>`<li>${esc(i.name)}</li>`).join('')}</ul></details><button class="ct-banquet-clear" type="button" data-banquet-clear>Снять выбор блюд</button>`:'<p class="ct-banquet-help">Блюда из разных разделов остаются в общей подборке.</p>'}</aside></div></div>`;
} }
function bind(root,context){ function bind(root,context){

View File

@ -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;
return {base,price:active?cents(Math.max(0,base-(p.type==='percent'?base*value/100:value))):base,active,scheduled:valid&&now<start,expired:valid&&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?'<span class="ct-promotion-badge">Акция</span>':'';
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();
})();

View File

@ -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)}}

View File

@ -92,7 +92,7 @@
const orderStamp=order=>`${String(order?.date||'').padStart(10,'0')}T${String(order?.time||'').padStart(5,'0')}`; const orderStamp=order=>`${String(order?.date||'').padStart(10,'0')}T${String(order?.time||'').padStart(5,'0')}`;
function orderTotal(order){ function orderTotal(order){
const explicit=Number(order?.total);if(Number.isFinite(explicit))return explicit; 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 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||'')}} 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||'')}}

View File

@ -20,7 +20,7 @@
const qa=(s,r=document)=>[...r.querySelectorAll(s)]; const qa=(s,r=document)=>[...r.querySelectorAll(s)];
const esc=v=>window.SunSafe?.escapeHTML?window.SunSafe.escapeHTML(String(v??'')):String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); const esc=v=>window.SunSafe?.escapeHTML?window.SunSafe.escapeHTML(String(v??'')):String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const toast=(text,type='info',ms=4500)=>{try{return window.SunEnterprise?.toast?.(text,type,ms)}catch(_){}console[type==='error'?'error':'log'](text)}; 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 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 writeJson=(key,value)=>localStorage.setItem(key,JSON.stringify(value));
const orders=()=>{const v=readJson('sunOrders',[]);return Array.isArray(v)?v:[]}; const orders=()=>{const v=readJson('sunOrders',[]);return Array.isArray(v)?v:[]};
@ -103,7 +103,7 @@
function orderTotal(order){ function orderTotal(order){
if(Number.isFinite(Number(order?.total))&&Number(order.total)>0)return Number(order.total); 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])); 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){ function showCalendarDay(cell){
const date=calendarCellDate(cell);if(!date)return; const date=calendarCellDate(cell);if(!date)return;
@ -127,7 +127,7 @@
function showReadOnlyMenuItem(item){ function showReadOnlyMenuItem(item){
restoreEditorDialog();const pane=$('sunMenuDetailV1762');if(!pane)return; restoreEditorDialog();const pane=$('sunMenuDetailV1762');if(!pane)return;
const comp=Array.isArray(item.composition)?item.composition.filter(Boolean):[],ingredients=Array.isArray(item.ingredients)?item.ingredients:[]; const comp=Array.isArray(item.composition)?item.composition.filter(Boolean):[],ingredients=Array.isArray(item.ingredients)?item.ingredients:[];
pane.innerHTML=`<div class="sun-menu-readonly"><h2>${esc(item.name||'Позиция')}<span class="sun-menu-readonly-badge">только просмотр</span></h2>${item.photo?`<img class="sun-menu-readonly-photo" src="${esc(item.photo)}" alt="">`:''}<div class="sun-menu-readonly-grid"><div class="sun-menu-readonly-box"><small>Категория</small><b>${esc(currentCategory().label)}</b></div><div class="sun-menu-readonly-box"><small>Цена</small><b>${esc(money(item.price||0))}</b></div><div class="sun-menu-readonly-box"><small>Вес</small><b>${esc(item.weight||'—')}</b></div><div class="sun-menu-readonly-box"><small>Количество</small><b>${Number(item.pieces||0)||'—'}${item.pieces?' шт.':''}</b></div></div><div class="sun-route-order-section"><b>Состав для клиента</b>${comp.length?`<ul class="sun-menu-readonly-list">${comp.map(x=>`<li>${esc(x)}</li>`).join('')}</ul>`:'<p class="hint">Не заполнен.</p>'}</div><div class="sun-route-order-section"><b>Состав / ТТК</b>${ingredients.length?`<ul class="sun-menu-readonly-list">${ingredients.map(x=>`<li>${esc(x?.[0]||'')}${esc(x?.[1]??'')} ${esc(x?.[2]||'')}</li>`).join('')}</ul>`:'<p class="hint">Не заполнен.</p>'}</div></div>`; pane.innerHTML=`<div class="sun-menu-readonly"><h2>${esc(item.name||'Позиция')}<span class="sun-menu-readonly-badge">только просмотр</span></h2>${item.photo?`<img class="sun-menu-readonly-photo" src="${esc(item.photo)}" alt="">`:''}<div class="sun-menu-readonly-grid"><div class="sun-menu-readonly-box"><small>Категория</small><b>${esc(currentCategory().label)}</b></div><div class="sun-menu-readonly-box"><small>Цена</small><b>${esc(money((window.CateriumPricing?.price(item)??Number(item?.price||0))))}</b></div><div class="sun-menu-readonly-box"><small>Вес</small><b>${esc(item.weight||'—')}</b></div><div class="sun-menu-readonly-box"><small>Количество</small><b>${Number(item.pieces||0)||'—'}${item.pieces?' шт.':''}</b></div></div><div class="sun-route-order-section"><b>Состав для клиента</b>${comp.length?`<ul class="sun-menu-readonly-list">${comp.map(x=>`<li>${esc(x)}</li>`).join('')}</ul>`:'<p class="hint">Не заполнен.</p>'}</div><div class="sun-route-order-section"><b>Состав / ТТК</b>${ingredients.length?`<ul class="sun-menu-readonly-list">${ingredients.map(x=>`<li>${esc(x?.[0]||'')}${esc(x?.[1]??'')} ${esc(x?.[2]||'')}</li>`).join('')}</ul>`:'<p class="hint">Не заполнен.</p>'}</div></div>`;
} }
function dockEditor(){ function dockEditor(){
if(!menuActive||!menuView?.classList.contains('on'))return; if(!menuActive||!menuView?.classList.contains('on'))return;
@ -139,7 +139,7 @@
if(!menuView)return;const list=$('sunMenuListV1762'),count=$('sunMenuCountV1762');if(!list)return; if(!menuView)return;const list=$('sunMenuListV1762'),count=$('sunMenuCountV1762');if(!list)return;
qa('[data-menu-cat-v1762]',menuView).forEach(b=>b.classList.toggle('on',Number(b.dataset.menuCatV1762)===menuCategory)); qa('[data-menu-cat-v1762]',menuView).forEach(b=>b.classList.toggle('on',Number(b.dataset.menuCatV1762)===menuCategory));
const items=menuItems();if(count)count.textContent=`${items.length} поз.`; const items=menuItems();if(count)count.textContent=`${items.length} поз.`;
list.innerHTML=items.length?items.map(item=>`<button type="button" class="sun-menu-row" data-menu-item-v1762="${esc(item.id)}">${item.photo?`<img src="${esc(item.photo)}" alt="" loading="lazy" decoding="async">`:'<div class="ph"></div>'}<span><b>${esc(item.name||'Без названия')}</b><small>${[item.weight,Number(item.pieces||0)?`${Number(item.pieces)} шт.`:'',item.catalogSection].filter(Boolean).map(esc).join(' · ')}</small></span><span class="sun-menu-row-price">${esc(money(item.price||0))}${Number(item.oldPrice||0)>Number(item.price||0)?`<small><s>${esc(money(item.oldPrice))}</s></small>`:''}</span></button>`).join(''):'<div class="sun-menu-empty">В этом разделе ничего не найдено.</div>'; list.innerHTML=items.length?items.map(item=>`<button type="button" class="sun-menu-row" data-menu-item-v1762="${esc(item.id)}">${item.photo?`<img src="${esc(item.photo)}" alt="" loading="lazy" decoding="async">`:'<div class="ph"></div>'}<span><b>${esc(item.name||'Без названия')}</b><small>${[item.weight,Number(item.pieces||0)?`${Number(item.pieces)} шт.`:'',item.catalogSection].filter(Boolean).map(esc).join(' · ')}</small></span><span class="sun-menu-row-price">${esc(money((window.CateriumPricing?.price(item)??Number(item?.price||0))))}${window.CateriumPricing?.badge(item)||''}</span></button>`).join(''):'<div class="sun-menu-empty">В этом разделе ничего не найдено.</div>';
qa('[data-menu-item-v1762]',list).forEach(b=>b.onclick=()=>{const item=boxes().find(x=>String(x.id)===String(b.dataset.menuItemV1762));if(!item)return;if(menuCanEdit()){window.editBox?.(item.id);setTimeout(dockEditor,0)}else showReadOnlyMenuItem(item)}); qa('[data-menu-item-v1762]',list).forEach(b=>b.onclick=()=>{const item=boxes().find(x=>String(x.id)===String(b.dataset.menuItemV1762));if(!item)return;if(menuCanEdit()){window.editBox?.(item.id);setTimeout(dockEditor,0)}else showReadOnlyMenuItem(item)});
} }
function patchMenuLegacyEditor(){ function patchMenuLegacyEditor(){
@ -147,7 +147,7 @@
originalModal=window.modal;originalCloseModal=window.closeModal;originalSaveBox=window.saveBox;originalRemoveBox=window.removeBox; 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 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 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}; 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();} 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}); 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 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){ 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 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=`<div class="sun-route-order-grid"><div class="sun-route-order-kpi"><small>Дата и время</small><b>${esc(fmtDate(o.date))} · ${esc(o.time||'—')}</b></div><div class="sun-route-order-kpi"><small>Статус</small><b>${esc(o.status||'Новый')}</b></div><div class="sun-route-order-kpi"><small>Сумма</small><b>${esc(money(total))}</b></div><div class="sun-route-order-kpi"><small>Остаток</small><b>${esc(money(balance))}</b></div></div><div class="sun-route-order-section"><p><b>${esc(o.event||'Заказ')}</b>${o.guestsCount?` · ${esc(o.guestsCount)} гостей`:''}</p><p><b>Клиент:</b> ${esc(o.contact||'—')} · ${esc(o.phone||'—')}</p><p><b>Адрес:</b> ${esc(o.address||'—')}</p>${o.delivery?`<p><b>Доставка:</b> ${esc(money(o.delivery))}</p>`:''}${o.courierNote?`<div class="sun-route-order-note"><b>Курьеру:</b><br>${esc(o.courierNote)}</div>`:''}${o.note?`<div class="sun-route-order-note"><b>Комментарий:</b><br>${esc(o.note)}</div>`:''}</div><div class="sun-route-order-section"><b>Меню заказа</b><div style="overflow:auto;margin-top:8px"><table class="sun-route-order-lines"><thead><tr><th>Позиция</th><th>Кол.</th><th>Цена</th><th>Сумма</th></tr></thead><tbody>${lines.length?lines.map(x=>`<tr><td>${esc(x.name)}</td><td>${x.qty}</td><td>${esc(money(x.price))}</td><td><b>${esc(money(x.sum))}</b></td></tr>`).join(''):'<tr><td colspan="4">Позиции не указаны.</td></tr>'}</tbody></table></div></div>`; const body=`<div class="sun-route-order-grid"><div class="sun-route-order-kpi"><small>Дата и время</small><b>${esc(fmtDate(o.date))} · ${esc(o.time||'—')}</b></div><div class="sun-route-order-kpi"><small>Статус</small><b>${esc(o.status||'Новый')}</b></div><div class="sun-route-order-kpi"><small>Сумма</small><b>${esc(money(total))}</b></div><div class="sun-route-order-kpi"><small>Остаток</small><b>${esc(money(balance))}</b></div></div><div class="sun-route-order-section"><p><b>${esc(o.event||'Заказ')}</b>${o.guestsCount?` · ${esc(o.guestsCount)} гостей`:''}</p><p><b>Клиент:</b> ${esc(o.contact||'—')} · ${esc(o.phone||'—')}</p><p><b>Адрес:</b> ${esc(o.address||'—')}</p>${o.delivery?`<p><b>Доставка:</b> ${esc(money(o.delivery))}</p>`:''}${o.courierNote?`<div class="sun-route-order-note"><b>Курьеру:</b><br>${esc(o.courierNote)}</div>`:''}${o.note?`<div class="sun-route-order-note"><b>Комментарий:</b><br>${esc(o.note)}</div>`:''}</div><div class="sun-route-order-section"><b>Меню заказа</b><div style="overflow:auto;margin-top:8px"><table class="sun-route-order-lines"><thead><tr><th>Позиция</th><th>Кол.</th><th>Цена</th><th>Сумма</th></tr></thead><tbody>${lines.length?lines.map(x=>`<tr><td>${esc(x.name)}</td><td>${x.qty}</td><td>${esc(money(x.price))}</td><td><b>${esc(money(x.sum))}</b></td></tr>`).join(''):'<tr><td colspan="4">Позиции не указаны.</td></tr>'}</tbody></table></div></div>`;
@ -253,6 +253,7 @@
installStyles();patchSupportPermissions();installMenuPage();maintainSupport();syncMenuPermission();enhanceRoutePage(); 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); document.addEventListener('click',e=>{const more=e.target.closest('#calendar .cal-more');if(more){e.preventDefault();e.stopImmediatePropagation();showCalendarDay(more.closest('.cal-day'));return}interceptRouteActions(e)},true);
window.addEventListener('sun:cloud-permissions-changed',()=>{patchSupportPermissions();maintainSupport();syncMenuPermission();if(menuActive){renderMenuList();emptyMenuDetail();}}); window.addEventListener('sun:cloud-permissions-changed',()=>{patchSupportPermissions();maintainSupport();syncMenuPermission();if(menuActive){renderMenuList();emptyMenuDetail();}});
window.addEventListener('caterium:catalog-prices-changed',()=>{if(menuActive)renderMenuList();});
window.addEventListener('sun:cloud-state-applied',()=>{maintainSupport();syncMenuPermission();if(menuActive)renderMenuList();setTimeout(enhanceRoutePage,80)}); window.addEventListener('sun:cloud-state-applied',()=>{maintainSupport();syncMenuPermission();if(menuActive)renderMenuList();setTimeout(enhanceRoutePage,80)});
const mo=new MutationObserver(records=>{let route=false,supportBanner=false;for(const r of records){for(const n of r.addedNodes){if(n.nodeType!==1)continue;if(n.id==='routeContent'||n.querySelector?.('#routeContent')||n.closest?.('#sun-routes-view'))route=true;if(n.id==='sunDevSupportBannerV22'||n.querySelector?.('#sunDevSupportBannerV22'))supportBanner=true;}}if(route)setTimeout(enhanceRoutePage,30);if(supportBanner)setTimeout(renderSupportStatus,20);});mo.observe(document.documentElement,{childList:true,subtree:true}); const mo=new MutationObserver(records=>{let route=false,supportBanner=false;for(const r of records){for(const n of r.addedNodes){if(n.nodeType!==1)continue;if(n.id==='routeContent'||n.querySelector?.('#routeContent')||n.closest?.('#sun-routes-view'))route=true;if(n.id==='sunDevSupportBannerV22'||n.querySelector?.('#sunDevSupportBannerV22'))supportBanner=true;}}if(route)setTimeout(enhanceRoutePage,30);if(supportBanner)setTimeout(renderSupportStatus,20);});mo.observe(document.documentElement,{childList:true,subtree:true});
if(!maintenanceTimer)maintenanceTimer=setInterval(()=>{if(document.hidden)return;maintainSupport();syncMenuPermission();if($('sun-routes-view')?.classList.contains('on'))enhanceRoutePage();},8000); if(!maintenanceTimer)maintenanceTimer=setInterval(()=>{if(document.hidden)return;maintainSupport();syncMenuPermission();if($('sun-routes-view')?.classList.contains('on'))enhanceRoutePage();},8000);

View File

@ -1,7 +1,7 @@
(()=>{ (()=>{
'use strict'; 'use strict';
const VERSION='17.7.3'; 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}}; 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(){ function installAuthBoot(){

View File

@ -59,7 +59,7 @@
if(existing){message(`Пробный заказ №${existing.id} уже создан. Его можно открыть во вкладке «Заказы».`);return;} if(existing){message(`Пробный заказ №${existing.id} уже создан. Его можно открыть во вкладке «Заказы».`);return;}
const spec=read(KEY,{}).scenario?.lines,items=catalog(); const spec=read(KEY,{}).scenario?.lines,items=catalog();
if(!Array.isArray(spec)||spec.length!==3){message('Сценарий недоступен. Добавьте боксы в заказ самостоятельно.');return;} 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;} 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 total=lines.reduce((s,l)=>s+l.price*l.qty,0),day=new Date();day.setDate(day.getDate()+1);
const date=`${day.getFullYear()}-${String(day.getMonth()+1).padStart(2,'0')}-${String(day.getDate()).padStart(2,'0')}`; const 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'; $('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])); const changed=JSON.stringify(box.ingredients)!==JSON.stringify(t.rows.map(r=>[r.name,r.gross,r.unit]));
dialog.innerHTML=`<div class="ct-demo-heading"><div><small>${esc(t.number)} · ${esc(t.basis)}</small><h2>${esc(box.name)}</h2></div><button class="outline" data-ttk-close aria-label="Закрыть ТТК">×</button></div> dialog.innerHTML=`<div class="ct-demo-heading"><div><small>${esc(t.number)} · ${esc(t.basis)}</small><h2>${esc(box.name)}</h2></div><button class="outline" data-ttk-close aria-label="Закрыть ТТК">×</button></div>
<p>${changed?'Исходный выход':'Выход'}: <b>${num(t.outputGrams)} г${Number(box.category)===6?' · 1 порция':t.pieces?` · ${num(t.pieces)} шт.`:''}</b> · ${Number(box.category)===6?'Цена порции':'Цена бокса'}: <b>${money(box.price)}</b></p> <p>${changed?'Исходный выход':'Выход'}: <b>${num(t.outputGrams)} г${Number(box.category)===6?' · 1 порция':t.pieces?` · ${num(t.pieces)} шт.`:''}</b> · ${Number(box.category)===6?'Цена порции':'Цена бокса'}: <b>${money((window.CateriumPricing?.price(box)??Number(box?.price||0)))}</b></p>
${changed?'<p class="ct-demo-notice">Состав изменён. Закупка и списание используют текущий состав из редактора. Ниже показана исходная учебная ТТК.</p>':''} ${changed?'<p class="ct-demo-notice">Состав изменён. Закупка и списание используют текущий состав из редактора. Ниже показана исходная учебная ТТК.</p>':''}
<div class="ct-demo-table"><table><thead><tr><th>Продукт / упаковка</th><th>Ед.</th><th>Брутто</th><th>Нетто</th><th>Цена за ед.</th><th>Сумма</th></tr></thead><tbody>${t.rows.map(r=>`<tr><td>${esc(r.name)}</td><td>${esc(r.unit)}</td><td>${num(r.gross)}</td><td>${num(r.net)}</td><td>${money(r.unitCost)}</td><td>${money(r.gross*r.unitCost)}</td></tr>`).join('')}</tbody></table></div> <div class="ct-demo-table"><table><thead><tr><th>Продукт / упаковка</th><th>Ед.</th><th>Брутто</th><th>Нетто</th><th>Цена за ед.</th><th>Сумма</th></tr></thead><tbody>${t.rows.map(r=>`<tr><td>${esc(r.name)}</td><td>${esc(r.unit)}</td><td>${num(r.gross)}</td><td>${num(r.net)}</td><td>${money(r.unitCost)}</td><td>${money(r.gross*r.unitCost)}</td></tr>`).join('')}</tbody></table></div>
<p><b>Продукты и упаковка: ${money(t.ingredientCost)}</b></p><p class="hint">Учебные закупочные цены. Работа, доставка и накладные расходы не включены. Расход со склада считается по брутто.</p> <p><b>Продукты и упаковка: ${money(t.ingredientCost)}</b></p><p class="hint">Учебные закупочные цены. Работа, доставка и накладные расходы не включены. Расход со склада считается по брутто.</p>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

131
tests/client-menu.spec.mjs Normal file
View File

@ -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('<html><body></body></html>');
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);
});

View File

@ -2,13 +2,13 @@ import { defineConfig, devices } from '@playwright/test';
import {fileURLToPath} from 'node:url'; import {fileURLToPath} from 'node:url';
export default defineConfig({ export default defineConfig({
testDir:'.', testDir:'.',
testMatch:['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, timeout:30000,
use:{baseURL:'http://127.0.0.1:4173'}, use:{baseURL:'http://127.0.0.1:4173'},
webServer:{command:'npx http-server public -p 4173 -c-1',cwd:fileURLToPath(new URL('../',import.meta.url)),port:4173,reuseExistingServer:true}, webServer:{command:'npx http-server public -p 4173 -c-1',cwd:fileURLToPath(new URL('../',import.meta.url)),port:4173,reuseExistingServer:true},
projects:[ projects:[
{name:'iphone-pdf',testMatch:['proposal-quality.spec.mjs'],grep:/all six selections|transparent wide|an actual offer downloads/,use:{...devices['iPhone 13'],serviceWorkers:'block'}}, {name:'iphone-pdf',testMatch:['proposal-quality.spec.mjs'],grep:/all six selections|transparent wide|an actual offer downloads/,use:{...devices['iPhone 13'],serviceWorkers:'block'}},
{name:'iphone-webkit',testMatch:['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:'desktop',use:{...devices['Desktop Chrome']}},
{name:'mobile-390',use:{viewport:{width:390,height:844},isMobile:true,hasTouch:true}} {name:'mobile-390',use:{viewport:{width:390,height:844},isMobile:true,hasTouch:true}}
] ]

View File

@ -22,13 +22,15 @@ try{
}); });
await context.addInitScript(()=>{ await context.addInitScript(()=>{
const workspaceId='ui-smoke-workspace'; 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'}; 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})); localStorage.setItem('sunCloudV2Config',JSON.stringify({workspaceId,localWorkspaceId:workspaceId,tenantStorageReady:true,autoSync:false}));
window.supabase={createClient:()=>({ window.supabase={createClient:()=>({
auth:{onAuthStateChange:()=>({data:{subscription:{unsubscribe(){}}}}),getSession:async()=>({data:{session:{user}},error:null}),getUser:async()=>({data:{user},error:null})}, auth:{onAuthStateChange:()=>({data:{subscription:{unsubscribe(){}}}}),getSession:async()=>({data:{session:{user}},error:null}),getUser:async()=>({data:{user},error:null})},
rpc:async name=>{ 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_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==='caterium_trial_demo_status')return {data:{canInstall:false,canUpgrade:false},error:null};
if(name==='sun_is_platform_admin')return {data:false,error:null}; if(name==='sun_is_platform_admin')return {data:false,error:null};
return {data:null,error:null}; return {data:null,error:null};
@ -37,6 +39,7 @@ try{
})}; })};
}); });
const page=await context.newPage(); const page=await context.newPage();
await page.clock.setFixedTime(new Date());
await page.goto(base.href,{waitUntil:'domcontentloaded',timeout:60000}); await page.goto(base.href,{waitUntil:'domcontentloaded',timeout:60000});
const gate=page.locator('#sunCloudAuthGateV3'); const gate=page.locator('#sunCloudAuthGateV3');
await expect(gate).toHaveAttribute('data-auth-state','loading',{timeout:20000}); 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 expect.poll(()=>page.locator('#ctHelpResults details').count()).toBeGreaterThan(0);
await page.locator('#ctHelpClose').click(); await page.locator('#ctHelpClose').click();
await expect(page.locator('#ctHelpDialog')).not.toBeVisible(); await expect(page.locator('#ctHelpDialog')).not.toBeVisible();
results.push({width,quietLoading:true,automaticOpen:true,helpDialog:true,icon}); await page.waitForFunction(()=>window.SunOpsUXV1762&&window.CateriumDataV1773&&window.CateriumPricing);
console.log(`PASS published UI ${width}px: quiet loading, automatic open, Help dialog${icon?', native question-circle icon':''}`); 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();} }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)); await fs.writeFile(`${output}/result.json`,JSON.stringify({base:base.href,checkedAt:new Date().toISOString(),backend:'mocked and network-blocked',results},null,2));