caterium-app/public/core/banquet-client-menu.js
pavlov346346-source 923ea64650 feat: hide per-item prices in client banquet menu, add 3 layout themes
The one-page banquet menu PDF for clients no longer prints a price
next to each dish, and the footer no longer shows a grand total —
only the price per guest is shown, so the document can't be read
as a per-dish price list. Added a theme selector (Золото/Ночь/
Минимал) with three visually distinct color/typography treatments
for the same one-page layout.

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

189 lines
19 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.

/* One-page banquet menus. This module only reads an explicit selection snapshot;
opening, changing display options and exporting never save or apply an order. */
(()=>{
'use strict';
if(window.CateriumBanquetClientMenu)return;
const W=1000,H=1414,M=64,SANS='"Caterium Menu Sans",Arial,sans-serif',SERIF='"Caterium Menu Serif",Georgia,serif';
const groups=[['cold','Холодные закуски'],['salads','Салаты'],['starters','Горячие закуски'],['main','Горячее'],['sides','Гарниры'],['desserts','Десерты'],['fruit','Фрукты и ягоды'],['bread','Хлеб и масло'],['other','Другие блюда']];
const THEMES={
gold:{label:'Золото',paper:'#fbf8f1',ink:'#243b30',muted:'#657269',accent:'#9b793e',rule:'#dcd5c6',titleAlign:'left'},
noir:{label:'Ночь',paper:'#1c1c20',ink:'#f4f1e9',muted:'#a79f8f',accent:'#c9a24a',rule:'#3c3b3d',titleAlign:'left'},
mono:{label:'Минимал',paper:'#ffffff',ink:'#161616',muted:'#6c6c6c',accent:'#161616',rule:'#e6e6e6',titleAlign:'center'}
};
const themeIds=Object.keys(THEMES);
const clean=v=>String(v??'').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g,'').trim();
const money=v=>Number(v).toLocaleString('ru-RU',{maximumFractionDigits:2})+' ₽';
const number=v=>{const n=Number(String(v??'').replace(',','.'));return Number.isFinite(n)?n:0;};
const copy=v=>JSON.parse(JSON.stringify(v));
const scope=()=>JSON.stringify([window.SunCloudV2?.getSession?.()?.user?.id||'',window.SunCloudV2?.getWorkspace?.()?.id||'']);
let fontsPromise,enginePromise,panel=null;
function allowed(){
const c=window.SunCloudV2,s=window.SunSaaSV16;
if(c?.isSupportMode?.()||c?.getSupportMode?.())return false;
if(s?.hasFeature?.('client_offers')===false||s?.isWritable?.()===false)return false;
return !c?.getSession?.()?.user||c.hasPermission?.('catalog.view')!==false;
}
function section(item){
if(window.CateriumBanquet?.group)return window.CateriumBanquet.group(item);
const s=clean(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 snapshot(input){
const catalog=Array.isArray(input?.catalog)?input.catalog:[],draft=input?.draft||{};
const ids=new Set((Array.isArray(draft.banquetSelection)?draft.banquetSelection:(draft.lines||[]).filter(l=>catalog.some(i=>String(i.id)===String(l.id)&&Number(i.category)===6)).map(l=>l.id)).map(String));
const rows=catalog.filter(i=>Number(i.category)===6&&i.hidden!==true&&ids.has(String(i.id)));
if(!rows.length)throw new Error('Сначала выберите блюда или готовое меню.');
if(rows.length>100)throw new Error('Для одной страницы выбрано слишком много блюд. Сократите меню.');
const items=rows.map(item=>{
const line=(draft.lines||[]).find(l=>String(l.id)===String(item.id));
const raw=line?.price,hasPrice=raw!==undefined&&raw!==null&&String(raw).trim()!==''&&Number.isFinite(Number(raw))&&Number(raw)>=0;
const price=hasPrice?Number(raw):(window.CateriumPricing?.price(item)??number(item.price));
const grams=window.CateriumBanquet?.grams?.(item)??Math.round((parseFloat(clean(item.weight).replace(',','.'))||0)*(/кг/i.test(clean(item.weight))?1000:1));
return {id:String(item.id),name:clean(item.name)||'Без названия',group:section(item),weight:clean(item.weight),grams:Math.max(0,number(grams)),cents:Math.round(Math.max(0,number(price))*100),estimated:item.banquet?.estimated===true};
});
const guests=Math.min(10000,Math.max(1,Math.round(number(draft.guestsCount)||1))),cents=items.reduce((n,i)=>n+i.cents,0);
if(!Number.isSafeInteger(cents*guests))throw new Error('Стоимость меню слишком велика для точного расчёта. Проверьте цены.');
const sourceBrand=input?.brand||window.CateriumBranding?.identity?.()||{};
return {items,guests,cents,totalCents:cents*guests,grams:items.reduce((n,i)=>n+i.grams,0),event:clean(input?.event??draft.event),date:clean(input?.date??draft.date),brand:{name:clean(sourceBrand.name)||'Моя компания',logo:clean(sourceBrand.logo),contacts:clean(sourceBrand.contacts)},estimated:items.some(i=>i.estimated)};
}
async function fonts(){
if(!fontsPromise)fontsPromise=Promise.all([['Caterium Menu Sans','Manrope.ttf','200 800'],['Caterium Menu Serif','PlayfairDisplay.ttf','400 900']].map(async([name,file,weight])=>{
if(!window.FontFace||!document.fonts)return;
let timer;try{const f=new FontFace(name,`url("${new URL('fonts/'+file,document.baseURI)}")`,{weight});await Promise.race([f.load(),new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error('font timeout')),6000);})]);document.fonts.add(f);}catch(_){/* Measured system-font fallback; no late font swap. */}finally{clearTimeout(timer);}
}));return fontsPromise;
}
function engine(){
if(window.SunPdfEngine)return Promise.resolve(window.SunPdfEngine);
if(enginePromise)return enginePromise;
enginePromise=new Promise((resolve,reject)=>{
const script=document.createElement('script');let timer;
const fail=()=>{clearTimeout(timer);script.remove();enginePromise=null;reject(new Error('Не удалось загрузить экспорт PDF. Проверьте интернет и повторите.'));};
script.src=new URL('core/pdf-engine.js?v=20260920-onepage',document.baseURI).href;
script.onload=()=>{clearTimeout(timer);window.SunPdfEngine?resolve(window.SunPdfEngine):fail();};script.onerror=fail;timer=setTimeout(fail,12000);document.head.append(script);
});return enginePromise;
}
async function logoImage(src){
if(!/^data:image\/(png|jpe?g|webp);base64,/i.test(src)||src.length>2000000)return null;
return new Promise(resolve=>{const i=new Image();let done=false;const finish=v=>{if(done)return;done=true;clearTimeout(timer);resolve(v);};const timer=setTimeout(()=>finish(null),3500);i.onload=()=>{try{resolvePrepared();}catch(_){finish(null);}};function resolvePrepared(){finish(window.CateriumBranding?.prepareLogoImage?.(i)||i);}i.onerror=()=>finish(null);i.src=src;});
}
function wrap(ctx,value,width){
const lines=[];let line='';
for(const para of clean(value).split(/\r?\n/)){
for(const word of para.split(/\s+/).filter(Boolean)){
if(ctx.measureText((line?line+' ':'')+word).width<=width){line+=(line?' ':'')+word;continue;}
if(line){lines.push(line);line='';}
for(const ch of word){if(line&&ctx.measureText(line+ch).width>width){lines.push(line);line='';}line+=ch;}
}
if(line){lines.push(line);line='';}
}
return lines;
}
function dateLabel(value){if(!/^\d{4}-\d{2}-\d{2}$/.test(value))return value;const d=new Date(value+'T12:00:00');return Number.isNaN(d.getTime())?value:d.toLocaleDateString('ru-RU',{day:'numeric',month:'long',year:'numeric'});}
function guestLabel(n){const a=n%100,b=n%10;return `${n.toLocaleString('ru-RU')} ${a>10&&a<20?'гостей':b===1?'гость':b>=2&&b<=4?'гостя':'гостей'}`;}
function plan(ctx,s,top,bottom){
const ordered=groups.flatMap(([id])=>s.items.filter(i=>i.group===id));
// Keep every dish and every character. Never crop, use ellipses or make a
// second page. A genuinely oversized menu gets a clear, recoverable error.
// Per-item prices are never shown to the client — only the per-guest total in the footer.
for(let font=24;font>=17;font--){
const choices=ordered.length<=8?[1,2]:[2,1];
for(const columns of choices){
const width=(W-M*2-(columns-1)*42)/columns;
ctx.font=`${font}px ${SANS}`;
const rows=ordered.map(item=>({item,lines:wrap(ctx,item.name,width),meta:[item.weight].filter(Boolean).join(' · ')}));
for(const row of rows){ctx.font=`${Math.max(13,font-5)}px ${SANS}`;row.metaLines=wrap(ctx,row.meta,width);row.height=row.lines.length*font*1.3+row.metaLines.length*(font-2)+10;}
const column=list=>{let group='',height=0;const entries=[];for(const row of list){const newGroup=row.item.group!==group;if(newGroup){height+=40;group=row.item.group;}entries.push({...row,heading:newGroup?groups.find(g=>g[0]===group)[1]:null});height+=row.height;}return {entries,height};};
let candidates=columns===1?[[column(rows)]]:Array.from({length:rows.length-1},(_,i)=>[column(rows.slice(0,i+1)),column(rows.slice(i+1))]);
candidates=candidates.filter(cols=>cols.every(c=>c.height<=bottom-top));
if(candidates.length){candidates.sort((a,b)=>Math.max(...a.map(c=>c.height))-Math.max(...b.map(c=>c.height)));return {font,width,columns:candidates[0],top,bottom};}
}
}
throw new Error('Меню не помещается на одну страницу без слишком мелкого текста. Сократите названия или количество выбранных блюд. Ни одно блюдо не было обрезано.');
}
async function render(s,{showPrices=true,themeId='gold'}={}){
const theme=THEMES[themeId]||THEMES.gold;
await fonts();const [pdf,logo]=await Promise.all([engine(),logoImage(s.brand.logo)]);
const canvas=document.createElement('canvas');canvas.width=W*2;canvas.height=H*2;
const c=canvas.getContext('2d',{alpha:false});c.scale(2,2);c.textBaseline='top';
const drawnText=[],{ink,muted,accent:gold,paper,rule}=theme,centered=theme.titleAlign==='center';
const text=(value,x,y,width,font,color=ink,lh=24,align='left')=>{c.font=font;c.fillStyle=color;c.textAlign=align;const lines=wrap(c,value,width);for(let i=0;i<lines.length;i++){c.fillText(lines[i],align==='center'?x+width/2:x,y+i*lh);drawnText.push(lines[i]);}return lines.length*lh;};
const line=(x,y,x2,color=rule)=>{c.strokeStyle=color;c.lineWidth=1;c.beginPath();c.moveTo(x,y);c.lineTo(x2,y);c.stroke();};
c.fillStyle=paper;c.fillRect(0,0,W,H);c.strokeStyle=rule;c.lineWidth=1;c.strokeRect(24,24,W-48,H-48);
const brandHeight=text(s.brand.name,M,58,logo?590:W-M*2,`600 21px ${SANS}`,ink,27,centered?'center':'left');
let headerBottom=Math.max(112,58+brandHeight);
if(logo&&!centered){const scale=Math.min(210/logo.width,66/logo.height);c.drawImage(logo,W-M-logo.width*scale,54,logo.width*scale,logo.height*scale);headerBottom=Math.max(headerBottom,125);}
line(M,headerBottom+14,W-M,gold);
let y=headerBottom+42;
y+=text('Банкетное меню',M,y,W-M*2,`500 52px ${SERIF}`,ink,63,centered?'center':'left')+14;
if(s.event)y+=text(s.event,M,y,W-M*2,`20px ${SANS}`,muted,27,centered?'center':'left')+10;
const facts=[s.date?dateLabel(s.date):'',guestLabel(s.guests)].filter(Boolean).join(' · ');
y+=text(facts,M,y,W-M*2,`600 16px ${SANS}`,gold,23,centered?'center':'left')+24;
line(M,y,W-M);const top=y+20;
c.font=`13px ${SANS}`;const contacts=wrap(c,s.brand.contacts,W-M*2),contactHeight=contacts.length*19;
const footerHeight=(showPrices?108:62)+contactHeight+(s.estimated?22:0),bottom=H-64-footerHeight;
const layout=plan(c,s,top,bottom);
for(let j=0;j<layout.columns.length;j++){
const col=layout.columns[j],x=M+j*(layout.width+42);let yy=top;
for(const row of col.entries){
if(row.heading){text(row.heading.toLocaleUpperCase('ru-RU'),x,yy+5,layout.width,`700 14px ${SANS}`,gold,18);yy+=40;}
yy+=text(row.item.name,x,yy,layout.width,`${layout.font}px ${SANS}`,ink,layout.font*1.3);
if(row.meta)yy+=text(row.meta,x,yy+4,layout.width,`${Math.max(13,layout.font-5)}px ${SANS}`,muted,layout.font-2);
yy+=10;
}
}
let fy=bottom+22;line(M,fy,W-M,gold);fy+=20;
// Only the per-guest price is ever shown to the client — never a per-item price
// and never the grand total, so the menu can't be read as a full price list.
if(showPrices){
text('ЦЕНА НА ОДНОГО ГОСТЯ',M,fy,W-M*2,`700 12px ${SANS}`,muted,17,centered?'center':'left');fy+=24;
let size=34;c.font=`600 ${size}px ${SERIF}`;const v=money(s.cents/100);while(size>16&&c.measureText(v).width>W-M*2)c.font=`600 ${--size}px ${SERIF}`;
text(v,M,fy,W-M*2,c.font,ink,size+4,centered?'center':'left');fy+=size+16;
text('За гостя, только выбранные блюда. Доставка и услуги не включены.',M,fy,W-M*2,`12px ${SANS}`,muted,18,centered?'center':'left');fy+=24;
}else if(s.grams){text(`Выход по указанным порциям: ${s.grams.toLocaleString('ru-RU')} г на гостя`,M,fy,W-M*2,`14px ${SANS}`,muted,20,centered?'center':'left');fy+=26;}
if(s.estimated){text('≈ Вес и стоимость предварительные — уточняются при согласовании.',M,fy,W-M*2,`12px ${SANS}`,muted,18);fy+=22;}
if(s.brand.contacts)text(s.brand.contacts,M,fy+5,W-M*2,`13px ${SANS}`,muted,19);
const jpeg=await new Promise((resolve,reject)=>canvas.toBlob(b=>b?resolve(b):reject(new Error('Не удалось сформировать страницу меню.')),'image/jpeg',.96));
const blob=pdf.fromJpegs([{width:canvas.width,height:canvas.height,bytes:new Uint8Array(await jpeg.arrayBuffer())}]);
return {canvas,jpeg,blob,layout,drawnText};
}
function close(){if(!panel)return;const old=panel;panel=null;old.generation++;if(old.url)URL.revokeObjectURL(old.url);old.dialog.remove();if(old.restore?.isConnected)old.restore.focus({preventScroll:true});}
function style(){
if(document.getElementById('ctBanquetClientStyle'))return;
const el=document.createElement('style');el.id='ctBanquetClientStyle';el.textContent=`
#ctBanquetClientDialog{width:min(980px,calc(100vw - 24px));max-width:none;max-height:94dvh;padding:0;border:1px solid #d5cebd;border-radius:16px;background:#f8f5ed;color:#243b30;overflow:auto;box-sizing:border-box}
#ctBanquetClientDialog::backdrop{background:#0009}#ctBanquetClientDialog .ct-bcm-toolbar{position:sticky;top:0;z-index:1;display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap;padding:15px 18px;background:#f8f5ed;border-bottom:1px solid #ddd5c4}
#ctBanquetClientDialog h2{font-size:18px;margin:0}#ctBanquetClientDialog .ct-bcm-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center}#ctBanquetClientDialog .ct-bcm-actions label{display:flex;flex-direction:row;align-items:center;gap:7px;font-size:12px;margin:0}
#ctBanquetClientDialog input[type=checkbox]{width:18px!important;height:18px;min-height:0;margin:0}#ctBanquetClientDialog select{min-height:34px;padding:5px 8px;border-radius:8px;border:1px solid #c8c9bb;background:#fff;color:#243b30;font:600 12px Arial}#ctBanquetClientDialog button{min-height:40px;padding:9px 12px;border-radius:9px;border:1px solid #c8c9bb;background:#fff;color:#243b30;font:600 12px Arial;cursor:pointer}#ctBanquetClientDialog [data-bcm-download]{background:#243b30;color:#fff;border-color:#243b30}
#ctBanquetClientDialog button:disabled{opacity:.45;cursor:default}#ctBanquetClientDialog [data-bcm-status]{margin:14px 18px;font-size:13px;line-height:1.5}#ctBanquetClientDialog [data-bcm-preview]{padding:0 18px 18px}#ctBanquetClientDialog img{display:block;width:100%;max-width:700px;height:auto;margin:auto;box-shadow:0 3px 20px #0002}
#ctBanquetClientDialog [hidden]{display:none!important}#ctBanquetClientDialog :focus-visible{outline:3px solid #ac8a4b;outline-offset:2px}@media(max-width:600px){#ctBanquetClientDialog .ct-bcm-toolbar{padding:12px}#ctBanquetClientDialog .ct-bcm-actions{gap:6px}#ctBanquetClientDialog [data-bcm-preview]{padding:0 8px 10px}}
`;document.head.append(el);
}
async function open(input){
if(!allowed())throw new Error('Предложения клиенту недоступны для текущих прав или тарифа.');
const s=snapshot(input),started=scope();close();style();
const dialog=document.createElement('dialog');dialog.id='ctBanquetClientDialog';dialog.setAttribute('aria-labelledby','ctBanquetClientTitle');
const themeOptions=themeIds.map(id=>`<option value="${id}">${THEMES[id].label}</option>`).join('');
dialog.innerHTML=`<div class="ct-bcm-toolbar"><h2 id="ctBanquetClientTitle">Меню для клиента</h2><div class="ct-bcm-actions"><label>Оформление<select data-bcm-theme>${themeOptions}</select></label><label><input type="checkbox" data-bcm-prices checked>Показывать цену за гостя</label><button type="button" data-bcm-download disabled>Скачать PDF</button><button type="button" data-bcm-share hidden disabled>Поделиться</button><button type="button" data-bcm-close aria-label="Закрыть меню для клиента">Закрыть</button></div></div><p data-bcm-status role="status" aria-live="polite">Оформляю меню на одной странице…</p><div data-bcm-preview></div>`;
const state={dialog,snapshot:s,generation:0,url:null,result:null,restore:document.activeElement};panel=state;
const get=q=>dialog.querySelector(q),status=get('[data-bcm-status]'),download=get('[data-bcm-download]'),share=get('[data-bcm-share]'),check=get('[data-bcm-prices]'),themeSelect=get('[data-bcm-theme]');
document.body.append(dialog);dialog.addEventListener('cancel',e=>{e.preventDefault();close();});get('[data-bcm-close]').onclick=close;dialog.showModal();
const valid=()=>panel===state&&scope()===started&&allowed();
const build=async()=>{
const ticket=++state.generation;download.disabled=true;share.disabled=true;state.result=null;status.textContent='Оформляю меню на одной странице…';
try{const result=await render(s,{showPrices:check.checked,themeId:themeSelect.value});if(!valid()||ticket!==state.generation)return;
if(state.url)URL.revokeObjectURL(state.url);state.url=URL.createObjectURL(result.jpeg);state.result=result;
const img=document.createElement('img');img.src=state.url;img.alt='Банкетное меню: одна страница A4';get('[data-bcm-preview]').replaceChildren(img);
status.textContent=`Готово: одна страница A4 · ${s.items.length} позиций. ${check.checked?'Показана только цена за гостя.':'Без указания цены.'}`;download.disabled=false;
const file=new File([result.blob],'Банкетное меню.pdf',{type:'application/pdf'});share.hidden=!(navigator.share&&navigator.canShare?.({files:[file]}));share.disabled=false;
}catch(error){if(valid()&&ticket===state.generation){status.textContent=error.message;get('[data-bcm-preview]').replaceChildren();}}
};
check.onchange=build;themeSelect.onchange=build;
download.onclick=()=>{if(!valid()||!state.result)return;const url=URL.createObjectURL(state.result.blob),a=document.createElement('a');a.href=url;a.download=`Банкетное меню${s.date?' '+s.date:''}.pdf`;document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),60000);};
share.onclick=async()=>{if(!valid()||!state.result)return;try{await navigator.share({files:[new File([state.result.blob],'Банкетное меню.pdf',{type:'application/pdf'})],title:'Банкетное меню'});}catch(error){if(valid()&&error.name!=='AbortError')status.textContent='Не удалось открыть отправку. Сохраните PDF и отправьте его вручную.';}};
await build();return state.result;
}
window.addEventListener('sun:cloud-tenant-changing',close);
window.addEventListener('sun:cloud-permissions-changed',()=>{if(panel&&!allowed())close();});
window.addEventListener('sun:subscription-changed',()=>{if(panel&&!allowed())close();});
window.CateriumBanquetClientMenu=Object.freeze({snapshot,render,open,close});
})();