caterium-app/public/core/banquet-menu.js
pavlov346346-source 3aa268907e fix: always show the per-guest price in client banquet menu
Per-item prices next to each dish were already removed; the
per-guest price footer is no longer an optional checkbox — it now
always renders, since it should never be hidden, only the per-dish
prices were meant to be removed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 18:00:52 +03:00

87 lines
21 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

(()=>{
'use strict';
const groups=[['cold','Холодные закуски'],['salads','Салаты'],['starters','Горячие закуски'],['main','Горячее'],['sides','Гарниры'],['desserts','Десерты'],['fruit','Фрукты и ягоды'],['bread','Хлеб и масло'],['other','Другие блюда']];
let packageId='all',groupId='all';
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const money=v=>Number(v||0).toLocaleString('ru-RU',{maximumFractionDigits:2})+' ₽';
const grams=item=>{const value=String(item?.weight||'').replace(',','.');return Math.round((parseFloat(value)||0)*(/кг/i.test(value)?1000:1))};
const group=item=>{const s=String(item?.catalogSection||'').toLowerCase();return /салат/.test(s)?'salads':/(горяч|тёпл|тепл).*закуск/.test(s)?'starters':/горяч|основн/.test(s)?'main':/гарнир/.test(s)?'sides':/десерт|сладк/.test(s)?'desserts':/фрукт|ягод/.test(s)?'fruit':/хлеб|масло|булоч/.test(s)?'bread':/рыб|мясн|сыр|овощ|холод|закуск/.test(s)?'cold':'other'};
function packages(catalog){const result=new Map();for(const i of catalog){const b=i.banquet;if(b?.packageId&&!result.has(b.packageId))result.set(b.packageId,{id:b.packageId,name:b.packageName||b.packageId,price:b.packagePrice,grams:b.packageGrams})}return [...result.values()]}
function selection(draft,catalog){if(!Array.isArray(draft.banquetSelection)){const ids=new Set(catalog.map(i=>String(i.id)));draft.banquetSelection=(draft.lines||[]).filter(l=>ids.has(String(l.id))).map(l=>String(l.id))}return new Set(draft.banquetSelection.map(String))}
const guestCount=draft=>Math.max(1,Math.round(Number(draft.guestsCount)||1));
const unitPrice=(item,draft)=>{const line=(draft.lines||[]).find(l=>String(l.id)===String(item.id));return line&&Number.isFinite(Number(line.price))?Math.max(0,Number(line.price)):(window.CateriumPricing?.price(item)??Number(item?.price||0))};
function totals(items,draft){return {price:items.reduce((n,i)=>n+unitPrice(i,draft),0),grams:items.reduce((n,i)=>n+grams(i),0)}}
function chooseMenu(draft,catalog,id){const rows=catalog.filter(i=>i.banquet?.packageId===id),used=new Set();draft.banquetSelection=rows.filter(i=>{const choice=i.banquet?.choiceGroup;if(!choice)return true;if(used.has(choice))return false;const defaults=rows.some(r=>r.banquet?.choiceGroup===choice&&r.banquet.defaultChoice);if(defaults&&!i.banquet.defaultChoice)return false;used.add(choice);return true}).map(i=>String(i.id));packageId=id;groupId='all'}
function toggle(draft,catalog,id){const ids=selection(draft,catalog),item=catalog.find(i=>String(i.id)===id);if(!item)return;if(ids.has(id))ids.delete(id);else{const choice=item.banquet?.choiceGroup;if(choice)for(const old of catalog)if(old.banquet?.packageId===item.banquet.packageId&&old.banquet?.choiceGroup===choice)ids.delete(String(old.id));ids.add(id)}draft.banquetSelection=[...ids]}
function apply(draft,catalog){const selected=selection(draft,catalog),rows=catalog.filter(i=>selected.has(String(i.id))),ids=new Set(catalog.map(i=>String(i.id))),count=guestCount(draft);if(!rows.length)return;const lines=rows.map(i=>({id:i.id,name:i.name,category:6,qty:count,price:unitPrice(i,draft)}));draft.lines=[...(draft.lines||[]).filter(l=>!ids.has(String(l.id))&&Number(l.category)!==6),...lines];draft.guestsCount=count;return lines}
function render({items,catalog,draft}){
const menus=packages(catalog);if(packageId!=='all'&&!menus.some(m=>m.id===packageId))packageId='all';
const candidates=items.filter(i=>packageId==='all'||i.banquet?.packageId===packageId),available=groups.filter(([id])=>candidates.some(i=>group(i)===id));
if(groupId!=='all'&&!available.some(([id])=>id===groupId))groupId='all';
const visible=candidates.filter(i=>groupId==='all'||group(i)===groupId),ids=selection(draft,catalog),selected=catalog.filter(i=>ids.has(String(i.id))),sum=totals(selected,draft),count=guestCount(draft);
const menu=menus.find(m=>m.id===packageId),sections=new Map();
for(const [id] of groups)for(const item of visible.filter(i=>group(i)===id)){const section=item.catalogSection||'Без раздела';if(!sections.has(section))sections.set(section,[]);sections.get(section).push(item)}
const estimates=catalog.some(i=>i.banquet?.estimated),hasExisting=(draft.lines||[]).some(l=>catalog.some(i=>String(i.id)===String(l.id))||Number(l.category)===6);
return `<div class="ct-banquet" aria-label="Конструктор банкетного меню">
<div class="ct-banquet-intro"><div><span class="ct-eyebrow">Банкетное меню</span><h2>Соберите меню для гостей</h2><p>Выберите готовое меню или отметьте отдельные блюда.</p></div><button class="outline" type="button" data-banquet-add> Добавить блюдо</button></div>
${menus.length?`<div class="ct-banquet-packages" aria-label="Пакеты меню"><button type="button" data-banquet-package="all" aria-pressed="${packageId==='all'}"><b>Все блюда</b><small>${catalog.length} позиций</small></button>${menus.map(m=>`<button type="button" data-banquet-package="${esc(m.id)}" aria-pressed="${packageId===m.id}"><b>${esc(m.name)}</b><span>${m.price?money(m.price):'Своя подборка'}</span><small>${m.grams?`${Number(m.grams).toLocaleString('ru-RU')} г · `:''}на 1 гостя</small></button>`).join('')}</div>`:''}
${catalog.some(i=>i.demo&&i.ttk)?'<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>`:''}
<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)}${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><button class="outline" type="button" data-banquet-client-menu ${selected.length?'':'disabled'}>Меню для клиента</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){
const {catalog,draft,rerender}=context;
root.querySelectorAll('[data-banquet-package]').forEach(b=>b.onclick=()=>{packageId=b.dataset.banquetPackage;groupId='all';rerender()});
root.querySelectorAll('[data-banquet-group]').forEach(b=>b.onclick=()=>{groupId=b.dataset.banquetGroup;rerender()});
root.querySelectorAll('[data-banquet-item]').forEach(b=>b.onchange=()=>{toggle(draft,catalog,b.dataset.banquetItem);rerender()});
root.querySelectorAll('[data-banquet-ttk]').forEach(b=>b.onclick=()=>window.CateriumTrialDemo?.showTTK(b.dataset.banquetTtk));
root.querySelectorAll('[data-banquet-edit]').forEach(b=>b.onclick=()=>window.editBox?.(b.dataset.banquetEdit));
const clientMenu=root.querySelector('[data-banquet-client-menu]');
clientMenu.onclick=async()=>{
const who=JSON.stringify([window.SunCloudV2?.getSession?.()?.user?.id,window.SunCloudV2?.getWorkspace?.()?.id]);
const input=JSON.parse(JSON.stringify({catalog,draft,event:document.getElementById('event')?.value??draft.event,date:document.getElementById('date')?.value??draft.date}));
clientMenu.disabled=true;
try{
if(!window.CateriumBanquetClientMenu)await new Promise((resolve,reject)=>{
const script=document.createElement('script');let timer;
const fail=()=>{clearTimeout(timer);script.remove();reject(new Error('Не удалось загрузить оформление меню. Повторите попытку.'));};
script.src='core/banquet-client-menu.js?v=20260922-guest-price-always';script.onload=()=>{clearTimeout(timer);window.CateriumBanquetClientMenu?resolve():fail();};script.onerror=fail;timer=setTimeout(fail,12000);document.head.append(script);
});
if(!root.isConnected||who!==JSON.stringify([window.SunCloudV2?.getSession?.()?.user?.id,window.SunCloudV2?.getWorkspace?.()?.id]))return;
await window.CateriumBanquetClientMenu.open(input);
}catch(error){if(root.isConnected)window.alert(error.message);}
finally{if(clientMenu.isConnected)clientMenu.disabled=false;}
};
const preset=root.querySelector('[data-banquet-preset]');if(preset)preset.onclick=()=>{chooseMenu(draft,catalog,preset.dataset.banquetPreset);rerender()};
const clear=root.querySelector('[data-banquet-clear]');if(clear)clear.onclick=()=>{draft.banquetSelection=[];rerender()};
root.querySelector('[data-banquet-add]').onclick=()=>window.editBox?.(null);
const guests=root.querySelector('[data-banquet-guests]');guests.oninput=()=>{draft.guestsCount=Math.min(10000,Math.max(1,Math.round(Number(guests.value)||1)));const field=document.getElementById('eventGuests');if(field)field.value=draft.guestsCount;const ids=selection(draft,catalog),sum=totals(catalog.filter(i=>ids.has(String(i.id))),draft);root.querySelector('[data-banquet-total]').textContent=money(sum.price*draft.guestsCount);root.querySelector('[data-banquet-total-weight]').textContent=(sum.grams*draft.guestsCount/1000).toLocaleString('ru-RU',{maximumFractionDigits:2})+' кг'};guests.onchange=()=>{guests.value=guestCount(draft)};
root.querySelector('[data-banquet-apply]').onclick=()=>{if(window.SunAdminRBACV3&&!window.SunAdminRBACV3.hasPermission(draft.id?'orders.edit':'orders.create'))return;apply(draft,catalog);const field=document.getElementById('eventGuests');if(field)field.value=draft.guestsCount;rerender();window.SunEnterprise?.toast?.('Банкетное меню добавлено в заказ.','success')};
}
function editor(category,item,catalog){
const section=document.getElementById('sunBanquetSectionLabel');if(!section)return;
let extra=document.getElementById('ctBanquetEditor');
if(!extra){extra=document.createElement('div');extra.id='ctBanquetEditor';extra.innerHTML='<label>Пакет меню<input id="ctBanquetPackage" list="ctBanquetPackageNames" placeholder="Например, Стандарт"><datalist id="ctBanquetPackageNames"></datalist></label><label class="ct-banquet-estimate-check"><input type="checkbox" id="ctBanquetEstimated"> Вес и цена предварительные</label>';section.insertAdjacentElement('afterend',extra)}
extra.hidden=Number(category)!==6;if(extra.hidden)return;
document.getElementById('ctBanquetPackage').value=item?.banquet?.packageName||'';
document.getElementById('ctBanquetEstimated').checked=item?.banquet?.estimated===true;
document.getElementById('ctBanquetPackageNames').innerHTML=packages(catalog).map(m=>`<option value="${esc(m.name)}"></option>`).join('');
let list=document.getElementById('ctBanquetSections');if(!list){list=document.createElement('datalist');list.id='ctBanquetSections';list.innerHTML=['Рыбные закуски','Мясные закуски','Сыры','Овощные закуски','Салаты','Горячие закуски','Горячие блюда','Гарниры','Десерты','Фрукты и ягоды','Хлеб и масло'].map(s=>`<option value="${s}"></option>`).join('');extra.appendChild(list)}
document.getElementById('banquetSection')?.setAttribute('list',list.id);
}
function saveEditor(item,catalog){if(Number(item.category)!==6)return;const input=document.getElementById('ctBanquetPackage');if(!input)return;const name=input.value.trim(),known=packages(catalog).find(m=>m.name===name),same=item.banquet?.packageName===name;item.banquet={...(same?item.banquet:{}),packageId:name?(known?.id||'custom-'+name.toLowerCase().replace(/\s+/g,'-')):'',packageName:name,estimated:document.getElementById('ctBanquetEstimated')?.checked===true};if(known){item.banquet.packagePrice=known.price;item.banquet.packageGrams=known.grams}}
const style=document.createElement('style');style.textContent=`
.ct-banquet-summary [data-banquet-client-menu]{width:100%;margin-top:10px;font-size:12px;min-height:42px}
#tiles .ct-banquet{grid-column:1/-1;min-width:0;width:100%;color:#34352e}.ct-banquet-intro{display:flex;justify-content:space-between;align-items:center;gap:12px;margin:6px 0 20px}.ct-eyebrow{font-size:10px;font-weight:800;letter-spacing:.1em;text-transform:uppercase;color:#8b775c}.ct-banquet h2{font-size:24px;margin:6px 0}.ct-banquet-intro p{font-size:12px;color:#7a7d75;margin:0}.ct-banquet-intro button{white-space:nowrap;font-size:12px}.ct-banquet-packages{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}.ct-banquet-packages button{display:flex;flex-direction:column;align-items:flex-start;gap:6px;padding:14px 12px;border:1px solid #e3e1d9;border-radius:12px;background:#fff;color:#3e4238;text-align:left;min-width:0}.ct-banquet-packages b{font-size:14px}.ct-banquet-packages span{font-size:20px;font-weight:700}.ct-banquet-packages small{font-size:11px;color:#777c70}.ct-banquet-packages button[aria-pressed=true]{background:#edf1e7;border:1.5px solid #667956;box-shadow:0 3px 12px #3344250a}.ct-banquet-estimate{font-size:11px;line-height:1.5;color:#81735f;margin:12px 0 18px}.ct-banquet-preset{display:flex;gap:10px;align-items:center;justify-content:space-between;background:#f6f4ed;border-radius:10px;padding:10px 12px;font-size:12px;margin:14px 0}.ct-banquet-preset button{font-size:11px;white-space:nowrap}.ct-banquet-groups{display:flex;flex-wrap:wrap;gap:7px;margin:16px 0}.ct-banquet-groups button{border:1px solid #e5e4df;border-radius:20px;padding:8px 10px;background:white;color:#707467;font-size:11px;font-weight:600}.ct-banquet-groups button[aria-pressed=true]{background:#3c4933;border-color:#3c4933;color:white}.ct-banquet-groups span{opacity:.65;margin-left:4px}.ct-banquet-layout{display:grid;grid-template-columns:minmax(0,1fr);gap:16px;align-items:start}.ct-banquet-dishes{min-width:0}.ct-banquet-section{margin-bottom:20px}.ct-banquet-section h3{font-size:12px;color:#7b806e;font-weight:700;letter-spacing:.025em;margin:0 0 8px;padding-top:8px}.ct-banquet-section h3 small{display:block;font-size:10px;font-weight:400;letter-spacing:0;margin-top:4px}.ct-banquet-dish{display:flex;align-items:center;border:1px solid #ecebe6;border-radius:10px;margin:6px 0;padding:10px 8px;background:#fff;gap:3px}.ct-banquet-dish.selected{border-color:#b0bd9e;background:#f6f8f1}.ct-banquet-dish label{display:grid;grid-template-columns:17px minmax(0,1fr) 46px 67px;align-items:center;gap:9px;flex:1;min-width:0;cursor:pointer;font-size:12px;color:#3e4439}.ct-banquet-dish input{accent-color:#65794c;width:16px;height:16px;margin:0}.ct-banquet-name{line-height:1.4;font-weight:600;overflow-wrap:anywhere}.ct-banquet-name small{display:block;color:#9a9d90;font-size:10px;font-weight:400;margin-top:3px}.ct-banquet-weight{font-size:11px;color:#8a8e82;text-align:right;white-space:nowrap}.ct-banquet-price{text-align:right;white-space:nowrap;font-size:12px}.ct-banquet-dish>button{border:0;background:none;color:#8c947f;padding:5px;font-size:14px}.ct-banquet-dish>[data-banquet-ttk]{font-size:10px;font-weight:700}.ct-banquet-summary{border:1px solid #e4e4d9;border-radius:14px;background:#fcfcf8;padding:17px;min-width:0}.ct-banquet-summary h3{font-size:22px;margin:6px 0 18px}.ct-banquet-summary label{font-size:11px;gap:7px}.ct-banquet-summary input{width:100%;border-radius:7px;font-size:15px;background:white}.ct-banquet-metric,.ct-banquet-total{display:grid;grid-template-columns:1fr auto;gap:7px;margin:18px 0}.ct-banquet-metric span,.ct-banquet-total span{font-size:12px;color:#767e6a}.ct-banquet-metric small,.ct-banquet-total small{grid-column:2;text-align:right;font-size:12px;color:#8c927f}.ct-banquet-total{border-top:1px solid #e3e4d9;padding-top:14px}.ct-banquet-total b{font-size:19px}.ct-banquet-summary .primary{width:100%;font-size:12px;background:#43543a;border-color:#43543a;line-height:1.4}.ct-banquet-summary .primary:disabled{opacity:.4;cursor:default}.ct-banquet-help{display:block;font-size:11px;line-height:1.5;color:#919581;margin-top:10px}.ct-banquet-summary details{font-size:11px;line-height:1.5;margin-top:15px}.ct-banquet-summary li{margin:6px 0}.ct-banquet-summary ul{padding-left:16px}.ct-banquet-clear{border:0;background:none;color:#92977f;font-size:11px;padding:12px 0 0}.ct-banquet-estimate-check{flex-direction:row!important;align-items:center;gap:8px;margin-top:12px}.ct-banquet-estimate-check input{width:16px;height:16px}.ct-banquet-summary:focus-within{outline:none}
@container (min-width:760px){.ct-banquet-layout{grid-template-columns:minmax(0,1fr) 230px}.ct-banquet-summary{position:sticky;top:12px}}
#new.sun-banquet-active .catalog{container-type:inline-size;width:100%;min-width:0}
#new.sun-banquet-active #sunCatalogSearchV1775,#new.sun-banquet-active .sun-catalog-view-switch{display:none!important}
@media(max-width:600px){.ct-banquet-intro{align-items:flex-start;flex-direction:column}.ct-banquet h2{font-size:21px}.ct-banquet-packages{grid-template-columns:repeat(2,minmax(0,1fr))}.ct-banquet-dish label{grid-template-columns:16px minmax(0,1fr) 67px;gap:7px}.ct-banquet-name{grid-column:2/4}.ct-banquet-dish input{grid-row:1/3}.ct-banquet-weight{grid-column:2;text-align:left}.ct-banquet-price{grid-column:3}.ct-banquet-preset{align-items:flex-start;flex-direction:column}}
`;document.head.appendChild(style);
window.addEventListener('sun:cloud-tenant-changing',()=>{packageId='all';groupId='all'});
window.CateriumBanquet={render,bind,editor,saveEditor,packages,group,grams,totals,chooseMenu,toggle,apply};
})();