Compare commits

...

10 Commits

Author SHA1 Message Date
pavlov346346-source
ab6d41ba0e feat: server-side order audit log for company owners
Some checks failed
Caterium QA / qa (push) Has been cancelled
Owners/admins can now see, on the Аккаунт settings tab, a
tamper-proof journal of who created, edited or deleted each order
and when — including employees who have orders.* permissions. It is
written from inside sun_save_app_state itself (which already
diffs orders server-side for permission checks), so it can't be
spoofed or wiped by the client, unlike the old per-browser
'История изменений' list which only covered the current device and
had a 'Clear history' button anyone could press.

- New table public.sun_order_audit_log (workspace, order id, action,
  actor, summary, details), locked down to security-definer writes
  only — no client insert/update/delete policy exists.
- New RPC sun_list_order_audit(workspace, limit), admin-only.
- New settings card 'Журнал заказов' reading it, admin-only,
  classified into the existing Аккаунт settings tab.
- Verified end-to-end against a local PGlite instance: create/edit/
  delete each produce one correctly-attributed row, and a non-admin
  member is denied read access.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 18:56:39 +03:00
pavlov346346-source
b25a2e0d0c fix: move payment colors into Оформление tab, unify reference-list cards
- Order payment colors settings now live under the Оформление
  (appearance) settings tab instead of Заказы, matching the request
  to group interface/appearance-related settings together.
- Client sources card is now full-width (wide) like the event types
  card, so both reference-list cards render with the same row width
  instead of one looking noticeably shorter than the other.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 18:48:20 +03:00
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
pavlov346346-source
ab2bc5f47e Verify single item PDF release wiring 2026-09-22 22:58:19 +08:00
pavlov346346-source
6561a4e43e Check single item PDF module syntax 2026-09-22 22:58:14 +08:00
pavlov346346-source
41ebe89f8f Verify single item PDF module in production 2026-09-22 22:57:44 +08:00
pavlov346346-source
dee3252dc8 Cache single item PDF module 2026-09-22 22:57:41 +08:00
pavlov346346-source
fb8f7c351a Load single item PDF export 2026-09-22 22:57:37 +08:00
pavlov346346-source
4265a88c34 Add single catalog item PDF export 2026-09-22 22:57:23 +08:00
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
11 changed files with 546 additions and 41 deletions

View File

@ -45,6 +45,7 @@ jobs:
app-runtime.js
core/hotfix-v1763.js
core/trial-promo-developer-v181.js
core/single-item-pdf.js
core/account-center-v1780.js
core/auth-security-v1774.js
core/company-branding.js

View File

@ -11,7 +11,7 @@
"serverReady": true,
"workspaceAutoDiscovery": true,
"invitesTemporarilyDisabled": false,
"pwaCache": "v110-20260918-ui-stability",
"pwaCache": "v111-20260918-ui-stability",
"fullOfferDescriptions": true,
"dynamicOfferRows": true,
"pdfOfferDescriptionFix": true,

View File

