caterium-app/public/core/banquet-menu.js
2026-09-17 21:07:54 +03:00

68 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

(()=>{
'use strict';
const groups=[['cold','Холодные закуски'],['salads','Салаты'],['starters','Горячие закуски'],['main','Горячее'],['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=>Math.round(Number(v)||0).toLocaleString('ru-RU')+' ₽';
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)?'fruit':/хлеб|масло|булоч/.test(s)?'bread':/рыб|мясн|сыр|овощ|холод|закуск/.test(s)?'cold':'other'};
function packages(catalog){const result=new Map();for(const i of catalog){const b=i.banquet;if(b?.packageId&&!result.has(b.packageId))result.set(b.packageId,{id:b.packageId,name:b.packageName||b.packageId,price:b.packagePrice,grams:b.packageGrams})}return [...result.values()]}
function selection(draft,catalog){if(!Array.isArray(draft.banquetSelection)){const ids=new Set(catalog.map(i=>String(i.id)));draft.banquetSelection=(draft.lines||[]).filter(l=>ids.has(String(l.id))).map(l=>String(l.id))}return new Set(draft.banquetSelection.map(String))}
const guestCount=draft=>Math.max(1,Math.round(Number(draft.guestsCount)||1));
const unitPrice=(item,draft)=>{const line=(draft.lines||[]).find(l=>String(l.id)===String(item.id));return line&&Number.isFinite(Number(line.price))?Math.max(0,Number(line.price)):Number(item.price)||0};
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>`:''}
${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)}<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><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>`;
}
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-edit]').forEach(b=>b.onclick=()=>window.editBox?.(b.dataset.banquetEdit));
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=`
#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-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};
})();