Compare commits
10 Commits
a6bd7f911b
...
ab6d41ba0e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab6d41ba0e | ||
|
|
b25a2e0d0c | ||
|
|
3aa268907e | ||
|
|
ab2bc5f47e | ||
|
|
6561a4e43e | ||
|
|
41ebe89f8f | ||
|
|
dee3252dc8 | ||
|
|
fb8f7c351a | ||
|
|
4265a88c34 | ||
|
|
923ea64650 |
1
.github/workflows/deploy-timeweb.yml
vendored
1
.github/workflows/deploy-timeweb.yml
vendored
@ -45,6 +45,7 @@ jobs:
|
|||||||
app-runtime.js
|
app-runtime.js
|
||||||
core/hotfix-v1763.js
|
core/hotfix-v1763.js
|
||||||
core/trial-promo-developer-v181.js
|
core/trial-promo-developer-v181.js
|
||||||
|
core/single-item-pdf.js
|
||||||
core/account-center-v1780.js
|
core/account-center-v1780.js
|
||||||
core/auth-security-v1774.js
|
core/auth-security-v1774.js
|
||||||
core/company-branding.js
|
core/company-branding.js
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
"serverReady": true,
|
"serverReady": true,
|
||||||
"workspaceAutoDiscovery": true,
|
"workspaceAutoDiscovery": true,
|
||||||
"invitesTemporarilyDisabled": false,
|
"invitesTemporarilyDisabled": false,
|
||||||
"pwaCache": "v110-20260918-ui-stability",
|
"pwaCache": "v111-20260918-ui-stability",
|
||||||
"fullOfferDescriptions": true,
|
"fullOfferDescriptions": true,
|
||||||
"dynamicOfferRows": true,
|
"dynamicOfferRows": true,
|
||||||
"pdfOfferDescriptionFix": true,
|
"pdfOfferDescriptionFix": true,
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
"version": "17.7.3",
|
"version": "17.7.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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",
|
"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: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",
|
"check:deploy": "npm run check:syntax && npm run test:static && npm run check:release && node tests/backend-cutover.mjs && npm run test:db",
|
||||||
|
|||||||
@ -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 th{color:#817a94;font-size:10px;text-transform:uppercase;letter-spacing:.04em}
|
||||||
.sun-crm-stats-table td b{color:#241d45}
|
.sun-crm-stats-table td b{color:#241d45}
|
||||||
.sun-lead-settings-list{display:grid;gap:7px;margin:10px 0}
|
.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}}
|
@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);
|
`;document.head.appendChild(style);
|
||||||
|
|
||||||
@ -4197,7 +4197,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
|
|||||||
function ensureSettingsCard(){
|
function ensureSettingsCard(){
|
||||||
const grid=document.querySelector('#enterprise-settings .enterprise-grid');if(!grid)return;
|
const grid=document.querySelector('#enterprise-settings .enterprise-grid');if(!grid)return;
|
||||||
let card=$('sunLeadSourcesSettingsCard');
|
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);
|
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));
|
$('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 ===== */
|
/* ===== MODULE: saas-v16.js ===== */
|
||||||
/* Sun Catering SaaS subscriptions, plan entitlements and platform admin v16 */
|
/* 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 qa=(sel,root=document)=>[...root.querySelectorAll(sel)];
|
||||||
const TAB_KEY='sunSettingsActiveTabV1';
|
const TAB_KEY='sunSettingsActiveTabV1';
|
||||||
const TABS=[
|
const TABS=[
|
||||||
{id:'account',label:'Аккаунт',hint:'Профиль, компания, тариф, пользователи и история изменений.'},
|
{id:'account',label:'Аккаунт',hint:'Профиль, компания, тариф, пользователи, журнал заказов и история изменений.'},
|
||||||
{id:'appearance',label:'Оформление',hint:'Цвета интерфейса, боковой панели и готовые цветовые варианты.'},
|
{id:'appearance',label:'Оформление',hint:'Цвета интерфейса, боковой панели, готовые цветовые варианты и цвета оплаты заказов.'},
|
||||||
{id:'offer',label:'Предложение',hint:'Шаблон и содержимое предложения клиенту.'},
|
{id:'offer',label:'Предложение',hint:'Шаблон и содержимое предложения клиенту.'},
|
||||||
{id:'orders',label:'Заказы',hint:'Статусы заказов и цвета состояния оплаты.'},
|
{id:'orders',label:'Заказы',hint:'Статусы заказов.'},
|
||||||
{id:'references',label:'Справочники',hint:'Типы мероприятий, источники клиентов и вкладки каталога.'},
|
{id:'references',label:'Справочники',hint:'Типы мероприятий, источники клиентов и вкладки каталога.'},
|
||||||
{id:'documents',label:'Документы',hint:'Реквизиты чека, QR-код и бланк заказа.'}
|
{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 heading(card){return String(card?.querySelector(':scope > h2')?.textContent||card?.querySelector('h2')?.textContent||'').trim()}
|
||||||
function classify(card){
|
function classify(card){
|
||||||
const id=String(card?.id||''),h=heading(card);
|
const id=String(card?.id||''),h=heading(card);
|
||||||
if(id==='sunCloudV2Card'||id==='sunSaaSSettingsCardV16'||/^(Профиль и аккаунт|Облачная синхронизация|Тариф и подписка|Пользователи и роли|История изменений)$/.test(h)){
|
if(id==='sunCloudV2Card'||id==='sunSaaSSettingsCardV16'||id==='sunOrderAuditLogCard'||/^(Профиль и аккаунт|Облачная синхронизация|Тариф и подписка|Пользователи и роли|Журнал заказов|История изменений)$/.test(h)){
|
||||||
const order=id==='sunCloudV2Card'||/^(Профиль и аккаунт|Облачная синхронизация)$/.test(h)?10:id==='sunSaaSSettingsCardV16'||h==='Тариф и подписка'?20:h==='Пользователи и роли'?30:90;
|
const order=id==='sunCloudV2Card'||/^(Профиль и аккаунт|Облачная синхронизация)$/.test(h)?10:id==='sunSaaSSettingsCardV16'||h==='Тариф и подписка'?20:h==='Пользователи и роли'?30:id==='sunOrderAuditLogCard'||h==='Журнал заказов'?80:90;
|
||||||
return['account',order];
|
return['account',order];
|
||||||
}
|
}
|
||||||
if(id==='sunBrandThemeSettingsCard'||/^(Цвета интерфейса|Настройки интерфейса|Оформление интерфейса)$/.test(h))return['appearance',10];
|
if(id==='sunBrandThemeSettingsCard'||/^(Цвета интерфейса|Настройки интерфейса|Оформление интерфейса)$/.test(h))return['appearance',10];
|
||||||
|
if(id==='sunOrderPaymentColorsCard'||h==='Цвета оплаты заказов')return['appearance',20];
|
||||||
if(id==='sunOfferTemplateSettingsCard'||h==='Шаблон предложения клиенту')return['offer',10];
|
if(id==='sunOfferTemplateSettingsCard'||h==='Шаблон предложения клиенту')return['offer',10];
|
||||||
if(id==='sunClientOfferSettingsCard'||h==='Настройки предложения клиенту')return['offer',20];
|
if(id==='sunClientOfferSettingsCard'||h==='Настройки предложения клиенту')return['offer',20];
|
||||||
if(id==='sunOrderPaymentColorsCard'||h==='Цвета оплаты заказов')return['orders',10];
|
|
||||||
if(id==='sunOrderStatusFeatureCard'||h==='Статусы заказов')return['orders',20];
|
if(id==='sunOrderStatusFeatureCard'||h==='Статусы заказов')return['orders',20];
|
||||||
if(id==='sunEventTypesSettingsCard'||h==='Типы мероприятий')return['references',10];
|
if(id==='sunEventTypesSettingsCard'||h==='Типы мероприятий')return['references',10];
|
||||||
if(id==='sunLeadSourcesSettingsCard'||h==='Источники клиентов')return['references',20];
|
if(id==='sunLeadSourcesSettingsCard'||h==='Источники клиентов')return['references',20];
|
||||||
|
|||||||
@ -5,6 +5,12 @@
|
|||||||
if(window.CateriumBanquetClientMenu)return;
|
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 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 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 clean=v=>String(v??'').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g,'').trim();
|
||||||
const money=v=>Number(v).toLocaleString('ru-RU',{maximumFractionDigits:2})+' ₽';
|
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 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 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 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));
|
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
|
// Keep every dish and every character. Never crop, use ellipses or make a
|
||||||
// second page. A genuinely oversized menu gets a clear, recoverable error.
|
// 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--){
|
for(let font=24;font>=17;font--){
|
||||||
const choices=ordered.length<=8?[1,2]:[2,1];
|
const choices=ordered.length<=8?[1,2]:[2,1];
|
||||||
for(const columns of choices){
|
for(const columns of choices){
|
||||||
const width=(W-M*2-(columns-1)*42)/columns;
|
const width=(W-M*2-(columns-1)*42)/columns;
|
||||||
ctx.font=`${font}px ${SANS}`;
|
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;}
|
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};};
|
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))]);
|
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('Меню не помещается на одну страницу без слишком мелкого текста. Сократите названия или количество выбранных блюд. Ни одно блюдо не было обрезано.');
|
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)]);
|
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 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 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 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],x,y+i*lh);drawnText.push(lines[i]);}return lines.length*lh;};
|
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();};
|
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);
|
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);
|
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);
|
line(M,headerBottom+14,W-M,gold);
|
||||||
let y=headerBottom+42;
|
let y=headerBottom+42;
|
||||||
y+=text('Банкетное меню',M,y,W-M*2,`500 52px ${SERIF}`,ink,63)+14;
|
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)+10;
|
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(' · ');
|
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;
|
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;
|
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 footerHeight=108+contactHeight+(s.estimated?22:0),bottom=H-64-footerHeight;
|
||||||
const layout=plan(c,s,showPrices,top,bottom);
|
const layout=plan(c,s,top,bottom);
|
||||||
for(let j=0;j<layout.columns.length;j++){
|
for(let j=0;j<layout.columns.length;j++){
|
||||||
const col=layout.columns[j],x=M+j*(layout.width+42);let yy=top;
|
const col=layout.columns[j],x=M+j*(layout.width+42);let yy=top;
|
||||||
for(const row of col.entries){
|
for(const row of col.entries){
|
||||||
@ -123,13 +131,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let fy=bottom+22;line(M,fy,W-M,gold);fy+=20;
|
let fy=bottom+22;line(M,fy,W-M,gold);fy+=20;
|
||||||
if(showPrices){
|
// Only the per-guest price is ever shown to the client — never a per-item price
|
||||||
text('НА ОДНОГО ГОСТЯ',M,fy,380,`700 12px ${SANS}`,muted,17);
|
// and never the grand total, so the menu can't be read as a full price list.
|
||||||
text(`МЕНЮ НА ${s.guests.toLocaleString('ru-RU')} ГОСТЕЙ`,W/2+20,fy,416,`700 12px ${SANS}`,muted,17);fy+=24;
|
// It is not optional: the client always sees what one guest costs.
|
||||||
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);};
|
text('ЦЕНА НА ОДНОГО ГОСТЯ',M,fy,W-M*2,`700 12px ${SANS}`,muted,17,centered?'center':'left');fy+=24;
|
||||||
fitMoney(money(s.cents/100),M);fitMoney(money(s.totalCents/100),W/2+20);fy+=45;
|
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('Только выбранные блюда. Доставка и услуги не включены.',M,fy,W-M*2,`12px ${SANS}`,muted,18);fy+=24;
|
text(v,M,fy,W-M*2,c.font,ink,size+4,centered?'center':'left');fy+=size+16;
|
||||||
}else if(s.grams){text(`Выход по указанным порциям: ${s.grams.toLocaleString('ru-RU')} г на гостя`,M,fy,W-M*2,`14px ${SANS}`,muted,20);fy+=26;}
|
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.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);
|
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 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{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::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 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 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}}
|
#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);
|
`;document.head.append(el);
|
||||||
@ -152,21 +160,22 @@
|
|||||||
if(!allowed())throw new Error('Предложения клиенту недоступны для текущих прав или тарифа.');
|
if(!allowed())throw new Error('Предложения клиенту недоступны для текущих прав или тарифа.');
|
||||||
const s=snapshot(input),started=scope();close();style();
|
const s=snapshot(input),started=scope();close();style();
|
||||||
const dialog=document.createElement('dialog');dialog.id='ctBanquetClientDialog';dialog.setAttribute('aria-labelledby','ctBanquetClientTitle');
|
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 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();
|
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 valid=()=>panel===state&&scope()===started&&allowed();
|
||||||
const build=async()=>{
|
const build=async()=>{
|
||||||
const ticket=++state.generation;download.disabled=true;share.disabled=true;state.result=null;status.textContent='Оформляю меню на одной странице…';
|
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;
|
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);
|
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;
|
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();}}
|
}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);};
|
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 и отправьте его вручную.';}};
|
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;
|
await build();return state.result;
|
||||||
|
|||||||
@ -48,7 +48,7 @@
|
|||||||
if(!window.CateriumBanquetClientMenu)await new Promise((resolve,reject)=>{
|
if(!window.CateriumBanquetClientMenu)await new Promise((resolve,reject)=>{
|
||||||
const script=document.createElement('script');let timer;
|
const script=document.createElement('script');let timer;
|
||||||
const fail=()=>{clearTimeout(timer);script.remove();reject(new Error('Не удалось загрузить оформление меню. Повторите попытку.'));};
|
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;
|
if(!root.isConnected||who!==JSON.stringify([window.SunCloudV2?.getSession?.()?.user?.id,window.SunCloudV2?.getWorkspace?.()?.id]))return;
|
||||||
await window.CateriumBanquetClientMenu.open(input);
|
await window.CateriumBanquetClientMenu.open(input);
|
||||||
|
|||||||
233
public/core/single-item-pdf.js
Normal file
233
public/core/single-item-pdf.js
Normal 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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();
|
||||||
|
})();
|
||||||
@ -8488,4 +8488,5 @@ body #sunBlankSettingsCardV3{display:none!important}
|
|||||||
</script>
|
</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>
|
||||||
|
|||||||
@ -3,7 +3,8 @@ const VERSION='20260918-ui-stability';
|
|||||||
const CORE=[
|
const CORE=[
|
||||||
'./core/support-form.js?v=20260921-support-recipient',
|
'./core/support-form.js?v=20260921-support-recipient',
|
||||||
'./core/trial-promo-developer-v181.js?v=20260921-promo-entry',
|
'./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/training-catalog.js?v=20260922-training-photos',
|
||||||
'./core/catalog-pricing.js?v=20260919-client-menu','./core/client-menu.css?v=20260919-client-menu',
|
'./core/catalog-pricing.js?v=20260919-client-menu','./core/client-menu.css?v=20260919-client-menu',
|
||||||
'./vendor/supabase-2.112.4.min.js',
|
'./vendor/supabase-2.112.4.min.js',
|
||||||
@ -17,7 +18,7 @@ const CORE=[
|
|||||||
];
|
];
|
||||||
const CRITICAL_FRESH=new Set([
|
const CRITICAL_FRESH=new Set([
|
||||||
'/core/support-form.js',
|
'/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/banquet-client-menu.js',
|
||||||
'/core/training-catalog.js',
|
'/core/training-catalog.js',
|
||||||
'/core/catalog-pricing.js','/core/client-menu.css',
|
'/core/catalog-pricing.js','/core/client-menu.css',
|
||||||
|
|||||||
177
supabase/migrations/20260922120000_order_audit_log.sql
Normal file
177
supabase/migrations/20260922120000_order_audit_log.sql
Normal 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';
|
||||||
@ -9,12 +9,18 @@ const pkg=JSON.parse(readRoot('package.json'));
|
|||||||
const lock=JSON.parse(readRoot('package-lock.json'));
|
const lock=JSON.parse(readRoot('package-lock.json'));
|
||||||
const releaseManifest=JSON.parse(readRoot('docs/release-manifest.json'));
|
const releaseManifest=JSON.parse(readRoot('docs/release-manifest.json'));
|
||||||
check(index.includes('core/stability-v1760.css'),'mobile stability stylesheet loaded');
|
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(css.includes('overflow-x:hidden')&&css.includes('.cats'),'mobile overflow guard present');
|
||||||
check(!index.includes('offer-gallery-data.js'),'blocking Base64 gallery absent');
|
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((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(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([...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(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('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');
|
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(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.version===`v${pkg.version}`,'release manifest version matches package.json');
|
||||||
check(releaseManifest.channel==='production','release manifest channel is production');
|
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(['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('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');
|
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');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user