@ -4,7 +4,7 @@
"version": "17.7.3",
"type": "module",
"scripts": {
"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",
"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/single-item-pdf.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 && node tests/client-sync-v1780.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",

View File

@ -4164,7 +4164,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
.sun-crm-stats-table th{color:#817a94;font-size:10px;text-transform:uppercase;letter-spacing:.04em}
.sun-crm-stats-table td b{color:#241d45}
.sun-lead-settings-list{display:grid;gap:7px;margin:10px 0}
.sun-lead-settings-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px}
.sun-lead-settings-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px;align-items:center}
@media(max-width:700px){.sun-crm-stats-table{font-size:10px}.sun-crm-stats-table th,.sun-crm-stats-table td{padding:7px 4px}.sun-crm-hide-mobile{display:none}}
`;document.head.appendChild(style);
@ -4197,7 +4197,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
function ensureSettingsCard(){
const grid=document.querySelector('#enterprise-settings .enterprise-grid');if(!grid)return;
let card=$('sunLeadSourcesSettingsCard');
if(!card){card=document.createElement('section');card.id='sunLeadSourcesSettingsCard';card.className='enterprise-card';card.innerHTML=`<h2>Источники клиентов</h2><p class="hint">Список для поля «Узнали из» в деталях заказа. Эти данные используются в статистике.</p><div class="sun-lead-settings-list" id="sunLeadSourcesRows"></div><div class="actions"><button class="outline" id="sunLeadSourceAdd" type="button">Добавить источник</button><button class="primary" id="sunLeadSourcesSave" type="button">Сохранить список</button></div>`;
if(!card){card=document.createElement('section');card.id='sunLeadSourcesSettingsCard';card.className='enterprise-card wide';card.innerHTML=`<h2>Источники клиентов</h2><p class="hint">Список для поля «Узнали из» в деталях заказа. Эти данные используются в статистике.</p><div class="sun-lead-settings-list" id="sunLeadSourcesRows"></div><div class="actions"><button class="outline" id="sunLeadSourceAdd" type="button">Добавить источник</button><button class="primary" id="sunLeadSourcesSave" type="button">Сохранить список</button></div>`;
const eventCard=$('sunEventTypesSettingsCard');if(eventCard)eventCard.insertAdjacentElement('afterend',card);else grid.appendChild(card);
$('sunLeadSourceAdd').onclick=()=>addSourceSettingsRow('');$('sunLeadSourcesSave').onclick=()=>saveSources(qa('#sunLeadSourcesRows input').map(x=>x.value));
}
@ -4618,6 +4618,83 @@ window.SUN_LEGACY_CATALOG_V175=[];
})();
;
/* ===== MODULE: order-audit-log.js ===== */
/* Server-side journal of who created, edited or deleted an order, and when.
Written by the database itself (see sun_save_app_state in the 20260922120000
migration), so it can't be spoofed or wiped by whoever made the change
unlike the old per-browser "История изменений" list. Visible to owners and
administrators only. */
(()=>{
'use strict';
if(window.__sunOrderAuditLogCard)return;window.__sunOrderAuditLogCard=true;
const $=id=>document.getElementById(id);
const qa=(s,r=document)=>[...r.querySelectorAll(s)];
const esc=window.SunSafe.escapeHTML;
const cloud=()=>window.SunCloudV2||null;
const client=()=>cloud()?.getClient?.()||null;
const workspace=()=>cloud()?.getWorkspace?.()||null;
const ACTION_LABELS={create:'Создан',update:'Изменён',delete:'Удалён'};
const money=v=>{const n=Number(v);return Number.isFinite(n)?n.toLocaleString('ru-RU')+' ₽':''};
function canSee(){return String(workspace()?.role||'')==='admin'}
function installStyle(){
if($('sunOrderAuditStyle'))return;
const s=document.createElement('style');s.id='sunOrderAuditStyle';s.textContent=`
.sun-order-audit-list{display:grid;gap:0;margin-top:10px;border-top:1px solid #e7eaee}
.sun-order-audit-row{display:grid;grid-template-columns:96px 1fr auto;gap:10px;align-items:baseline;padding:9px 2px;border-bottom:1px dashed #e7eaee;font-size:12.5px}
.sun-order-audit-row small{display:block;color:#8a919b;font-size:10.5px}
.sun-order-audit-row b{color:#243b30}
.sun-order-audit-action{justify-self:end;padding:3px 8px;border-radius:999px;font-size:10.5px;font-weight:800;white-space:nowrap}
.sun-order-audit-action.create{background:#e7f4ea;color:#2f7a45}
.sun-order-audit-action.update{background:#fdf3de;color:#9a7016}
.sun-order-audit-action.delete{background:#fbe7e7;color:#a64040}
@media(max-width:640px){.sun-order-audit-row{grid-template-columns:1fr;gap:2px}.sun-order-audit-action{justify-self:start}}
`;document.head.appendChild(s);
}
function row(item){
const at=new Date(item.created_at);
const when=Number.isNaN(at.getTime())?'':at.toLocaleString('ru-RU',{day:'2-digit',month:'2-digit',year:'2-digit',hour:'2-digit',minute:'2-digit'});
const d=item.details||{};
const meta=[d.status?String(d.status):'',Number.isFinite(Number(d.total))?money(d.total):''].filter(Boolean).join(' · ');
return `<div class="sun-order-audit-row"><small>${esc(when)}</small><div><b>${esc(item.summary||('Заказ №'+item.order_id))}</b><small>${esc(item.actor_name||'Сотрудник')}${item.actor_role?` · ${esc(item.actor_role)}`:''}${meta?` · ${esc(meta)}`:''}</small></div><span class="sun-order-audit-action ${esc(item.action)}">${esc(ACTION_LABELS[item.action]||item.action)}</span></div>`;
}
async function load(body){
const c=client(),ws=workspace();
if(!c||!ws?.id){body.innerHTML='<p class="hint">Доступно после входа в облачную рабочую базу.</p>';return}
body.innerHTML='<p class="hint">Загрузка…</p>';
try{
const {data,error}=await c.rpc('sun_list_order_audit',{p_workspace:ws.id,p_limit:200});
if(error)throw error;
const rows=Array.isArray(data)?data:[];
body.innerHTML=rows.length?`<div class="sun-order-audit-list">${rows.map(row).join('')}</div>`:'<p class="hint">Изменений заказов пока нет.</p>';
}catch(e){body.innerHTML=`<p class="hint">Не удалось загрузить журнал: ${esc(e.message||String(e))}</p>`;}
}
function ensureCard(){
if(!canSee()){$('sunOrderAuditLogCard')?.remove();return}
const grid=document.querySelector('#enterprise-settings .enterprise-grid');if(!grid)return;
let card=$('sunOrderAuditLogCard');
if(!card){
installStyle();
card=document.createElement('section');card.className='enterprise-card wide';card.id='sunOrderAuditLogCard';
card.innerHTML='<h2>Журнал заказов</h2><p class="hint">Кто и когда создал, изменил или удалил заказ — включая сотрудников с доступом к заказам. Запись ведётся на сервере, её нельзя стереть из браузера.</p><div class="actions"><button class="outline" id="sunOrderAuditRefresh" type="button">Обновить</button></div><div id="sunOrderAuditBody"></div>';
const audit=qa(':scope > .enterprise-card',grid).find(x=>(x.querySelector('h2')?.textContent||'').trim()==='История изменений');
window.SunSafe.insertBefore(grid,card,audit||null);
$('sunOrderAuditRefresh').onclick=()=>load($('sunOrderAuditBody'));
}
const body=$('sunOrderAuditBody');if(body&&!body.dataset.loaded){body.dataset.loaded='1';load(body)}
}
document.addEventListener('click',e=>{const b=e.target.closest('header nav button');if(!b)return;if(String(b.dataset.navLabel||b.textContent||'').trim()==='Настройки')setTimeout(ensureCard,90)},true);
const host=$('enterprise-settings');if(host)new MutationObserver(()=>{if(host.classList.contains('on'))setTimeout(ensureCard,60)}).observe(host,{childList:true,subtree:true});
window.addEventListener('sun:cloud-permissions-changed',()=>setTimeout(ensureCard,120));
if($('enterprise-settings')?.classList.contains('on'))setTimeout(ensureCard,140);
})();
;
/* ===== MODULE: saas-v16.js ===== */
/* Sun Catering SaaS subscriptions, plan entitlements and platform admin v16 */
(()=>{
@ -5284,10 +5361,10 @@ window.SUN_LEGACY_CATALOG_V175=[];
const qa=(sel,root=document)=>[...root.querySelectorAll(sel)];
const TAB_KEY='sunSettingsActiveTabV1';
const TABS=[
{id:'account',label:'Аккаунт',hint:'Профиль, компания, тариф, пользователи и история изменений.'},
{id:'appearance',label:'Оформление',hint:'Цвета интерфейса, боковой панели и готовые цветовые варианты.'},
{id:'account',label:'Аккаунт',hint:'Профиль, компания, тариф, пользователи, журнал заказов и история изменений.'},
{id:'appearance',label:'Оформление',hint:'Цвета интерфейса, боковой панели, готовые цветовые варианты и цвета оплаты заказов.'},
{id:'offer',label:'Предложение',hint:'Шаблон и содержимое предложения клиенту.'},
{id:'orders',label:'Заказы',hint:'Статусы заказов и цвета состояния оплаты.'},
{id:'orders',label:'Заказы',hint:'Статусы заказов.'},
{id:'references',label:'Справочники',hint:'Типы мероприятий, источники клиентов и вкладки каталога.'},
{id:'documents',label:'Документы',hint:'Реквизиты чека, QR-код и бланк заказа.'}
];
@ -5328,14 +5405,14 @@ window.SUN_LEGACY_CATALOG_V175=[];
function heading(card){return String(card?.querySelector(':scope > h2')?.textContent||card?.querySelector('h2')?.textContent||'').trim()}
function classify(card){
const id=String(card?.id||''),h=heading(card);
if(id==='sunCloudV2Card'||id==='sunSaaSSettingsCardV16'||/^(Профиль и аккаунт|Облачная синхронизация|Тариф и подписка|Пользователи и роли|История изменений)$/.test(h)){
const order=id==='sunCloudV2Card'||/^(Профиль и аккаунт|Облачная синхронизация)$/.test(h)?10:id==='sunSaaSSettingsCardV16'||h==='Тариф и подписка'?20:h==='Пользователи и роли'?30:90;
if(id==='sunCloudV2Card'||id==='sunSaaSSettingsCardV16'||id==='sunOrderAuditLogCard'||/^(Профиль и аккаунт|Облачная синхронизация|Тариф и подписка|Пользователи и роли|Журнал заказов|История изменений)$/.test(h)){
const order=id==='sunCloudV2Card'||/^(Профиль и аккаунт|Облачная синхронизация)$/.test(h)?10:id==='sunSaaSSettingsCardV16'||h==='Тариф и подписка'?20:h==='Пользователи и роли'?30:id==='sunOrderAuditLogCard'||h==='Журнал заказов'?80:90;
return['account',order];
}
if(id==='sunBrandThemeSettingsCard'||/^(Цвета интерфейса|Настройки интерфейса|Оформление интерфейса)$/.test(h))return['appearance',10];
if(id==='sunOrderPaymentColorsCard'||h==='Цвета оплаты заказов')return['appearance',20];
if(id==='sunOfferTemplateSettingsCard'||h==='Шаблон предложения клиенту')return['offer',10];
if(id==='sunClientOfferSettingsCard'||h==='Настройки предложения клиенту')return['offer',20];
if(id==='sunOrderPaymentColorsCard'||h==='Цвета оплаты заказов')return['orders',10];
if(id==='sunOrderStatusFeatureCard'||h==='Статусы заказов')return['orders',20];
if(id==='sunEventTypesSettingsCard'||h==='Типы мероприятий')return['references',10];
if(id==='sunLeadSourcesSettingsCard'||h==='Источники клиентов')return['references',20];

View File

@ -5,6 +5,12 @@
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;};
@ -73,16 +79,17 @@
}
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,showPrices,top,bottom){
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,showPrices?money(item.cents/100):''].filter(Boolean).join(' · ')}));
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))]);
@ -92,27 +99,28 @@
}
throw new Error('Меню не помещается на одну страницу без слишком мелкого текста. Сократите названия или количество выбранных блюд. Ни одно блюдо не было обрезано.');
}
async function render(s,{showPrices=true}={}){
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='#243b30',muted='#657269',gold='#9b793e',paper='#fbf8f1',rule='#dcd5c6';
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],x,y+i*lh);drawnText.push(lines[i]);}return lines.length*lh;};
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);
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){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);}
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)+14;
if(s.event)y+=text(s.event,M,y,W-M*2,`20px ${SANS}`,muted,27)+10;
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)+24;
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?142:62)+contactHeight+(s.estimated?22:0),bottom=H-64-footerHeight;
const layout=plan(c,s,showPrices,top,bottom);
const footerHeight=108+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){
@ -123,13 +131,13 @@
}
}
let fy=bottom+22;line(M,fy,W-M,gold);fy+=20;
if(showPrices){
text('НА ОДНОГО ГОСТЯ',M,fy,380,`700 12px ${SANS}`,muted,17);
text(`МЕНЮ НА ${s.guests.toLocaleString('ru-RU')} ГОСТЕЙ`,W/2+20,fy,416,`700 12px ${SANS}`,muted,17);fy+=24;
const fitMoney=(v,x)=>{let size=30;c.font=`600 ${size}px ${SERIF}`;while(size>14&&c.measureText(v).width>400)c.font=`600 ${--size}px ${SERIF}`;text(v,x,fy,400,c.font,ink,size+4);};
fitMoney(money(s.cents/100),M);fitMoney(money(s.totalCents/100),W/2+20);fy+=45;
text('Только выбранные блюда. Доставка и услуги не включены.',M,fy,W-M*2,`12px ${SANS}`,muted,18);fy+=24;
}else if(s.grams){text(`Выход по указанным порциям: ${s.grams.toLocaleString('ru-RU')} г на гостя`,M,fy,W-M*2,`14px ${SANS}`,muted,20);fy+=26;}
// 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.
// It is not optional: the client always sees what one guest costs.
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;
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));
@ -143,7 +151,7 @@
#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 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 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);
@ -152,21 +160,22 @@
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');
dialog.innerHTML='<div class="ct-bcm-toolbar"><h2 id="ctBanquetClientTitle">Меню для клиента</h2><div class="ct-bcm-actions"><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 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><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]');
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,{showPrices:check.checked});if(!valid()||ticket!==state.generation)return;
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} позиций. ${check.checked?'Стоимость только выбранных блюд.':'Без указания стоимости.'}`;download.disabled=false;
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();}}
};
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;

View File

@ -48,7 +48,7 @@
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=20260920-onepage';script.onload=()=>{clearTimeout(timer);window.CateriumBanquetClientMenu?resolve():fail();};script.onerror=fail;timer=setTimeout(fail,12000);document.head.append(script);
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);

View File

@ -0,0 +1,233 @@
(()=>{
'use strict';
if(window.CateriumSingleItemPdf)return;
const VERSION='20260922-single-item-pdf-v1';
const $=id=>document.getElementById(id);
const esc=v=>window.SunSafe?.escapeHTML?window.SunSafe.escapeHTML(String(v??'')):String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const CATEGORY_NAMES={0:'Боксы',1:'Посуда',2:'Дополнения',3:'Напитки',4:'Доставка',5:'Премиум',6:'Банкетное меню'};
let currentItemId='',lastPdfUrl='',wrappedEditBox=null;
function toast(message,type='success'){
try{return window.SunEnterprise?.toast?.(message,type)}catch(_){}
if(type==='warn'||type==='error')console.warn(message);else console.log(message);
}
function catalog(){
try{
const rows=window.CateriumDataV1773?.catalog?.list?.();
if(Array.isArray(rows))return rows;
}catch(_){}
try{
const rows=JSON.parse(localStorage.getItem('sunBoxes')||'[]');
return Array.isArray(rows)?rows:[];
}catch(_){return[]}
}
function itemById(id){return catalog().find(x=>String(x?.id)===String(id))||null}
function categoryName(item){return String(item?.catalogSection||CATEGORY_NAMES[Number(item?.category||0)]||'Каталог').trim()}
function price(item){try{return Number(window.CateriumPricing?.price?.(item)??item?.price??0)||0}catch(_){return Number(item?.price||0)||0}}
function money(value){return `${Number(value||0).toLocaleString('ru-RU',{maximumFractionDigits:2})} ₽`}
function brand(){
try{
const b=window.CateriumBranding?.identity?.();
if(b)return b;
}catch(_){}
return {name:'Caterium',logo:'',contacts:''};
}
function cleanIngredientName(name){return String(name||'').replace(/\s*[—–-]\s*\d+(?:[.,]\d+)?\s*г(?:\s*\/\s*шт\.?)?\s*$/i,'').trim()}
function composition(item){
if(Array.isArray(item?.composition)&&item.composition.some(Boolean))return item.composition.map(x=>String(x||'').trim()).filter(Boolean);
if(Array.isArray(item?.ingredients))return item.ingredients.filter(x=>Array.isArray(x)&&x[0]).map(row=>{
const qty=Math.max(0,Number(row[1]||0)),unit=String(row[2]||'').trim(),name=cleanIngredientName(row[0]);
if(!qty||(/^поз\.?$/i.test(unit)&&qty<=1))return name;
const shown=Number.isInteger(qty)?String(qty):qty.toLocaleString('ru-RU',{maximumFractionDigits:2});
return `${name}${unit?`${shown} ${unit}`:''}`;
}).filter(Boolean);
return [];
}
function safeImageSrc(src){
const raw=String(src||'').trim();if(!raw)return'';
try{return window.SunSafe?.imageAssetSrc?window.SunSafe.imageAssetSrc(raw):raw}catch(_){return raw}
}
function loadImage(src){
return new Promise(resolve=>{
const raw=safeImageSrc(src);if(!raw)return resolve(null);
const img=new Image();let done=false;
const finish=v=>{if(done)return;done=true;clearTimeout(timer);resolve(v)};
try{const u=new URL(raw,document.baseURI||location.href);if(/^https?:$/i.test(u.protocol)&&u.origin!==location.origin)img.crossOrigin='anonymous'}catch(_){}
img.onload=()=>finish(img);img.onerror=()=>finish(null);
const timer=setTimeout(()=>finish(null),15000);img.src=raw;
});
}
function drawCover(ctx,img,x,y,w,h){
if(!img)return;
const iw=img.naturalWidth||img.width,ih=img.naturalHeight||img.height;if(!iw||!ih)return;
const scale=Math.max(w/iw,h/ih),sw=w/scale,sh=h/scale,sx=(iw-sw)/2,sy=(ih-sh)/2;
ctx.drawImage(img,sx,sy,sw,sh,x,y,w,h);
}
function drawContain(ctx,img,x,y,w,h){
if(!img)return;
const iw=img.naturalWidth||img.width,ih=img.naturalHeight||img.height;if(!iw||!ih)return;
const scale=Math.min(w/iw,h/ih),dw=iw*scale,dh=ih*scale;
ctx.drawImage(img,x+(w-dw)/2,y+(h-dh)/2,dw,dh);
}
function wrap(ctx,text,width,maxLines=20){
const words=String(text||'').replace(/\s+/g,' ').trim().split(' ').filter(Boolean),lines=[];let line='';
for(const word of words){
const test=line?`${line} ${word}`:word;
if(!line||ctx.measureText(test).width<=width)line=test;
else{lines.push(line);line=word;if(lines.length>=maxLines-1)break}
}
if(line&&lines.length<maxLines)lines.push(line);
if(words.length&&lines.length===maxLines){
let last=lines[maxLines-1]||'';
while(last.length>2&&ctx.measureText(last+'…').width>width)last=last.slice(0,-1);
lines[maxLines-1]=last.replace(/[\s,.;:-]+$/,'')+'…';
}
return lines;
}
function filename(name){
const base=String(name||'Бокс').trim().replace(/[\\/:*?"<>|]+/g,' ').replace(/\s+/g,' ').slice(0,80)||'Бокс';
return `${base}.pdf`;
}
function canvasJpeg(canvas){
const data=canvas.toDataURL('image/jpeg',.94),base64=String(data).split(',')[1]||'';
if(!base64)throw new Error('Не удалось подготовить страницу PDF.');
const raw=atob(base64),bytes=new Uint8Array(raw.length);for(let i=0;i<raw.length;i++)bytes[i]=raw.charCodeAt(i);
return {bytes,width:canvas.width,height:canvas.height};
}
async function renderItemPage(item){
const W=1240,H=1754,heroH=1110,canvas=document.createElement('canvas');canvas.width=W;canvas.height=H;
const ctx=canvas.getContext('2d',{alpha:false});ctx.textBaseline='alphabetic';ctx.textAlign='left';
ctx.fillStyle='#fff';ctx.fillRect(0,0,W,H);
const b=brand(),photo=await loadImage(item.photo||'');
if(photo)drawCover(ctx,photo,0,0,W,heroH);
else{
ctx.fillStyle='#f3f0e9';ctx.fillRect(0,0,W,heroH);
const logo=await loadImage(b.logo||'');if(logo)drawContain(ctx,logo,370,280,500,500);
else{ctx.fillStyle='#d7d1c6';ctx.font='700 72px Arial,sans-serif';ctx.textAlign='center';ctx.fillText(String(b.name||'Caterium'),W/2,heroH/2);ctx.textAlign='left'}
}
// Subtle photo readability veil at the very top, matching the reference's clean label.
const topGrad=ctx.createLinearGradient(0,0,0,170);topGrad.addColorStop(0,'rgba(255,255,255,.72)');topGrad.addColorStop(1,'rgba(255,255,255,0)');
ctx.fillStyle=topGrad;ctx.fillRect(0,0,W,180);
ctx.fillStyle='#c45f73';ctx.font='500 30px Arial,sans-serif';ctx.fillText(categoryName(item).toLowerCase(),42,70);
const panelY=heroH;ctx.fillStyle='#fff';ctx.fillRect(0,panelY,W,H-panelY);
const titleY=1195,priceY=1195;
ctx.fillStyle='#171719';ctx.font='400 52px Arial,sans-serif';
const titleLines=wrap(ctx,String(item.name||'').toUpperCase(),710,2);
titleLines.forEach((line,i)=>ctx.fillText(line,38,titleY+i*58));
ctx.textAlign='right';ctx.fillStyle='#171719';ctx.font='400 50px Arial,sans-serif';ctx.fillText(money(price(item)),1197,priceY);
ctx.strokeStyle='#c94f67';ctx.lineWidth=5;ctx.beginPath();ctx.moveTo(970,1212);ctx.lineTo(1197,1212);ctx.stroke();ctx.textAlign='left';
const metaY=1260;
ctx.textAlign='right';ctx.fillStyle='#242426';ctx.font='500 25px Arial,sans-serif';
const pieces=Math.max(0,Math.round(Number(item.pieces||0)));
if(pieces)ctx.fillText(`${pieces} шт.`,1197,metaY);
if(item.weight)ctx.fillText(`Вес: ${String(item.weight)}`,1197,metaY+(pieces?38:0));
ctx.textAlign='left';
const lines=composition(item);
ctx.fillStyle='#55565a';ctx.font='400 24px Arial,sans-serif';
let y=1328,rendered=0;
for(const raw of lines){
const parts=wrap(ctx,raw,760,2);
for(const part of parts){
if(rendered>=8)break;
ctx.fillText(part,38,y);y+=34;rendered++;
}
if(rendered>=8)break;
}
if(!rendered){ctx.fillStyle='#83858a';ctx.fillText('Состав не указан.',38,y)}
// Company mark in the lower-right, similar to the reference watermark.
ctx.textAlign='right';ctx.fillStyle='#76b9b4';ctx.font='500 22px Arial,sans-serif';
ctx.fillText(String(b.name||window.CateriumBranding?.documentName?.()||'Caterium'),1197,1708);
ctx.textAlign='left';
return canvasJpeg(canvas);
}
async function buildItemPdfBlob(id){
const item=itemById(id);if(!item)throw new Error('Позиция не найдена в каталоге.');
if(!window.SunPdfEngine?.fromJpegs)throw new Error('PDF-движок ещё не загружен.');
const page=await renderItemPage(item);
return window.SunPdfEngine.fromJpegs([page]);
}
function loadingPage(win,item){
try{
win.document.open();win.document.write(`<!doctype html><meta charset="utf-8"><title>${esc(item?.name||'PDF бокса')}</title><style>body{margin:0;display:grid;place-items:center;min-height:100vh;background:#f5f2eb;font:16px Arial;color:#15364c}.box{text-align:center;background:#fff;padding:28px 34px;border-radius:14px;box-shadow:0 10px 35px #0002}.mark{font-size:32px;color:#c99a32}.p{margin-top:9px;color:#68717a}</style><div class="box"><div class="mark">☼</div><b>Формируем PDF</b><div class="p">Один бокс · одна страница A4</div></div>`);win.document.close();
}catch(_){}
}
async function openItemPdf(id){
const item=itemById(id);if(!item){toast('Позиция не найдена в каталоге.','warn');return}
const viewer=window.open('about:blank','_blank');if(viewer)loadingPage(viewer,item);
try{
const blob=await buildItemPdfBlob(id);
if(lastPdfUrl)URL.revokeObjectURL(lastPdfUrl);lastPdfUrl=URL.createObjectURL(blob);
if(viewer)viewer.location.replace(lastPdfUrl);
else{
const a=document.createElement('a');a.href=lastPdfUrl;a.download=filename(item.name);a.style.display='none';document.body.appendChild(a);a.click();a.remove();
toast('PDF бокса подготовлен.','success');
}
}catch(error){
try{if(viewer)viewer.document.body.innerHTML=`<div style="font:16px Arial;padding:30px;color:#7a2e2e"><b>Не удалось сформировать PDF.</b><p>${esc(error?.message||error)}</p></div>`}catch(_){}
toast(error?.message||'Не удалось сформировать PDF.','warn');
}
}
function ensureEditorButton(){
const actions=document.querySelector('#editor .dialog .actions');if(!actions)return null;
let btn=$('sunSingleItemPdfButton');
if(!btn){
btn=document.createElement('button');btn.id='sunSingleItemPdfButton';btn.type='button';btn.className='outline';btn.textContent='PDF бокса';
btn.title='Открыть одностраничный PDF выбранного бокса';
btn.onclick=()=>{const id=String(btn.dataset.itemId||'');if(id)void openItemPdf(id)};
const danger=actions.querySelector('.danger');if(danger)danger.before(btn);else actions.appendChild(btn);
}
return btn;
}
function syncEditorButton(id){
currentItemId=String(id||'');const btn=ensureEditorButton();if(!btn)return;
const item=currentItemId?itemById(currentItemId):null;
btn.dataset.itemId=currentItemId;
btn.hidden=!item;btn.style.display=item?'inline-flex':'none';
btn.textContent=item&&[0,5].includes(Number(item.category||0))?'PDF бокса':'PDF позиции';
}
function injectReadOnlyButton(){
const host=document.querySelector('#sunMenuDetailV1762 .sun-menu-readonly');if(!host||!currentItemId||host.querySelector('[data-single-item-pdf]'))return;
const item=itemById(currentItemId);if(!item)return;
const btn=document.createElement('button');btn.type='button';btn.className='outline';btn.dataset.singleItemPdf='1';btn.textContent=[0,5].includes(Number(item.category||0))?'PDF бокса':'PDF позиции';
btn.style.margin='0 0 14px';btn.onclick=()=>void openItemPdf(currentItemId);
host.insertBefore(btn,host.firstElementChild?.nextSibling||host.firstChild);
}
function wrapEditor(){
if(typeof window.editBox!=='function'||window.editBox===wrappedEditBox)return;
const previous=window.editBox;
wrappedEditBox=function(id,...args){
currentItemId=String(id||'');
const result=previous.call(this,id,...args);
setTimeout(()=>syncEditorButton(currentItemId),0);
return result;
};
wrappedEditBox.__singlePdfWrapped=true;window.editBox=wrappedEditBox;
}
function boot(){
ensureEditorButton();syncEditorButton('');
wrapEditor();
document.addEventListener('click',event=>{
const row=event.target.closest?.('[data-menu-item-v1762]');if(row){currentItemId=String(row.dataset.menuItemV1762||'');setTimeout(injectReadOnlyButton,0)}
},true);
new MutationObserver(()=>{if(window.editBox!==wrappedEditBox)wrapEditor();if(document.querySelector('#editor .dialog .actions')&&!$('sunSingleItemPdfButton'))syncEditorButton(currentItemId);injectReadOnlyButton();}).observe(document.documentElement,{childList:true,subtree:true});
}
window.CateriumSingleItemPdf=Object.freeze({VERSION,buildItemPdfBlob,openItemPdf,renderItemPage});
window.sunBuildSingleCatalogItemPdfBlob=buildItemPdfBlob;
window.sunOpenSingleCatalogItemPdf=openItemPdf;
window.sunSyncSingleCatalogPdfButton=syncEditorButton;
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot();
})();

View File

@ -8488,4 +8488,5 @@ body #sunBlankSettingsCardV3{display:none!important}
</script>
<script src="app-runtime.js?v=20260921-client-sync"></script>
<script src="app-runtime.js?v=20260922-settings-tabs-cleanup"></script>
<script src="core/single-item-pdf.js?v=20260922-single-item-pdf-v1"></script>

View File

@ -3,7 +3,8 @@ const VERSION='20260918-ui-stability';
const CORE=[
'./core/support-form.js?v=20260921-support-recipient',
'./core/trial-promo-developer-v181.js?v=20260921-promo-entry',
'./core/banquet-client-menu.js?v=20260920-onepage',
'./core/single-item-pdf.js?v=20260922-single-item-pdf-v1',
'./core/banquet-client-menu.js?v=20260922-guest-price-always',
'./core/training-catalog.js?v=20260922-training-photos',
'./core/catalog-pricing.js?v=20260919-client-menu','./core/client-menu.css?v=20260919-client-menu',
'./vendor/supabase-2.112.4.min.js',
@ -17,7 +18,7 @@ const CORE=[
];
const CRITICAL_FRESH=new Set([
'/core/support-form.js',
'/core/trial-promo-developer-v181.js','/core/hotfix-v1763.js',
'/core/trial-promo-developer-v181.js','/core/single-item-pdf.js','/core/hotfix-v1763.js',
'/core/banquet-client-menu.js',
'/core/training-catalog.js',
'/core/catalog-pricing.js','/core/client-menu.css',

View File

@ -0,0 +1,177 @@
-- Server-side, tamper-proof audit trail for order changes.
-- The owner needs to see when an employee with orders.* permissions
-- created, edited or deleted an order, on any device, at any time —
-- not just in a local per-browser history that an employee could
-- clear themselves. This writes from inside sun_save_app_state,
-- which already detects created/deleted/edited orders server-side
-- for permission checks, so the log can't be spoofed or skipped by
-- the client and needs no extra client-side call.
create table if not exists public.sun_order_audit_log (
id bigint generated always as identity primary key,
workspace_id uuid not null references public.sun_workspaces(id) on delete cascade,
order_id text not null,
action text not null check (action in ('create','update','delete')),
actor_id uuid references auth.users(id) on delete set null,
actor_name text not null default '',
actor_role text not null default '',
summary text not null default '',
details jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
create index if not exists sun_order_audit_log_workspace_idx
on public.sun_order_audit_log(workspace_id, created_at desc);
-- Only the security-definer functions below (running as their owner,
-- which bypasses RLS) ever write here. No insert/update/delete policy
-- is granted to end users, so an employee cannot edit or clear their
-- own trail the way the old local-only history could be cleared.
alter table public.sun_order_audit_log enable row level security;
revoke all on public.sun_order_audit_log from public, anon, authenticated;
create or replace function public.sun_list_order_audit(p_workspace uuid, p_limit integer default 200)
returns table(
id bigint,
order_id text,
action text,
actor_name text,
actor_role text,
summary text,
details jsonb,
created_at timestamptz
)
language plpgsql
stable
security definer
set search_path = public
as $$
begin
if coalesce(public.sun_member_role(p_workspace),'') <> 'admin' then
raise exception 'Administrator required';
end if;
return query
select l.id, l.order_id, l.action, l.actor_name, l.actor_role, l.summary, l.details, l.created_at
from public.sun_order_audit_log l
where l.workspace_id = p_workspace
order by l.created_at desc
limit least(greatest(coalesce(p_limit,200),1),1000);
end;
$$;
revoke all on function public.sun_list_order_audit(uuid,integer) from public, anon;
grant execute on function public.sun_list_order_audit(uuid,integer) to authenticated;
create or replace function public.sun_save_app_state(p_workspace uuid, p_payload jsonb, p_client_id text)
returns table(workspace_id uuid, payload jsonb, revision bigint, updated_at timestamptz, client_id text)
language plpgsql
security definer
set search_path='public'
as $$
declare
v_old jsonb := '{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb;
v_old_storage jsonb;
v_new_storage jsonb;
v_final_storage jsonb;
v_final_payload jsonb;
v_key text;
v_perm text;
v_feature text;
v_old_orders jsonb;
v_new_orders jsonb;
v_create boolean;
v_delete boolean;
v_edit boolean;
v_saved public.sun_app_state%rowtype;
v_actor_name text;
v_actor_role text;
v_row jsonb;
begin
if public.sun_member_role(p_workspace) is null then raise exception 'Access denied'; end if;
if public.sun_subscription_access_mode(p_workspace)<>'full' then raise exception 'Подписка истекла: база доступна только для просмотра'; end if;
if p_payload is null or jsonb_typeof(p_payload) <> 'object' or jsonb_typeof(coalesce(p_payload->'storage','{}'::jsonb)) <> 'object' then raise exception 'Invalid payload'; end if;
select a.payload into v_old from public.sun_app_state a where a.workspace_id=p_workspace;
if v_old is null then v_old := '{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb; end if;
v_old_storage := coalesce(v_old->'storage','{}'::jsonb);
v_new_storage := coalesce(p_payload->'storage','{}'::jsonb);
v_final_storage := v_old_storage;
for v_key in select key from (select jsonb_object_keys(v_old_storage) key union select jsonb_object_keys(v_new_storage) key) q loop
if not public.sun_can_read_storage_key(p_workspace,v_key) and not (v_new_storage ? v_key) then continue; end if;
if coalesce(v_old_storage->v_key,'null'::jsonb) = coalesce(v_new_storage->v_key,'null'::jsonb) then continue; end if;
if v_key='sunOrders' then
if not public.sun_workspace_has_feature(p_workspace,'orders') then raise exception 'Заказы недоступны на текущем тарифе'; end if;
v_old_orders := coalesce(v_old_storage #> array['sunOrders','v'],'[]'::jsonb);
v_new_orders := coalesce(v_new_storage #> array['sunOrders','v'],'[]'::jsonb);
if jsonb_typeof(v_old_orders)<>'array' or jsonb_typeof(v_new_orders)<>'array' then raise exception 'Invalid orders payload'; end if;
select exists(select 1 from jsonb_array_elements(v_new_orders) n where not exists(select 1 from jsonb_array_elements(v_old_orders) o where o->>'id'=n->>'id')) into v_create;
select exists(select 1 from jsonb_array_elements(v_old_orders) o where not exists(select 1 from jsonb_array_elements(v_new_orders) n where n->>'id'=o->>'id')) into v_delete;
select exists(select 1 from jsonb_array_elements(v_new_orders) n join lateral (select o from jsonb_array_elements(v_old_orders) o where o->>'id'=n->>'id' limit 1) x on true where x.o<>n) into v_edit;
if v_create and not public.sun_has_permission(p_workspace,'orders.create') then raise exception 'Нет права создавать заказы'; end if;
if v_edit and not public.sun_has_permission(p_workspace,'orders.edit') then raise exception 'Нет права изменять заказы'; end if;
if v_delete and not public.sun_has_permission(p_workspace,'orders.delete') then raise exception 'Нет права удалять заказы'; end if;
if v_create or v_edit or v_delete then
select m.display_name, m.role into v_actor_name, v_actor_role
from public.sun_workspace_members m where m.workspace_id=p_workspace and m.user_id=auth.uid();
if coalesce(trim(v_actor_name),'')='' then
select u.email into v_actor_name from auth.users u where u.id=auth.uid();
end if;
v_actor_name := coalesce(nullif(trim(v_actor_name),''),'Сотрудник');
v_actor_role := coalesce(v_actor_role,'');
for v_row in select n from jsonb_array_elements(v_new_orders) n
where not exists(select 1 from jsonb_array_elements(v_old_orders) o where o->>'id'=(n)->>'id')
loop
insert into public.sun_order_audit_log(workspace_id,order_id,action,actor_id,actor_name,actor_role,summary,details)
values (p_workspace, coalesce(v_row->>'id',''), 'create', auth.uid(), v_actor_name, v_actor_role,
format('№%s · %s', coalesce(v_row->>'id','?'), coalesce(nullif(v_row->>'event',''),'Заказ')),
jsonb_build_object('event',v_row->'event','date',v_row->'date','status',v_row->'status','total',v_row->'total'));
end loop;
for v_row in select o from jsonb_array_elements(v_old_orders) o
where not exists(select 1 from jsonb_array_elements(v_new_orders) n where n->>'id'=(o)->>'id')
loop
insert into public.sun_order_audit_log(workspace_id,order_id,action,actor_id,actor_name,actor_role,summary,details)
values (p_workspace, coalesce(v_row->>'id',''), 'delete', auth.uid(), v_actor_name, v_actor_role,
format('№%s · %s', coalesce(v_row->>'id','?'), coalesce(nullif(v_row->>'event',''),'Заказ')),
jsonb_build_object('event',v_row->'event','date',v_row->'date','status',v_row->'status','total',v_row->'total'));
end loop;
for v_row in select n from jsonb_array_elements(v_new_orders) n
join lateral (select o from jsonb_array_elements(v_old_orders) o where o->>'id'=(n)->>'id' limit 1) x on true
where x.o<>n
loop
insert into public.sun_order_audit_log(workspace_id,order_id,action,actor_id,actor_name,actor_role,summary,details)
values (p_workspace, coalesce(v_row->>'id',''), 'update', auth.uid(), v_actor_name, v_actor_role,
format('№%s · %s', coalesce(v_row->>'id','?'), coalesce(nullif(v_row->>'event',''),'Заказ')),
jsonb_build_object('event',v_row->'event','date',v_row->'date','status',v_row->'status','total',v_row->'total','prepayment',v_row->'prepayment'));
end loop;
end if;
else
v_feature := public.sun_feature_for_storage_key(v_key);
if v_feature is not null and not public.sun_workspace_has_feature(p_workspace,v_feature) then raise exception 'Функция недоступна на текущем тарифе: %',v_feature; end if;
v_perm := public.sun_required_write_permission(v_key);
if v_perm='_member' then null;
elsif not public.sun_has_permission(p_workspace,v_perm) then raise exception 'Нет права изменять раздел: %',v_key;
end if;
end if;
if v_new_storage ? v_key then v_final_storage := v_final_storage || jsonb_build_object(v_key,v_new_storage->v_key);
else v_final_storage := v_final_storage - v_key; end if;
end loop;
v_final_payload := jsonb_build_object('format',coalesce(p_payload->'format',v_old->'format','"sun-cloud-v2"'::jsonb),'version',coalesce(p_payload->'version',v_old->'version','2'::jsonb),'storage',v_final_storage);
insert into public.sun_app_state as app_state(workspace_id,payload,client_id)
values (p_workspace,v_final_payload,p_client_id)
on conflict on constraint sun_app_state_pkey do update set payload=excluded.payload,client_id=excluded.client_id
returning app_state.* into v_saved;
insert into public.sun_sync_events(workspace_id,revision,client_id) values (p_workspace,v_saved.revision,p_client_id);
return query select * from public.sun_fetch_app_state(p_workspace);
end;
$$;
revoke all on function public.sun_save_app_state(uuid,jsonb,text) from public, anon;
grant execute on function public.sun_save_app_state(uuid,jsonb,text) to authenticated;
notify pgrst, 'reload schema';

View File

@ -9,12 +9,18 @@ const pkg=JSON.parse(readRoot('package.json'));
const lock=JSON.parse(readRoot('package-lock.json'));
const releaseManifest=JSON.parse(readRoot('docs/release-manifest.json'));
check(index.includes('core/stability-v1760.css'),'mobile stability stylesheet loaded');
check(fs.existsSync(path.join(pub,'core/single-item-pdf.js')),'single item PDF module exists');
const singleItemPdf=read('core/single-item-pdf.js');
check(index.includes('core/single-item-pdf.js?v=20260922-single-item-pdf-v1'),'single item PDF module is loaded after the app runtime');
check(singleItemPdf.includes('sunSingleItemPdfButton')&&singleItemPdf.includes('PDF бокса'),'saved catalog item exposes a one-item PDF action');
check(singleItemPdf.includes('SunPdfEngine.fromJpegs([page])')&&singleItemPdf.includes('heroH=1110'),'single item export builds one A4 PDF page with a large photo layout');
check(singleItemPdf.includes('item.weight')&&singleItemPdf.includes('item.pieces')&&singleItemPdf.includes('composition(item)'),'single item PDF includes weight, piece count and composition');
check(css.includes('overflow-x:hidden')&&css.includes('.cats'),'mobile overflow guard present');
check(!index.includes('offer-gallery-data.js'),'blocking Base64 gallery absent');
check((runtime.match(/\/Type \/Catalog/g)||[]).length===0,'runtime contains no PDF binary writer');
check(read('core/pdf-engine.js').includes('595.28')&&read('core/pdf-engine.js').includes('841.89'),'PDF engine uses A4 MediaBox');
check([...index.matchAll(/@page\{([^}]*)\}/g)].every(m=>/size:A4/i.test(m[1])),'compact @page rules use A4');
check(sw.includes('v110-20260918-ui-stability')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js')&&sw.includes('offer-workspace-v1769.js'),'service worker cache is v17.7.3');
check(sw.includes('20260918-ui-stability')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js')&&sw.includes('offer-workspace-v1769.js'),'service worker cache is v17.7.3');
check(index.includes('20260918-ui-stability')&&index.includes('classic-offer-pdf-v1767.js')&&!index.includes('20260907-v17-6-0-stability-security'),'index cache-busting points to v17.7.3');
check(performance.includes('SunAttachmentGuard')&&performance.includes('TARGET=2*1024*1024'),'chat photo auto-compression is versioned');
check(performance.includes("rpc('sun_dev_dashboard')")&&performance.includes('server_size')&&performance.includes('storage_size'),'Developer Console server/storage counters are versioned');
@ -39,7 +45,7 @@ check(!/sb_secret_[A-Za-z0-9_-]{20,}|service_role\s*[:=]\s*["'][A-Za-z0-9._-]{30
check(lock.version===pkg.version&&lock.packages?.['']?.version===pkg.version,'package.json and package-lock.json versions match');
check(releaseManifest.version===`v${pkg.version}`,'release manifest version matches package.json');
check(releaseManifest.channel==='production','release manifest channel is production');
check(String(releaseManifest.pwaCache||'').includes('v110-20260918-ui-stability'),'release manifest points to current PWA cache');
check(String(releaseManifest.pwaCache||'').includes('v111-20260918-ui-stability'),'release manifest points to current PWA cache');
check(['17.6.2','17.6.3','17.6.4','17.6.5','17.6.6','17.6.7','17.6.8','17.6.9','17.7.0','17.7.1','17.7.2','17.7.3'].every(v=>fs.existsSync(path.join(root,`docs/releases/V${v}-CHANGES.txt`))),'release notes exist through v17.7.3');
check(runtime.includes('CLOUD_RPC_TIMEOUT_MS=45000')&&runtime.includes('CLOUD_CONFLICT_MAX_RETRIES=4')&&runtime.includes('retryCount'),'cloud sync has timeout and capped exponential conflict retries');
check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes('ERROR_DEDUPE_MS=5*60*1000')&&runtime.includes('mirrorBusy=false')&&runtime.includes('backupBusy=false'),'stability logger uses current version, dedupe and single-flight guards');