/* 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,{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{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=108+contactHeight+(s.estimated?22:0),bottom=H-64-footerHeight; const layout=plan(c,s,top,bottom); for(let j=0;j16&&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; 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=>``).join(''); dialog.innerHTML=`

Меню для клиента

Оформляю меню на одной странице…

`; 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]'),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,{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} позиций. Показана только цена за гостя.`;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();}} }; 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}); })();