Compare commits
No commits in common. "main" and "demo-photos-and-orders" have entirely different histories.
main
...
demo-photo
@ -1,78 +0,0 @@
|
||||
name: Caterium QA
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
concurrency:
|
||||
group: caterium-qa-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
qa:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# registry.npmjs.org sits behind Cloudflare IPs that this self-hosted
|
||||
# runner cannot reach (times out on both IPv4 and IPv6). npmmirror.com
|
||||
# mirrors the full npm registry and is reachable, so point npm at it
|
||||
# instead of failing every install. GitHub-hosted runners don't need
|
||||
# this, which is why .github/workflows/qa.yml doesn't set it.
|
||||
NPM_CONFIG_REGISTRY: https://registry.npmmirror.com
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- run: npm ci
|
||||
- name: Audit dependencies (retry service errors only)
|
||||
# npmmirror.com (see NPM_CONFIG_REGISTRY above) doesn't implement
|
||||
# npm's bulk security-advisories endpoint, so this always reports
|
||||
# "audit endpoint returned an error" here even though installs
|
||||
# work fine. The real audit still runs on GitHub-hosted runners
|
||||
# against registry.npmjs.org (.github/workflows/qa.yml), so don't
|
||||
# fail the whole Gitea pipeline over a check this runner can't
|
||||
# physically perform.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 4
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
report="$(mktemp)"
|
||||
trap 'rm -f "$report"' EXIT
|
||||
for attempt in 1 2 3; do
|
||||
set +e
|
||||
npm audit --audit-level=high --loglevel=verbose > "$report" 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
cat "$report"
|
||||
if [ "$status" -eq 0 ]; then exit 0; fi
|
||||
# An actual vulnerability report fails immediately. A failed
|
||||
# registry response is retried, never accepted as a clean audit.
|
||||
if ! grep -Eq 'audit endpoint returned an error|ENOTFOUND|ECONNRESET|EAI_AGAIN|ETIMEDOUT|E429|E503' "$report"; then
|
||||
exit "$status"
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then exit "$status"; fi
|
||||
echo "Audit service unavailable; retry $attempt/3 after a delay."
|
||||
sleep "$((attempt * 20))"
|
||||
done
|
||||
exit 1
|
||||
- run: npm run check:deploy
|
||||
- name: Install PHP CLI
|
||||
# The self-hosted act_runner image (unlike GitHub's ubuntu-latest)
|
||||
# ships without PHP at all, so the lint/proxy steps below fail with
|
||||
# "php: command not found" unless we install it first. php-curl is
|
||||
# needed too: tests/proxy-http.php calls CURLOPT_* constants, which
|
||||
# the bare php-cli package doesn't define.
|
||||
run: |
|
||||
apt-get update -y
|
||||
apt-get install -y --no-install-recommends php-cli php-curl
|
||||
- run: php -l public/api/index.php && php -l ops/timeweb/api-proxy.php
|
||||
- run: php tests/proxy-http.php app && php tests/proxy-http.php api
|
||||
- run: npx playwright install --with-deps chromium webkit
|
||||
- name: Run e2e tests
|
||||
# This VPS (2 CPU / 4GB) also runs Gitea + Postgres + Caddy. Playwright's
|
||||
# default worker count (one per CPU, all projects in parallel) spins up
|
||||
# chromium and webkit processes concurrently and has twice now driven the
|
||||
# host into an unresponsive state (server needed a hard reboot) partway
|
||||
# through this step. --workers=1 runs the same test suite serially
|
||||
# instead of in parallel — slower, but it keeps peak memory/CPU to one
|
||||
# browser process at a time. GitHub-hosted runners have real headroom,
|
||||
# so .github/workflows/qa.yml keeps full parallelism.
|
||||
run: npx playwright test --config=tests/playwright.config.mjs --workers=1
|
||||
2
.github/workflows/deploy-timeweb.yml
vendored
@ -45,9 +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
|
||||
core/sun-safe.js
|
||||
core/login-signature-v1776.js
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
"serverReady": true,
|
||||
"workspaceAutoDiscovery": true,
|
||||
"invitesTemporarilyDisabled": false,
|
||||
"pwaCache": "v111-20260918-ui-stability",
|
||||
"pwaCache": "v110-20260918-ui-stability",
|
||||
"fullOfferDescriptions": true,
|
||||
"dynamicOfferRows": true,
|
||||
"pdfOfferDescriptionFix": true,
|
||||
|
||||
@ -1,39 +0,0 @@
|
||||
{
|
||||
"note": "Photos for the 34 training-catalog items that previously had none (banquet-v1 dishes, extras-v1 sets/supplies/delivery). Sourced from Pexels (free license, no attribution required) by search + manual visual review; two items (banquet-caprese, banquet-roastbeef) were AI-generated instead. Files live in public/demo/images/<file>.webp, 1024x1024, resized/converted with sharp. Regenerate the catalog JSON with ops/demo/build-trial-banquet.mjs and ops/demo/build-trial-extras.mjs after editing the *-data.mjs sources.",
|
||||
"items": [
|
||||
{"itemId": "demo-banquet-v1-caprese", "file": "banquet-caprese", "source": "ai-generated"},
|
||||
{"itemId": "demo-banquet-v1-roastbeef", "file": "banquet-roastbeef", "source": "ai-generated"},
|
||||
{"itemId": "demo-banquet-v1-salmon-roll", "file": "banquet-salmon-roll", "source": "pexels"},
|
||||
{"itemId": "demo-banquet-v1-hummus", "file": "banquet-hummus", "source": "pexels"},
|
||||
{"itemId": "demo-banquet-v1-caesar", "file": "banquet-caesar", "source": "pexels"},
|
||||
{"itemId": "demo-banquet-v1-olivier", "file": "banquet-olivier", "source": "pexels"},
|
||||
{"itemId": "demo-banquet-v1-greek", "file": "banquet-greek", "source": "pexels"},
|
||||
{"itemId": "demo-banquet-v1-julienne", "file": "banquet-julienne", "source": "pexels"},
|
||||
{"itemId": "demo-banquet-v1-stuffed-mushrooms", "file": "banquet-stuffed-mushrooms", "source": "pexels"},
|
||||
{"itemId": "demo-banquet-v1-chicken", "file": "banquet-chicken", "source": "pexels"},
|
||||
{"itemId": "demo-banquet-v1-cod", "file": "banquet-cod", "source": "pexels"},
|
||||
{"itemId": "demo-banquet-v1-beef-hot", "file": "banquet-beef-hot", "source": "pexels"},
|
||||
{"itemId": "demo-banquet-v1-mash", "file": "banquet-mash", "source": "pexels"},
|
||||
{"itemId": "demo-banquet-v1-rice", "file": "banquet-rice", "source": "pexels"},
|
||||
{"itemId": "demo-banquet-v1-berry-cream", "file": "banquet-berry-cream", "source": "pexels"},
|
||||
{"itemId": "demo-banquet-v1-cheese-honey", "file": "banquet-cheese-honey", "source": "pexels"},
|
||||
{"itemId": "demo-banquet-v1-fruit", "file": "banquet-fruit", "source": "pexels"},
|
||||
{"itemId": "demo-banquet-v1-bread", "file": "banquet-bread", "source": "pexels"},
|
||||
{"itemId": "demo-extras-v1-salmon-caprese", "file": "extras-salmon-caprese", "source": "pexels"},
|
||||
{"itemId": "demo-extras-v1-meat-cheese", "file": "extras-meat-cheese", "source": "pexels"},
|
||||
{"itemId": "demo-extras-v1-mini-buffet", "file": "extras-mini-buffet", "source": "pexels"},
|
||||
{"itemId": "demo-extras-v1-water", "file": "extras-water", "source": "pexels"},
|
||||
{"itemId": "demo-extras-v1-sparkling", "file": "extras-sparkling", "source": "pexels"},
|
||||
{"itemId": "demo-extras-v1-juice", "file": "extras-juice", "source": "pexels"},
|
||||
{"itemId": "demo-extras-v1-mors", "file": "extras-mors", "source": "pexels"},
|
||||
{"itemId": "demo-extras-v1-plate", "file": "extras-plate", "source": "pexels"},
|
||||
{"itemId": "demo-extras-v1-fork", "file": "extras-fork", "source": "pexels"},
|
||||
{"itemId": "demo-extras-v1-glass", "file": "extras-glass", "source": "pexels"},
|
||||
{"itemId": "demo-extras-v1-napkin", "file": "extras-napkin", "source": "pexels"},
|
||||
{"itemId": "demo-extras-v1-ice", "file": "extras-ice", "source": "pexels"},
|
||||
{"itemId": "demo-extras-v1-tablecloth", "file": "extras-tablecloth", "source": "pexels"},
|
||||
{"itemId": "demo-extras-v1-serving-kit", "file": "extras-serving-kit", "source": "pexels"},
|
||||
{"itemId": "demo-extras-v1-delivery-city", "file": "extras-delivery-city", "source": "pexels"},
|
||||
{"itemId": "demo-extras-v1-delivery-outer", "file": "extras-delivery-outer", "source": "pexels"}
|
||||
]
|
||||
}
|
||||
@ -68,7 +68,7 @@ const recipes=[
|
||||
export const banquet=recipes.map(([key,name,catalogSection,price,quantities,allergens,steps,waterGrams=0],index)=>{
|
||||
const rows=quantities.map(([key,gross,net])=>{const p=products.get(key);return {productId:p.id,name:p.name,unit:p.unit,gross:gross/1000,net:net/1000,unitCost:p.cost}});
|
||||
const outputGrams=Math.round(rows.reduce((sum,r)=>sum+r.net*1000,waterGrams));
|
||||
return {id:`demo-banquet-v1-${key}`,name,category:6,catalogSection,price,weight:`${outputGrams} г`,pieces:1,photo:`demo/images/banquet-${key}.webp`,demo:true,
|
||||
return {id:`demo-banquet-v1-${key}`,name,category:6,catalogSection,price,weight:`${outputGrams} г`,pieces:1,photo:'',demo:true,
|
||||
composition:[rows.map(r=>r.name).join(', ')],ingredients:rows.map(r=>[r.name,r.gross,r.unit]),
|
||||
ttk:{number:`ДЕМО-Б${String(index+1).padStart(2,'0')}`,basis:'На 1 порцию / 1 гостя',outputGrams,waterGrams,pieces:1,rows,steps,allergens,
|
||||
ingredientCost:Math.round(rows.reduce((sum,r)=>sum+r.gross*r.unitCost,0)*100)/100,
|
||||
|
||||
@ -10,7 +10,7 @@ const premium=mixes.map(([key,name,price,pieces,sourceIds],index)=>{
|
||||
const sources=sourceIds.map(id=>boxes.find(b=>b.id===`demo-v1-${id}`)),map=new Map();
|
||||
for(const b of sources)for(const r of b.ttk.rows){const old=map.get(r.productId)||{...r,gross:0,net:0};old.gross=round(old.gross+r.gross/2);old.net=round(old.net+r.net/2);map.set(r.productId,old)}
|
||||
const rows=[...map.values()],outputGrams=Math.round(rows.filter(r=>r.unit==='кг').reduce((s,r)=>s+r.net*1000,0));
|
||||
return {id:`demo-extras-v1-${key}`,name,category:5,catalogSection:'Демонстрационные премиум-сеты',price,pieces,weight:`${outputGrams} г`,photo:`demo/images/extras-${key}.webp`,demo:true,
|
||||
return {id:`demo-extras-v1-${key}`,name,category:5,catalogSection:'Демонстрационные премиум-сеты',price,pieces,weight:`${outputGrams} г`,photo:'',demo:true,
|
||||
composition:sources.map(b=>'Половина стандартного бокса: '+b.name),ingredients:rows.map(r=>[r.name,r.gross,r.unit]),
|
||||
ttk:{number:`ДЕМО-П${index+1}`,basis:'На 1 готовый премиум-сет',outputGrams,pieces,rows,
|
||||
steps:['Приготовить две мини-подборки в половинном объёме по указанным нормам.',...sources.flatMap(b=>b.ttk.steps.map(s=>b.name+': '+s)),'Уложить обе подборки в одну общую коробку.'],
|
||||
@ -31,6 +31,6 @@ const specs=[
|
||||
['serving-kit',2,'Набор для подачи: щипцы и лопатка',190,'Демо: набор щипцы и лопатка',85,'шт.','pack','Один комплект одноразовых приборов для подачи']
|
||||
];
|
||||
const stock=specs.map(([key,category,name,price,productName,cost,unit,supplier])=>({id:`demo-extras-v1-stock-${key}`,name:productName,cost,unit,qty:0,min:unit==='кг'?1:10,supplierId:`demo-v1-supplier-${supplier}`,lastPurchaseDate:'',lastPurchasePrice:0}));
|
||||
const packaged=specs.map(([key,category,name,price,productName,cost,unit,supplier,description])=>({id:`demo-extras-v1-${key}`,name,category,catalogSection:({1:'Одноразовая посуда',2:'Для сервировки',3:'Безалкогольные напитки'})[category],price,pieces:1,weight:key==='ice'?'1 кг':'',photo:`demo/images/extras-${key}.webp`,composition:[description],ingredients:[[productName,1,unit]],demo:true}));
|
||||
const delivery=[['city','Доставка по городу',600,'Учебный тариф за один адрес в пределах города.'],['outer','Доставка за город',1200,'Учебный тариф за один адрес в ближайшем пригороде.']].map(([key,name,price,note])=>({id:`demo-extras-v1-delivery-${key}`,name,category:4,catalogSection:'Учебные тарифы доставки',price,pieces:0,weight:'',photo:`demo/images/extras-delivery-${key}.webp`,composition:[note],ingredients:[],demo:true}));
|
||||
const packaged=specs.map(([key,category,name,price,productName,cost,unit,supplier,description])=>({id:`demo-extras-v1-${key}`,name,category,catalogSection:({1:'Одноразовая посуда',2:'Для сервировки',3:'Безалкогольные напитки'})[category],price,pieces:1,weight:key==='ice'?'1 кг':'',photo:'',composition:[description],ingredients:[[productName,1,unit]],demo:true}));
|
||||
const delivery=[['city','Доставка по городу',600,'Учебный тариф за один адрес в пределах города.'],['outer','Доставка за город',1200,'Учебный тариф за один адрес в ближайшем пригороде.']].map(([key,name,price,note])=>({id:`demo-extras-v1-delivery-${key}`,name,category:4,catalogSection:'Учебные тарифы доставки',price,pieces:0,weight:'',photo:'',composition:[note],ingredients:[],demo:true}));
|
||||
export const extrasDemo={version:1,items:[...premium,...packaged,...delivery],stock};
|
||||
|
||||
@ -1,169 +0,0 @@
|
||||
-- Caterium v17.9.1
|
||||
-- Public owners may register without a promo code. No-promo workspaces are
|
||||
-- permanently limited to the Basic plan. A valid promo keeps the existing
|
||||
-- server-enforced trial plan and duration.
|
||||
|
||||
begin;
|
||||
|
||||
create or replace function public.caterium_public_signup_policy()
|
||||
returns jsonb
|
||||
language sql
|
||||
stable
|
||||
security invoker
|
||||
set search_path = public
|
||||
as $function$
|
||||
select jsonb_build_object(
|
||||
'promo_optional', true,
|
||||
'default_plan', 'basic'
|
||||
);
|
||||
$function$;
|
||||
|
||||
create or replace function public.sun_create_workspace(p_name text default 'Новая компания'::text)
|
||||
returns uuid
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public, auth, pg_temp
|
||||
as $function$
|
||||
declare
|
||||
v_user uuid:=auth.uid();
|
||||
v_workspace uuid;
|
||||
v_name text:='Новая компания';
|
||||
v_email text;
|
||||
v_profile jsonb;
|
||||
v_storage jsonb;
|
||||
v_code text;
|
||||
v_has_promo boolean:=false;
|
||||
v_promo public.caterium_trial_promos%rowtype;
|
||||
v_trial_end timestamptz;
|
||||
begin
|
||||
if v_user is null then raise exception 'Authentication required'; end if;
|
||||
if not exists(select 1 from auth.users where id=v_user and email_confirmed_at is not null) then
|
||||
raise exception 'Подтвердите email';
|
||||
end if;
|
||||
if exists(select 1 from public.sun_workspace_members where user_id=v_user and is_active=true) then
|
||||
raise exception 'Аккаунт уже относится к компании';
|
||||
end if;
|
||||
|
||||
select lower(email),public.caterium_normalize_trial_code(raw_user_meta_data->>'promo_code')
|
||||
into v_email,v_code
|
||||
from auth.users
|
||||
where id=v_user;
|
||||
|
||||
v_has_promo:=coalesce(v_code,'')<>'';
|
||||
|
||||
if v_has_promo then
|
||||
if exists(select 1 from public.caterium_trial_redemptions where user_id=v_user) then
|
||||
raise exception 'Пробный период для этого аккаунта уже использован';
|
||||
end if;
|
||||
|
||||
select * into v_promo
|
||||
from public.caterium_trial_promos
|
||||
where code=v_code
|
||||
for update;
|
||||
|
||||
if not found then raise exception 'Промокод не найден'; end if;
|
||||
if not v_promo.is_active then raise exception 'Промокод отключён'; end if;
|
||||
if v_promo.valid_until is not null and v_promo.valid_until<=now() then raise exception 'Срок действия промокода истёк'; end if;
|
||||
if v_promo.use_count>=v_promo.max_uses then raise exception 'Промокод уже использован'; end if;
|
||||
if v_promo.client_email is not null and lower(v_promo.client_email)<>v_email then raise exception 'Промокод предназначен для другого email'; end if;
|
||||
|
||||
v_trial_end:=now()+make_interval(days=>v_promo.trial_days);
|
||||
end if;
|
||||
|
||||
insert into public.sun_workspaces(name,created_by)
|
||||
values(v_name,v_user)
|
||||
returning id into v_workspace;
|
||||
|
||||
insert into public.sun_workspace_members(workspace_id,user_id,role,display_name,is_active,permissions)
|
||||
values(
|
||||
v_workspace,
|
||||
v_user,
|
||||
'admin',
|
||||
coalesce((select raw_user_meta_data->>'name' from auth.users where id=v_user),split_part(coalesce(v_email,'Администратор'),'@',1)),
|
||||
true,
|
||||
public.sun_role_default_permissions('admin')
|
||||
);
|
||||
|
||||
v_profile:=jsonb_build_object(
|
||||
'name','','shortName','','logo','','tagline','','city','','phone','','email',coalesce(v_email,''),
|
||||
'website','','address','','legalName','','inn','','kpp','','ogrn','','legalAddress','',
|
||||
'bank','','bik','','account','','corrAccount','','legacyLocked',false
|
||||
);
|
||||
v_storage:=jsonb_build_object('sunCompanyProfileV1',v_profile::text);
|
||||
|
||||
insert into public.sun_app_state(workspace_id,payload,client_id)
|
||||
values(v_workspace,jsonb_build_object('format','sun-cloud-v2','version',2,'storage',v_storage),'bootstrap')
|
||||
on conflict(workspace_id) do nothing;
|
||||
|
||||
if v_has_promo then
|
||||
insert into public.sun_workspace_subscriptions(
|
||||
workspace_id,plan_id,status,trial_started_at,trial_ends_at,current_period_start,current_period_end,grace_until,source,note
|
||||
)
|
||||
values(
|
||||
v_workspace,v_promo.plan_id,'trialing',now(),v_trial_end,null,null,v_trial_end+interval '7 days','promo_trial','Trial by promo '||v_promo.code
|
||||
)
|
||||
on conflict(workspace_id) do update
|
||||
set plan_id=excluded.plan_id,
|
||||
status=excluded.status,
|
||||
trial_started_at=excluded.trial_started_at,
|
||||
trial_ends_at=excluded.trial_ends_at,
|
||||
current_period_start=null,
|
||||
current_period_end=null,
|
||||
grace_until=excluded.grace_until,
|
||||
source=excluded.source,
|
||||
note=excluded.note,
|
||||
updated_at=now();
|
||||
|
||||
insert into public.caterium_trial_redemptions(promo_id,workspace_id,user_id,email,trial_ends_at)
|
||||
values(v_promo.id,v_workspace,v_user,v_email,v_trial_end);
|
||||
|
||||
update public.caterium_trial_promos
|
||||
set use_count=use_count+1,updated_at=now()
|
||||
where id=v_promo.id;
|
||||
|
||||
insert into public.sun_platform_audit_events(actor_user_id,action,target_workspace_id,target_user_id,details)
|
||||
values(
|
||||
v_user,'trial_promo.redeem',v_workspace,v_user,
|
||||
jsonb_build_object('promo_id',v_promo.id,'code',v_promo.code,'trial_days',v_promo.trial_days,'plan',v_promo.plan_id)
|
||||
);
|
||||
else
|
||||
insert into public.sun_workspace_subscriptions(
|
||||
workspace_id,plan_id,status,trial_started_at,trial_ends_at,current_period_start,current_period_end,grace_until,source,note
|
||||
)
|
||||
values(
|
||||
v_workspace,'basic','active',null,null,now(),null,null,'public_signup_basic','Basic access without promo'
|
||||
)
|
||||
on conflict(workspace_id) do update
|
||||
set plan_id='basic',
|
||||
status='active',
|
||||
trial_started_at=null,
|
||||
trial_ends_at=null,
|
||||
current_period_start=now(),
|
||||
current_period_end=null,
|
||||
grace_until=null,
|
||||
source='public_signup_basic',
|
||||
note='Basic access without promo',
|
||||
updated_at=now();
|
||||
|
||||
insert into public.sun_platform_audit_events(actor_user_id,action,target_workspace_id,target_user_id,details)
|
||||
values(
|
||||
v_user,'public_signup.basic',v_workspace,v_user,
|
||||
jsonb_build_object('plan','basic','promo',false)
|
||||
);
|
||||
end if;
|
||||
|
||||
update auth.users
|
||||
set raw_user_meta_data=coalesce(raw_user_meta_data,'{}'::jsonb)-'promo_code'-'company_name'
|
||||
where id=v_user;
|
||||
|
||||
return v_workspace;
|
||||
end;
|
||||
$function$;
|
||||
|
||||
revoke all on function public.caterium_public_signup_policy() from public,anon,authenticated;
|
||||
grant execute on function public.caterium_public_signup_policy() to anon,authenticated;
|
||||
|
||||
revoke all on function public.sun_create_workspace(text) from public,anon;
|
||||
grant execute on function public.sun_create_workspace(text) to authenticated,service_role;
|
||||
|
||||
commit;
|
||||
@ -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/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",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@ -252,7 +252,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
|
||||
.sun-discount-panel label>span{display:block;color:#68717a;font-size:12px;margin-bottom:5px}
|
||||
.sun-order-summary-discount{color:#8a5b00}
|
||||
.sun-order-summary-net{font-weight:800;color:#15364c}
|
||||
.sun-order-summary-promo small{color:#7b858d;margin-left:5px}.sun-order-line-sum{display:flex;align-items:center;justify-content:flex-end;gap:8px}.sun-order-line-delete{width:28px;height:28px;flex:0 0 28px;border:1px solid #e2b8b8;border-radius:8px;background:#fff7f7;color:#a83535;font-size:19px;line-height:1;cursor:pointer}.sun-order-line-delete:hover{background:#ffeaea;border-color:#d98d8d}
|
||||
.sun-order-summary-promo small{color:#7b858d;margin-left:5px}
|
||||
.sun-doc-qr{width:92px;height:92px;object-fit:contain;border:1px solid #e0e3e5;border-radius:7px;padding:4px;background:#fff}
|
||||
.sun-doc-title-with-qr{display:flex;align-items:flex-start;gap:14px;justify-content:flex-end}
|
||||
.sun-doc-title-with-qr .sun-doc-title{text-align:right}
|
||||
@ -429,11 +429,10 @@ window.SUN_LEGACY_CATALOG_V175=[];
|
||||
function renderOrderLines(){
|
||||
if(!$('lines'))return;
|
||||
if(!(draft.lines||[]).length){$('lines').innerHTML='<p class="empty">Выберите позиции из каталога.</p>';return}
|
||||
const rows=draft.lines.map((line,index)=>{const item=boxes.find(x=>String(x.id)===String(line.id));if(!item)return'';const qty=Math.max(1,Number(line.qty||1)),price=lineUnitPrice(line),isBox=[0,5,6].includes(Number(item.category||0));return `<div class="sun-order-line-row" data-line-index="${index}"><div class="sun-order-line-name" title="${esc(item.name)}">${esc(String(item.name||'').replace(/^(?:Фуршетный\s+)?бокс\s*№\s*(\d+)\s*(?:[—–-]\s*)?/i,'№ $1 '))}${isBox&&(item.weight||inferBoxPieces(item))?`<small>${item.weight?`<b>Вес:</b> ${esc(item.weight)}`:''}${item.weight&&inferBoxPieces(item)?' · ':''}${inferBoxPieces(item)?`<b>Количество:</b> ${inferBoxPieces(item)} шт.`:''}</small>`:''}</div><div class="sun-order-line-qty"><button type="button" onclick="qty(${SunSafe.jsArg(line.id)},${qty-1})">−</button><input type="number" min="1" value="${qty}" onchange="qty(${SunSafe.jsArg(line.id)},+this.value)"><button type="button" onclick="qty(${SunSafe.jsArg(line.id)},${qty+1})">+</button></div><div class="sun-order-line-price"><input type="text" inputmode="decimal" autocomplete="off" value="${price}" onchange="sunOrderLinePriceChanged(${index},this)"></div><div class="sun-order-line-sum"><b>${money(price*qty)}</b><button class="sun-order-line-delete" type="button" onclick="sunRemoveOrderLine(${index})" aria-label="Удалить позицию" title="Удалить позицию">×</button></div></div>`}).join('');
|
||||
const rows=draft.lines.map((line,index)=>{const item=boxes.find(x=>String(x.id)===String(line.id));if(!item)return'';const qty=Math.max(1,Number(line.qty||1)),price=lineUnitPrice(line),isBox=[0,5,6].includes(Number(item.category||0));return `<div class="sun-order-line-row" data-line-index="${index}"><div class="sun-order-line-name" title="${esc(item.name)}">${esc(String(item.name||'').replace(/^(?:Фуршетный\s+)?бокс\s*№\s*(\d+)\s*(?:[—–-]\s*)?/i,'№ $1 '))}${isBox&&(item.weight||inferBoxPieces(item))?`<small>${item.weight?`<b>Вес:</b> ${esc(item.weight)}`:''}${item.weight&&inferBoxPieces(item)?' · ':''}${inferBoxPieces(item)?`<b>Количество:</b> ${inferBoxPieces(item)} шт.`:''}</small>`:''}</div><div class="sun-order-line-qty"><button type="button" onclick="qty(${SunSafe.jsArg(line.id)},${qty-1})">−</button><input type="number" min="1" value="${qty}" onchange="qty(${SunSafe.jsArg(line.id)},+this.value)"><button type="button" onclick="qty(${SunSafe.jsArg(line.id)},${qty+1})">+</button></div><div class="sun-order-line-price"><input type="text" inputmode="decimal" autocomplete="off" value="${price}" onchange="sunOrderLinePriceChanged(${index},this)"></div><div class="sun-order-line-sum"><b>${money(price*qty)}</b></div></div>`}).join('');
|
||||
$('lines').innerHTML=`<div class="sun-order-lines-table"><div class="sun-order-line-head"><span>Наименование</span><span>Кол.</span><span>Цена</span><span>Стоимость</span></div><div class="sun-order-line-body">${rows}</div><div class="sun-order-line-total"><span>Стоимость позиций</span><b>${money(baseTotal(draft))}</b></div></div>`;
|
||||
}
|
||||
window.sunOrderLinePriceChanged=(index,input)=>{const line=draft.lines?.[index];if(!line)return;const value=Number(String(input.value||0).replace(',','.'));if(!Number.isFinite(value)){input.value=String(lineUnitPrice(line));return;}line.price=Math.max(0,Math.round(value*100)/100);const row=input.closest('.sun-order-line-row'),sum=row?.querySelector('.sun-order-line-sum b');if(sum)sum.textContent=money(line.price*Math.max(0,Number(line.qty||0)));const total=$('lines')?.querySelector('.sun-order-line-total b');if(total)total.textContent=money(baseTotal(draft));updateOrderSummary();};
|
||||
window.sunRemoveOrderLine=(index)=>{if(!Array.isArray(draft.lines))return false;const i=Number(index);if(!Number.isInteger(i)||i<0||i>=draft.lines.length)return false;draft.lines.splice(i,1);renderCatalogV5();updateOrderSummary();return true;};
|
||||
function boxNumber(item){const explicit=Number(item?.boxNumber);if(Number.isInteger(explicit)&&explicit>=0)return explicit;const name=String(item?.name||'');const match=name.match(/(?:бокс\s*)?№\s*0*(\d+)/i)||name.match(/^\s*№\s*0*(\d+)/i);return match?Number(match[1]):null}
|
||||
function wantedBoxNumber(query){const raw=String(query||'').trim();if(!raw)return null;const match=raw.match(/^(?:бокс\s*)?(?:№\s*)?0*(\d+)\s*$/i);return match?Number(match[1]):null}
|
||||
function itemMatches(item,query){if(!query)return true;if(Number(activeCat)===0){const wanted=wantedBoxNumber(query);return wanted!==null&&boxNumber(item)===wanted}const q=query.trim().toLowerCase();const parts=[item.name,item.catalogSection,item.weight,...(Array.isArray(item.composition)?item.composition:[]),...(Array.isArray(item.ingredients)?item.ingredients.map(x=>x?.[0]):[])];return parts.join(' ').toLowerCase().includes(q)}
|
||||
@ -660,17 +659,16 @@ window.SUN_LEGACY_CATALOG_V175=[];
|
||||
function party(title,rows){const list=rows.filter(([,v])=>String(v||'').trim());if(!list.length)return'';return `<section class="sun-receipt-party"><h3>${esc(title)}</h3><div class="sun-receipt-party-grid">${list.map(([k,v])=>`<div><span>${esc(k)}</span><b>${esc(v)}</b></div>`).join('')}</div></section>`}
|
||||
|
||||
// ---------------- Order blank + compact receipt ----------------
|
||||
function customerVisibleOrderNote(value){return String(value||'').split(/\r?\n/).filter(line=>{const text=line.trim();if(!text)return false;return !/^(?:заказ\s+из\s+telegram|импорт(?:ирован)?(?:о|ный)?\s+из\s+telegram|источник\s*[:—-]?\s*telegram|source\s*[:—-]?\s*telegram)\b/i.test(text)}).join('\n').trim()}
|
||||
function currentRows(){return (draft.lines||[]).map((line,index)=>{const item=boxes.find(x=>String(x.id)===String(line.id)),price=lineUnitPrice(line),qty=Math.max(0,Number(line.qty||0)),categoryId=Number(item?.category??line.category??0);return{index:index+1,name:item?.name||line.name||`Позиция ${line.id||''}`,category:catName(categoryId),categoryId,weight:[0,5,6].includes(categoryId)?String(item?.weight||''):'',pieces:[0,5].includes(categoryId)?inferBoxPieces(item):0,price,qty,sum:price*qty}}).filter(r=>r.qty>0)}
|
||||
const docDate=raw=>{if(!raw)return'—';const d=new Date(`${raw}T00:00:00`);return Number.isNaN(d.getTime())?raw:d.toLocaleDateString('ru-RU')};
|
||||
const docActions=()=>`<div class="sun-doc-actions"><button class="primary" type="button" onclick="window.print()">Печать / PDF</button><button class="outline" type="button" onclick="closeModal('preview')">Закрыть</button></div>`;
|
||||
function showOrderBlank(){
|
||||
captureBuyerFromFields();captureDiscounts();const rows=currentRows(),p=pricing(draft),qr=defaultQr(),b=draft.buyerRequisites||buyerDefaults(),dialog=document.querySelector('#preview .dialog');if(!dialog)return;const number=draft.id||'черновик',qrNote=String(draft.blankQrText||'').trim(),visibleNote=customerVisibleOrderNote($('note')?.value);
|
||||
dialog.innerHTML=`<div class="sun-doc"><div class="sun-doc-head"><div class="sun-doc-brand"><img src="${window.CateriumBranding.logoHTML()}" alt="${window.CateriumBranding.nameHTML()}"><div><strong>${window.CateriumBranding.nameHTML()}</strong><small>${window.CateriumBranding.cityHTML()}</small></div></div><div class="sun-doc-title-with-qr"><div class="sun-doc-title"><h2>Заказ № ${esc(number)}</h2><p>${esc($('event')?.value||'Новое мероприятие')}</p>${draft.status?`<span class="sun-doc-badge">${esc(draft.status)}</span>`:''}</div>${qr?`<div class="sun-doc-qr-wrap"><img class="sun-doc-qr" src="${qr}" alt="QR-код"><div id="sunBlankQrText" class="sun-doc-qr-note" contenteditable="true" spellcheck="true" data-placeholder="Нажмите и добавьте текст рядом с QR">${esc(qrNote)}</div></div>`:''}</div></div><div class="sun-doc-grid"><div class="sun-doc-field"><span>Клиент</span><b>${esc($('contact')?.value||'—')}</b></div><div class="sun-doc-field"><span>Телефон</span><b>${esc($('phone')?.value||'—')}</b></div><div class="sun-doc-field"><span>Дата и время выдачи</span><b>${esc(docDate($('date')?.value))} · ${esc($('time')?.value||'—')}</b></div><div class="sun-doc-field"><span>Адрес доставки</span><b>${esc($('address')?.value||'—')}</b></div>${b.name?`<div class="sun-doc-field"><span>Компания покупателя</span><b>${esc(b.name)}</b></div>`:''}</div><table><thead><tr><th class="doc-num">№</th><th class="doc-name">Позиция</th><th>Цена</th><th>Кол.</th><th>Сумма</th></tr></thead><tbody>${rows.length?rows.map(r=>`<tr><td class="doc-num">${r.index}</td><td class="doc-name"><b>${esc(r.name)}</b><span class="doc-sub">${esc(r.category)}</span>${(r.weight||r.pieces)?`<span class="doc-sub sun-doc-weight">${r.weight?`${r.categoryId===6?'Вес порции':'Вес бокса'}: ${esc(r.weight)}`:''}${r.weight&&r.pieces?' · ':''}${r.pieces?`Количество: ${esc(r.pieces)} шт.`:''}</span>`:''}</td><td>${money(r.price)}</td><td>${r.qty}</td><td><b>${money(r.sum)}</b></td></tr>`).join(''):'<tr><td colspan="5">В заказе пока нет позиций.</td></tr>'}</tbody></table><div class="sun-doc-summary"><p><span>Стоимость позиций</span><b>${money(p.base)}</b></p>${p.manual?`<p><span>Скидка</span><b>− ${money(p.manual)}</b></p>`:''}${p.promo?`<p><span>Промокод ${esc(draft.promoCode||'')}</span><b>− ${money(p.promo)}</b></p>`:''}<p><span>Доставка</span><b>${money(p.delivery)}</b></p><p><span>Итого</span><b>${money(p.total)}</b></p><p><span>Предоплата</span><b>${money(p.paid)}</b></p><p class="grand"><span>Остаток к оплате</span><span>${money(p.balance)}</span></p></div>${visibleNote?`<div class="sun-doc-note"><b>Комментарий к заказу</b>${esc(visibleNote)}</div>`:''}<div class="sun-doc-footer"><div><b>${window.CateriumBranding.nameHTML()}</b><br>Спасибо, что выбрали нас.<div class="sun-doc-sign">Ответственный / подпись</div></div><div>Заказчик подтверждает состав заказа, дату, время и адрес выдачи.<div class="sun-doc-sign">Заказчик / подпись</div></div></div>${docActions()}</div>`;window.modal?.('preview');const noteEl=$('sunBlankQrText');if(noteEl){const save=()=>{const text=String(noteEl.innerText||noteEl.textContent||'').replace(/\u00a0/g,' ').trim();draft.blankQrText=text;if(draft.id){const saved=(orders||[]).find(o=>String(o.id)===String(draft.id));if(saved){saved.blankQrText=text;try{persist()}catch(_){}}}};noteEl.addEventListener('input',save);noteEl.addEventListener('blur',save);}
|
||||
captureBuyerFromFields();captureDiscounts();const rows=currentRows(),p=pricing(draft),qr=defaultQr(),b=draft.buyerRequisites||buyerDefaults(),dialog=document.querySelector('#preview .dialog');if(!dialog)return;const number=draft.id||'черновик',qrNote=String(draft.blankQrText||'').trim();
|
||||
dialog.innerHTML=`<div class="sun-doc"><div class="sun-doc-head"><div class="sun-doc-brand"><img src="${window.CateriumBranding.logoHTML()}" alt="${window.CateriumBranding.nameHTML()}"><div><strong>${window.CateriumBranding.nameHTML()}</strong><small>${window.CateriumBranding.cityHTML()}</small></div></div><div class="sun-doc-title-with-qr"><div class="sun-doc-title"><h2>Заказ № ${esc(number)}</h2><p>${esc($('event')?.value||'Новое мероприятие')}</p>${draft.status?`<span class="sun-doc-badge">${esc(draft.status)}</span>`:''}</div>${qr?`<div class="sun-doc-qr-wrap"><img class="sun-doc-qr" src="${qr}" alt="QR-код"><div id="sunBlankQrText" class="sun-doc-qr-note" contenteditable="true" spellcheck="true" data-placeholder="Нажмите и добавьте текст рядом с QR">${esc(qrNote)}</div></div>`:''}</div></div><div class="sun-doc-grid"><div class="sun-doc-field"><span>Клиент</span><b>${esc($('contact')?.value||'—')}</b></div><div class="sun-doc-field"><span>Телефон</span><b>${esc($('phone')?.value||'—')}</b></div><div class="sun-doc-field"><span>Дата и время выдачи</span><b>${esc(docDate($('date')?.value))} · ${esc($('time')?.value||'—')}</b></div><div class="sun-doc-field"><span>Адрес доставки</span><b>${esc($('address')?.value||'—')}</b></div>${b.name?`<div class="sun-doc-field"><span>Компания покупателя</span><b>${esc(b.name)}</b></div>`:''}</div><table><thead><tr><th class="doc-num">№</th><th class="doc-name">Позиция</th><th>Цена</th><th>Кол.</th><th>Сумма</th></tr></thead><tbody>${rows.length?rows.map(r=>`<tr><td class="doc-num">${r.index}</td><td class="doc-name"><b>${esc(r.name)}</b><span class="doc-sub">${esc(r.category)}</span>${(r.weight||r.pieces)?`<span class="doc-sub sun-doc-weight">${r.weight?`${r.categoryId===6?'Вес порции':'Вес бокса'}: ${esc(r.weight)}`:''}${r.weight&&r.pieces?' · ':''}${r.pieces?`Количество: ${esc(r.pieces)} шт.`:''}</span>`:''}</td><td>${money(r.price)}</td><td>${r.qty}</td><td><b>${money(r.sum)}</b></td></tr>`).join(''):'<tr><td colspan="5">В заказе пока нет позиций.</td></tr>'}</tbody></table><div class="sun-doc-summary"><p><span>Стоимость позиций</span><b>${money(p.base)}</b></p>${p.manual?`<p><span>Скидка</span><b>− ${money(p.manual)}</b></p>`:''}${p.promo?`<p><span>Промокод ${esc(draft.promoCode||'')}</span><b>− ${money(p.promo)}</b></p>`:''}<p><span>Доставка</span><b>${money(p.delivery)}</b></p><p><span>Итого</span><b>${money(p.total)}</b></p><p><span>Предоплата</span><b>${money(p.paid)}</b></p><p class="grand"><span>Остаток к оплате</span><span>${money(p.balance)}</span></p></div>${$('note')?.value?`<div class="sun-doc-note"><b>Комментарий к заказу</b>${esc($('note').value)}</div>`:''}<div class="sun-doc-footer"><div><b>${window.CateriumBranding.nameHTML()}</b><br>Спасибо, что выбрали нас.<div class="sun-doc-sign">Ответственный / подпись</div></div><div>Заказчик подтверждает состав заказа, дату, время и адрес выдачи.<div class="sun-doc-sign">Заказчик / подпись</div></div></div>${docActions()}</div>`;window.modal?.('preview');const noteEl=$('sunBlankQrText');if(noteEl){const save=()=>{const text=String(noteEl.innerText||noteEl.textContent||'').replace(/\u00a0/g,' ').trim();draft.blankQrText=text;if(draft.id){const saved=(orders||[]).find(o=>String(o.id)===String(draft.id));if(saved){saved.blankQrText=text;try{persist()}catch(_){}}}};noteEl.addEventListener('input',save);noteEl.addEventListener('blur',save);}
|
||||
}
|
||||
function showReceipt(){
|
||||
captureBuyerFromFields();captureDiscounts();const rows=currentRows(),p=pricing(draft),s=readSeller(),b=draft.buyerRequisites||buyerDefaults(),dialog=document.querySelector('#preview .dialog'),visibleNote=customerVisibleOrderNote($('note')?.value);if(!dialog)return;const seller=s.includeSeller!==false?party('Продавец',[['Наименование',s.sellerLegalName||s.sellerName],['ИНН',s.sellerInn],['КПП',s.sellerKpp],['ОГРН / ОГРНИП',s.sellerOgrn],['Адрес',s.sellerLegalAddress],['Телефон',s.sellerPhone],['E-mail',s.sellerEmail],['Банк',s.sellerBank],['БИК',s.sellerBik],['Расч. счёт',s.sellerAccount],['Корр. счёт',s.sellerCorrAccount]]):'';const buyer=party('Покупатель',[['Компания / ИП',b.name],['ИНН',b.inn],['КПП',b.kpp],['ОГРН / ОГРНИП',b.ogrn],['Адрес',b.legalAddress],['Контакт',b.contact],['Телефон',b.phone],['E-mail',b.email],['Банк',b.bank],['БИК',b.bik],['Расч. счёт',b.account],['Корр. счёт',b.corrAccount]]);
|
||||
dialog.innerHTML=`<div class="sun-doc sun-receipt"><div class="sun-doc-head"><div class="sun-doc-brand"><img src="${window.CateriumBranding.logoHTML()}" alt="${window.CateriumBranding.nameHTML()}"><div><strong>${esc(s.sellerName||(window.CateriumBranding.documentName()))}</strong><small>${esc(s.sellerLegalName||'')}</small></div></div><div class="sun-doc-title"><h2>Товарный чек № ${esc(draft.id||'—')}</h2><p>${esc(docDate($('date')?.value||draft.date))} · ${esc($('time')?.value||draft.time||'')}</p></div></div>${seller}${buyer}<table><thead><tr><th class="doc-num">№</th><th class="doc-name">Наименование</th><th>Цена</th><th>Кол.</th><th>Сумма</th></tr></thead><tbody>${rows.length?rows.map(r=>`<tr><td class="doc-num">${r.index}</td><td class="doc-name">${esc(r.name)}${(r.weight||r.pieces)?`<span class="doc-sub sun-doc-weight">${r.weight?`${r.categoryId===6?'Вес порции':'Вес бокса'}: ${esc(r.weight)}`:''}${r.weight&&r.pieces?' · ':''}${r.pieces?`Количество: ${esc(r.pieces)} шт.`:''}</span>`:''}</td><td>${money(r.price)}</td><td>${r.qty}</td><td>${money(r.sum)}</td></tr>`).join(''):'<tr><td colspan="5">В заказе нет позиций.</td></tr>'}</tbody></table><div class="sun-doc-summary"><p><span>Стоимость позиций</span><b>${money(p.base)}</b></p>${p.manual?`<p><span>Скидка</span><b>− ${money(p.manual)}</b></p>`:''}${p.promo?`<p><span>Промокод ${esc(draft.promoCode||'')}</span><b>− ${money(p.promo)}</b></p>`:''}<p><span>Доставка</span><b>${money(p.delivery)}</b></p><p><span>Итого</span><b>${money(p.total)}</b></p><p><span>Оплачено</span><b>${money(p.paid)}</b></p><p class="grand"><span>К оплате</span><span>${money(p.balance)}</span></p></div>${visibleNote?`<div class="sun-doc-note"><b>Примечание</b>${esc(visibleNote)}</div>`:''}<div class="sun-doc-footer"><div><div class="sun-doc-sign">Продавец / подпись</div></div><div><div class="sun-doc-sign">Покупатель / подпись</div></div></div>${docActions()}</div>`;window.modal?.('preview');
|
||||
captureBuyerFromFields();captureDiscounts();const rows=currentRows(),p=pricing(draft),s=readSeller(),b=draft.buyerRequisites||buyerDefaults(),dialog=document.querySelector('#preview .dialog');if(!dialog)return;const seller=s.includeSeller!==false?party('Продавец',[['Наименование',s.sellerLegalName||s.sellerName],['ИНН',s.sellerInn],['КПП',s.sellerKpp],['ОГРН / ОГРНИП',s.sellerOgrn],['Адрес',s.sellerLegalAddress],['Телефон',s.sellerPhone],['E-mail',s.sellerEmail],['Банк',s.sellerBank],['БИК',s.sellerBik],['Расч. счёт',s.sellerAccount],['Корр. счёт',s.sellerCorrAccount]]):'';const buyer=party('Покупатель',[['Компания / ИП',b.name],['ИНН',b.inn],['КПП',b.kpp],['ОГРН / ОГРНИП',b.ogrn],['Адрес',b.legalAddress],['Контакт',b.contact],['Телефон',b.phone],['E-mail',b.email],['Банк',b.bank],['БИК',b.bik],['Расч. счёт',b.account],['Корр. счёт',b.corrAccount]]);
|
||||
dialog.innerHTML=`<div class="sun-doc sun-receipt"><div class="sun-doc-head"><div class="sun-doc-brand"><img src="${window.CateriumBranding.logoHTML()}" alt="${window.CateriumBranding.nameHTML()}"><div><strong>${esc(s.sellerName||(window.CateriumBranding.documentName()))}</strong><small>${esc(s.sellerLegalName||'')}</small></div></div><div class="sun-doc-title"><h2>Товарный чек № ${esc(draft.id||'—')}</h2><p>${esc(docDate($('date')?.value||draft.date))} · ${esc($('time')?.value||draft.time||'')}</p></div></div>${seller}${buyer}<table><thead><tr><th class="doc-num">№</th><th class="doc-name">Наименование</th><th>Цена</th><th>Кол.</th><th>Сумма</th></tr></thead><tbody>${rows.length?rows.map(r=>`<tr><td class="doc-num">${r.index}</td><td class="doc-name">${esc(r.name)}${(r.weight||r.pieces)?`<span class="doc-sub sun-doc-weight">${r.weight?`${r.categoryId===6?'Вес порции':'Вес бокса'}: ${esc(r.weight)}`:''}${r.weight&&r.pieces?' · ':''}${r.pieces?`Количество: ${esc(r.pieces)} шт.`:''}</span>`:''}</td><td>${money(r.price)}</td><td>${r.qty}</td><td>${money(r.sum)}</td></tr>`).join(''):'<tr><td colspan="5">В заказе нет позиций.</td></tr>'}</tbody></table><div class="sun-doc-summary"><p><span>Стоимость позиций</span><b>${money(p.base)}</b></p>${p.manual?`<p><span>Скидка</span><b>− ${money(p.manual)}</b></p>`:''}${p.promo?`<p><span>Промокод ${esc(draft.promoCode||'')}</span><b>− ${money(p.promo)}</b></p>`:''}<p><span>Доставка</span><b>${money(p.delivery)}</b></p><p><span>Итого</span><b>${money(p.total)}</b></p><p><span>Оплачено</span><b>${money(p.paid)}</b></p><p class="grand"><span>К оплате</span><span>${money(p.balance)}</span></p></div>${$('note')?.value?`<div class="sun-doc-note"><b>Примечание</b>${esc($('note').value)}</div>`:''}<div class="sun-doc-footer"><div><div class="sun-doc-sign">Продавец / подпись</div></div><div><div class="sun-doc-sign">Покупатель / подпись</div></div></div>${docActions()}</div>`;window.modal?.('preview');
|
||||
}
|
||||
window.sunShowConfiguredReceipt=showReceipt;window.sunShowOrderBlankV5=showOrderBlank;
|
||||
function patchDocButtons(){qa('#new .actions button').forEach(b=>{const text=(b.textContent||'').trim();if(text==='Товарный чек')b.onclick=showReceipt;if(text==='Бланк заказа')b.onclick=showOrderBlank;});}
|
||||
@ -4071,7 +4069,6 @@ window.SUN_LEGACY_CATALOG_V175=[];
|
||||
try{window.renderStats?.()}catch(_){}
|
||||
try{window.sunRenderOrderCards?.()}catch(_){}
|
||||
window.dispatchEvent(new CustomEvent('sun:order-deleted',{detail:{id:order.id}}));
|
||||
try{const sync=window.SunCloudV2?.syncNow?.();if(sync&&typeof sync.catch==='function')sync.catch(error=>console.warn('Order delete cloud sync failed',error));}catch(error){console.warn('Order delete cloud sync failed',error)}
|
||||
window.SunEnterprise?.toast?.(`Заказ №${order.id} удалён.`,'success');
|
||||
return true;
|
||||
};
|
||||
@ -4164,7 +4161,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;align-items:center}
|
||||
.sun-lead-settings-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px}
|
||||
@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 +4194,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 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>`;
|
||||
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>`;
|
||||
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,83 +4615,6 @@ 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 */
|
||||
(()=>{
|
||||
@ -5361,10 +5281,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-код и бланк заказа.'}
|
||||
];
|
||||
@ -5405,14 +5325,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'||id==='sunOrderAuditLogCard'||/^(Профиль и аккаунт|Облачная синхронизация|Тариф и подписка|Пользователи и роли|Журнал заказов|История изменений)$/.test(h)){
|
||||
const order=id==='sunCloudV2Card'||/^(Профиль и аккаунт|Облачная синхронизация)$/.test(h)?10:id==='sunSaaSSettingsCardV16'||h==='Тариф и подписка'?20:h==='Пользователи и роли'?30:id==='sunOrderAuditLogCard'||h==='Журнал заказов'?80:90;
|
||||
if(id==='sunCloudV2Card'||id==='sunSaaSSettingsCardV16'||/^(Профиль и аккаунт|Облачная синхронизация|Тариф и подписка|Пользователи и роли|История изменений)$/.test(h)){
|
||||
const order=id==='sunCloudV2Card'||/^(Профиль и аккаунт|Облачная синхронизация)$/.test(h)?10:id==='sunSaaSSettingsCardV16'||h==='Тариф и подписка'?20:h==='Пользователи и роли'?30: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];
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
(()=>{
|
||||
'use strict';
|
||||
|
||||
const VERSION='17.9.1-public-basic-signup';
|
||||
const VERSION='17.8.5-single-session-login';
|
||||
const PENDING_REGISTRATION_KEY='sunPendingRegistrationV23';
|
||||
let busy=false;
|
||||
|
||||
@ -9,26 +9,7 @@
|
||||
const cloud=()=>window.SunCloudV2||null;
|
||||
const client=()=>cloud()?.getClient?.()||null;
|
||||
const esc=value=>window.SunSafe?.escapeHTML?window.SunSafe.escapeHTML(String(value??'')):String(value??'');
|
||||
const PLAN_LABELS={basic:'Базовый',professional:'Профессиональный',full:'Полный'};
|
||||
let signupPolicy={checked:false,promoOptional:false,defaultPlan:'basic'},signupPolicyPromise=null;
|
||||
|
||||
async function refreshSignupPolicy(c=client(),force=false){
|
||||
if(signupPolicy.checked&&!force)return signupPolicy;
|
||||
if(signupPolicyPromise)return signupPolicyPromise;
|
||||
if(!c)return signupPolicy;
|
||||
const task=(async()=>{
|
||||
try{
|
||||
const {data,error}=await c.rpc('caterium_public_signup_policy');
|
||||
if(error)throw error;
|
||||
signupPolicy={checked:true,promoOptional:data?.promo_optional===true,defaultPlan:data?.default_plan==='basic'?'basic':'basic'};
|
||||
}catch(_){signupPolicy={checked:true,promoOptional:false,defaultPlan:'basic'};}
|
||||
return signupPolicy;
|
||||
})();
|
||||
signupPolicyPromise=task;
|
||||
try{return await task}finally{if(signupPolicyPromise===task)signupPolicyPromise=null;}
|
||||
}
|
||||
function promoHintText(){return signupPolicy.promoOptional?'Без промокода будет создана компания на Базовом тарифе. Промокод может активировать другой тариф и пробный период.':'Для создания новой компании требуется действующий промокод.';}
|
||||
function renderPromoPolicy(){const label=$('sunGatePromoLabelTextV181'),hint=$('sunGatePromoHintV181');if(label)label.textContent=signupPolicy.promoOptional?'Промокод — необязательно':'Промокод пробной версии';if(hint&&!String($('sunGatePromoV181')?.value||'').trim())hint.textContent=promoHintText();}
|
||||
|
||||
function redirectUrl(){try{const u=new URL(location.href);u.hash='';return u.toString();}catch(_){return location.href.split('#')[0];}}
|
||||
function savePendingRegistration(email,promoCode){try{localStorage.setItem(PENDING_REGISTRATION_KEY,JSON.stringify({email:String(email||'').trim().toLowerCase(),companyName:'Новая компания',promoCode:String(promoCode||'').trim().toUpperCase(),createdAt:new Date().toISOString()}));}catch(_){}}
|
||||
@ -42,20 +23,16 @@
|
||||
const fields=$('sunGateRegisterFieldsV27');if(!fields)return;
|
||||
const company=$('sunGateCompanyV3');if(company?.parentElement)company.parentElement.remove();
|
||||
if(!$('sunGatePromoV181')){
|
||||
const label=document.createElement('label');label.id='sunGatePromoLabelV181';label.innerHTML='<span id="sunGatePromoLabelTextV181">Промокод пробной версии</span><input id="sunGatePromoV181" autocomplete="off" maxlength="32" placeholder="Например, CTM-A7K4P9"><small id="sunGatePromoHintV181" style="display:block;margin-top:5px;opacity:.72">Проверяю условия регистрации…</small>';
|
||||
const label=document.createElement('label');label.id='sunGatePromoLabelV181';label.innerHTML='<span>Промокод пробной версии</span><input id="sunGatePromoV181" autocomplete="off" maxlength="32" placeholder="Например, CTM-A7K4P9"><small id="sunGatePromoHintV181" style="display:block;margin-top:5px;opacity:.72">Промокод создаёт компанию и активирует пробный период.</small>';
|
||||
fields.appendChild(label);
|
||||
const input=$('sunGatePromoV181');input?.addEventListener('input',()=>{input.value=input.value.toUpperCase().replace(/\s+/g,'');if(!input.value)renderPromoPolicy();});
|
||||
input?.addEventListener('blur',async()=>{const code=String(input.value||'').trim(),email=String($('sunGateEmailV3')?.value||'').trim().toLowerCase(),h=$('sunGatePromoHintV181');if(!h)return;if(!code){await refreshSignupPolicy();renderPromoPolicy();return;}try{const c=client();if(!c)return;const {data,error}=await c.rpc('caterium_trial_promo_preview',{p_code:code,p_email:email||null});if(error)throw error;const label=PLAN_LABELS[data?.plan]||data?.plan||'тариф по промокоду';h.textContent=data?.valid?`Промокод принят · ${label} · ${Number(data.trial_days||14)} дней`:(data?.reason||'Промокод недействителен');}catch(_){h.textContent='Не удалось проверить промокод.';}});
|
||||
const input=$('sunGatePromoV181');input?.addEventListener('input',()=>{input.value=input.value.toUpperCase().replace(/\s+/g,'');});
|
||||
input?.addEventListener('blur',async()=>{const code=String(input.value||'').trim(),email=String($('sunGateEmailV3')?.value||'').trim().toLowerCase(),h=$('sunGatePromoHintV181');if(!code||!h)return;try{const c=client();if(!c)return;const {data,error}=await c.rpc('caterium_trial_promo_preview',{p_code:code,p_email:email||null});if(error)throw error;h.textContent=data?.valid?`Промокод принят · ${Number(data.trial_days||14)} дней пробного доступа`:(data?.reason||'Промокод недействителен');}catch(_){h.textContent='Не удалось проверить промокод.';}});
|
||||
}
|
||||
void refreshSignupPolicy().then(renderPromoPolicy);
|
||||
}
|
||||
|
||||
async function validatePromo(c,email,code){
|
||||
if(!code){const policy=await refreshSignupPolicy(c);if(policy.promoOptional)return {valid:true,plan:'basic',withoutPromo:true};throw new Error('Введите промокод пробной версии Caterium.');}
|
||||
const {data,error}=await c.rpc('caterium_trial_promo_preview',{p_code:code,p_email:email});if(error)throw error;if(!data?.valid)throw new Error(data?.reason||'Промокод недействителен.');return data;
|
||||
}
|
||||
async function validatePromo(c,email,code){if(!code)throw new Error('Введите промокод пробной версии Caterium.');const {data,error}=await c.rpc('caterium_trial_promo_preview',{p_code:code,p_email:email});if(error)throw error;if(!data?.valid)throw new Error(data?.reason||'Промокод недействителен.');return data;}
|
||||
|
||||
function showPublicConfirmation(gate,email){if(!gate)return;gate.innerHTML=`<div class="sun-cloud-auth-card"><div class="sun-cloud-auth-brand"><img src="caterium-login-logo.png" alt="Caterium"><div><h2>Подтвердите email</h2><div class="hint">Регистрация Caterium</div></div></div><p class="hint">Мы отправили письмо на <b>${esc(email)}</b>. После подтверждения войдите в Caterium — компания и доступ будут созданы автоматически. Без промокода активируется Базовый тариф; с промокодом — тариф и срок по условиям кода. Название компании вы укажете в настройках.</p><div class="sun-cloud-auth-actions"><button class="primary" id="sunAuthGoLoginV1774" type="button">Перейти ко входу</button></div></div>`;gate.querySelector('#sunAuthGoLoginV1774')?.addEventListener('click',()=>location.reload(),{once:true});}
|
||||
function showPublicConfirmation(gate,email){if(!gate)return;gate.innerHTML=`<div class="sun-cloud-auth-card"><div class="sun-cloud-auth-brand"><img src="caterium-login-logo.png" alt="Caterium"><div><h2>Подтвердите email</h2><div class="hint">Регистрация Caterium</div></div></div><p class="hint">Мы отправили письмо на <b>${esc(email)}</b>. После подтверждения войдите в Caterium — компания и пробный период будут созданы автоматически. Название компании вы укажете в настройках.</p><div class="sun-cloud-auth-actions"><button class="primary" id="sunAuthGoLoginV1774" type="button">Перейти ко входу</button></div></div>`;gate.querySelector('#sunAuthGoLoginV1774')?.addEventListener('click',()=>location.reload(),{once:true});}
|
||||
function showInviteConfirmation(gate,email){if(!gate)return;gate.innerHTML=`<div class="sun-cloud-auth-card"><div class="sun-cloud-auth-brand"><img src="caterium-login-logo.png" alt="Caterium"><div><h2>Подтвердите email</h2><div class="hint">Приглашение сохранено</div></div></div><p class="hint">Письмо подтверждения отправлено на <b>${esc(email)}</b>. После подтверждения войдите по ссылке приглашения — Caterium автоматически подключит вас к компании.</p><div class="sun-cloud-auth-actions"><button class="primary" id="sunInviteReloadV1774" type="button">Я подтвердил email</button></div></div>`;gate.querySelector('#sunInviteReloadV1774')?.addEventListener('click',()=>location.reload(),{once:true});}
|
||||
|
||||
async function publicLogin(gate){
|
||||
@ -76,12 +53,11 @@
|
||||
if(busy)return;const c=client();if(!c){setError(gate,'Облачный сервис не подключён.');return;}
|
||||
const email=String($('sunGateEmailV3')?.value||'').trim().toLowerCase(),password=String($('sunGatePasswordV3')?.value||''),password2=String($('sunGatePassword2V27')?.value||''),promoCode=String($('sunGatePromoV181')?.value||'').trim().toUpperCase();
|
||||
if(!email||password.length<6){setError(gate,'Введите email и пароль минимум из 6 символов.');return;}if(password!==password2){setError(gate,'Пароли не совпадают.');return;}
|
||||
busy=true;const button=$('sunGateSubmitV3');if(button)button.disabled=true;setError(gate,promoCode?'Проверяю промокод…':'Проверяю Базовый тариф…');
|
||||
busy=true;const button=$('sunGateSubmitV3');if(button)button.disabled=true;setError(gate,'Проверяю промокод…');
|
||||
try{
|
||||
await validatePromo(c,email,promoCode);savePendingRegistration(email,promoCode);setError(gate,'Создаю аккаунт…');
|
||||
const existing=await c.auth.signInWithPassword({email,password});if(!existing.error&&existing.data?.session){location.reload();return;}
|
||||
const signupData=promoCode?{promo_code:promoCode}:{};
|
||||
const result=await c.auth.signUp({email,password,options:{emailRedirectTo:redirectUrl(),data:signupData}});if(result.error)throw result.error;
|
||||
const result=await c.auth.signUp({email,password,options:{emailRedirectTo:redirectUrl(),data:{promo_code:promoCode}}});if(result.error)throw result.error;
|
||||
if(result.data?.session){await signOutUnsafeSession(c);throw new Error('Подтверждение email не включено на сервере. Регистрация остановлена.');}
|
||||
showPublicConfirmation(gate,email);
|
||||
}catch(error){clearPendingRegistration();setError(gate,friendlySignupError(error));if(button)button.disabled=false;}finally{busy=false;}
|
||||
@ -97,11 +73,11 @@
|
||||
}
|
||||
|
||||
async function finishOwnerOnboarding(gate){
|
||||
if(busy)return false;const c=client(),api=cloud(),pending=pendingRegistration();if(!c||!api||!pending?.email)return false;
|
||||
if(busy)return false;const c=client(),api=cloud(),pending=pendingRegistration();if(!c||!api||!pending?.promoCode)return false;
|
||||
const session=api.getSession?.();const email=String(session?.user?.email||'').trim().toLowerCase();if(!email||email!==String(pending.email||'').toLowerCase())return false;
|
||||
busy=true;setError(gate,'Подключаю рабочую базу…');
|
||||
try{
|
||||
const user=session.user;const metadata={...(user.user_metadata||{}),promo_code:pending.promoCode||null};
|
||||
const user=session.user;const metadata={...(user.user_metadata||{}),promo_code:pending.promoCode};
|
||||
const updated=await c.auth.updateUser({data:metadata});if(updated.error)throw updated.error;
|
||||
const created=await c.rpc('sun_create_workspace',{p_name:'Новая компания'});if(created.error)throw created.error;
|
||||
clearPendingRegistration();await api.reloadMemberships?.();location.reload();return true;
|
||||
@ -114,7 +90,7 @@
|
||||
const recovery=$('sunGateRecoveryCompanyV25');
|
||||
if(recovery){
|
||||
recovery.closest('label')?.remove();
|
||||
const button=$('sunGateRetryWorkspaceV3');if(button){button.textContent='Проверить доступ';button.onclick=async()=>{setError(gate,'Проверяю доступ…');try{const found=await cloud()?.reloadMemberships?.();if(found){location.reload();return;}if(await finishOwnerOnboarding(gate))return;await refreshSignupPolicy();setError(gate,signupPolicy.promoOptional?'Аккаунт не привязан к компании. Если вы сотрудник — попросите владельца добавить вас в «Пользователи и права». Для новой компании зарегистрируйтесь: без промокода доступен Базовый тариф.':'Аккаунт не привязан к компании. Если вы сотрудник — попросите владельца добавить вас в «Пользователи и права». Для новой компании нужен действующий промокод.');}catch(e){setError(gate,e?.message||String(e));}};}
|
||||
const button=$('sunGateRetryWorkspaceV3');if(button){button.textContent='Проверить доступ';button.onclick=async()=>{setError(gate,'Проверяю доступ…');try{const found=await cloud()?.reloadMemberships?.();if(found){location.reload();return;}if(await finishOwnerOnboarding(gate))return;setError(gate,'Аккаунт не привязан к компании. Если вы сотрудник — попросите владельца добавить вас в «Пользователи и права». Для новой компании зарегистрируйте пробный период по промокоду.');}catch(e){setError(gate,e?.message||String(e));}};}
|
||||
const title=gate.querySelector('h2');if(title)title.textContent='Нет доступа к компании';
|
||||
const hints=gate.querySelectorAll('.hint');if(hints[1])hints[1].textContent='Этот аккаунт пока не привязан к рабочей компании.';
|
||||
}
|
||||
|
||||
@ -5,12 +5,6 @@
|
||||
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;};
|
||||
@ -79,17 +73,16 @@
|
||||
}
|
||||
function dateLabel(value){if(!/^\d{4}-\d{2}-\d{2}$/.test(value))return value;const d=new Date(value+'T12:00:00');return Number.isNaN(d.getTime())?value:d.toLocaleDateString('ru-RU',{day:'numeric',month:'long',year:'numeric'});}
|
||||
function guestLabel(n){const a=n%100,b=n%10;return `${n.toLocaleString('ru-RU')} ${a>10&&a<20?'гостей':b===1?'гость':b>=2&&b<=4?'гостя':'гостей'}`;}
|
||||
function plan(ctx,s,top,bottom){
|
||||
function plan(ctx,s,showPrices,top,bottom){
|
||||
const ordered=groups.flatMap(([id])=>s.items.filter(i=>i.group===id));
|
||||
// Keep every dish and every character. Never crop, use ellipses or make a
|
||||
// second page. A genuinely oversized menu gets a clear, recoverable error.
|
||||
// Per-item prices are never shown to the client — only the per-guest total in the footer.
|
||||
for(let font=24;font>=17;font--){
|
||||
const choices=ordered.length<=8?[1,2]:[2,1];
|
||||
for(const columns of choices){
|
||||
const width=(W-M*2-(columns-1)*42)/columns;
|
||||
ctx.font=`${font}px ${SANS}`;
|
||||
const rows=ordered.map(item=>({item,lines:wrap(ctx,item.name,width),meta:[item.weight].filter(Boolean).join(' · ')}));
|
||||
const rows=ordered.map(item=>({item,lines:wrap(ctx,item.name,width),meta:[item.weight,showPrices?money(item.cents/100):''].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))]);
|
||||
@ -99,28 +92,27 @@
|
||||
}
|
||||
throw new Error('Меню не помещается на одну страницу без слишком мелкого текста. Сократите названия или количество выбранных блюд. Ни одно блюдо не было обрезано.');
|
||||
}
|
||||
async function render(s,{themeId='gold'}={}){
|
||||
const theme=THEMES[themeId]||THEMES.gold;
|
||||
async function render(s,{showPrices=true}={}){
|
||||
await fonts();const [pdf,logo]=await Promise.all([engine(),logoImage(s.brand.logo)]);
|
||||
const canvas=document.createElement('canvas');canvas.width=W*2;canvas.height=H*2;
|
||||
const c=canvas.getContext('2d',{alpha:false});c.scale(2,2);c.textBaseline='top';
|
||||
const drawnText=[],{ink,muted,accent:gold,paper,rule}=theme,centered=theme.titleAlign==='center';
|
||||
const text=(value,x,y,width,font,color=ink,lh=24,align='left')=>{c.font=font;c.fillStyle=color;c.textAlign=align;const lines=wrap(c,value,width);for(let i=0;i<lines.length;i++){c.fillText(lines[i],align==='center'?x+width/2:x,y+i*lh);drawnText.push(lines[i]);}return lines.length*lh;};
|
||||
const 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 line=(x,y,x2,color=rule)=>{c.strokeStyle=color;c.lineWidth=1;c.beginPath();c.moveTo(x,y);c.lineTo(x2,y);c.stroke();};
|
||||
c.fillStyle=paper;c.fillRect(0,0,W,H);c.strokeStyle=rule;c.lineWidth=1;c.strokeRect(24,24,W-48,H-48);
|
||||
const brandHeight=text(s.brand.name,M,58,logo?590:W-M*2,`600 21px ${SANS}`,ink,27,centered?'center':'left');
|
||||
const brandHeight=text(s.brand.name,M,58,logo?590:W-M*2,`600 21px ${SANS}`,ink,27);
|
||||
let headerBottom=Math.max(112,58+brandHeight);
|
||||
if(logo&&!centered){const scale=Math.min(210/logo.width,66/logo.height);c.drawImage(logo,W-M-logo.width*scale,54,logo.width*scale,logo.height*scale);headerBottom=Math.max(headerBottom,125);}
|
||||
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);}
|
||||
line(M,headerBottom+14,W-M,gold);
|
||||
let y=headerBottom+42;
|
||||
y+=text('Банкетное меню',M,y,W-M*2,`500 52px ${SERIF}`,ink,63,centered?'center':'left')+14;
|
||||
if(s.event)y+=text(s.event,M,y,W-M*2,`20px ${SANS}`,muted,27,centered?'center':'left')+10;
|
||||
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;
|
||||
const facts=[s.date?dateLabel(s.date):'',guestLabel(s.guests)].filter(Boolean).join(' · ');
|
||||
y+=text(facts,M,y,W-M*2,`600 16px ${SANS}`,gold,23,centered?'center':'left')+24;
|
||||
y+=text(facts,M,y,W-M*2,`600 16px ${SANS}`,gold,23)+24;
|
||||
line(M,y,W-M);const top=y+20;
|
||||
c.font=`13px ${SANS}`;const contacts=wrap(c,s.brand.contacts,W-M*2),contactHeight=contacts.length*19;
|
||||
const footerHeight=108+contactHeight+(s.estimated?22:0),bottom=H-64-footerHeight;
|
||||
const layout=plan(c,s,top,bottom);
|
||||
const footerHeight=(showPrices?142:62)+contactHeight+(s.estimated?22:0),bottom=H-64-footerHeight;
|
||||
const layout=plan(c,s,showPrices,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){
|
||||
@ -131,13 +123,13 @@
|
||||
}
|
||||
}
|
||||
let fy=bottom+22;line(M,fy,W-M,gold);fy+=20;
|
||||
// Only the per-guest price is ever shown to the client — never a per-item price
|
||||
// and never the grand total, so the menu can't be read as a full price list.
|
||||
// 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(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;}
|
||||
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));
|
||||
@ -151,7 +143,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 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 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 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);
|
||||
@ -160,22 +152,21 @@
|
||||
if(!allowed())throw new Error('Предложения клиенту недоступны для текущих прав или тарифа.');
|
||||
const s=snapshot(input),started=scope();close();style();
|
||||
const dialog=document.createElement('dialog');dialog.id='ctBanquetClientDialog';dialog.setAttribute('aria-labelledby','ctBanquetClientTitle');
|
||||
const themeOptions=themeIds.map(id=>`<option value="${id}">${THEMES[id].label}</option>`).join('');
|
||||
dialog.innerHTML=`<div class="ct-bcm-toolbar"><h2 id="ctBanquetClientTitle">Меню для клиента</h2><div class="ct-bcm-actions"><label>Оформление<select data-bcm-theme>${themeOptions}</select></label><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>`;
|
||||
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 state={dialog,snapshot:s,generation:0,url:null,result:null,restore:document.activeElement};panel=state;
|
||||
const get=q=>dialog.querySelector(q),status=get('[data-bcm-status]'),download=get('[data-bcm-download]'),share=get('[data-bcm-share]'),themeSelect=get('[data-bcm-theme]');
|
||||
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]');
|
||||
document.body.append(dialog);dialog.addEventListener('cancel',e=>{e.preventDefault();close();});get('[data-bcm-close]').onclick=close;dialog.showModal();
|
||||
const valid=()=>panel===state&&scope()===started&&allowed();
|
||||
const build=async()=>{
|
||||
const ticket=++state.generation;download.disabled=true;share.disabled=true;state.result=null;status.textContent='Оформляю меню на одной странице…';
|
||||
try{const result=await render(s,{themeId:themeSelect.value});if(!valid()||ticket!==state.generation)return;
|
||||
try{const result=await render(s,{showPrices:check.checked});if(!valid()||ticket!==state.generation)return;
|
||||
if(state.url)URL.revokeObjectURL(state.url);state.url=URL.createObjectURL(result.jpeg);state.result=result;
|
||||
const img=document.createElement('img');img.src=state.url;img.alt='Банкетное меню: одна страница A4';get('[data-bcm-preview]').replaceChildren(img);
|
||||
status.textContent=`Готово: одна страница A4 · ${s.items.length} позиций. Показана только цена за гостя.`;download.disabled=false;
|
||||
status.textContent=`Готово: одна страница A4 · ${s.items.length} позиций. ${check.checked?'Стоимость только выбранных блюд.':'Без указания стоимости.'}`;download.disabled=false;
|
||||
const file=new File([result.blob],'Банкетное меню.pdf',{type:'application/pdf'});share.hidden=!(navigator.share&&navigator.canShare?.({files:[file]}));share.disabled=false;
|
||||
}catch(error){if(valid()&&ticket===state.generation){status.textContent=error.message;get('[data-bcm-preview]').replaceChildren();}}
|
||||
};
|
||||
themeSelect.onchange=build;
|
||||
check.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;
|
||||
|
||||
@ -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=20260922-guest-price-always';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=20260920-onepage';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);
|
||||
|
||||
@ -10,7 +10,7 @@ function comments(){let rows=lineRows(),ls=activeLines();rows.forEach((row,i)=>{
|
||||
function searchBox(){let catalog=$('#new .catalog');if(!catalog||$('#sunCatalogSearchV1775'))return;let i=document.createElement('input');i.id='sunCatalogSearchV1775';i.type='search';i.placeholder='Поиск по № бокса или названию';($('.catalog-head',catalog)||catalog.firstElementChild)?.insertAdjacentElement('afterend',i);i.addEventListener('input',()=>{let q=safe(i.value).toLowerCase().replace(/ё/g,'е');$$('#tiles .tile').forEach(t=>{let text=safe(t.textContent).toLowerCase().replace(/ё/g,'е');t.hidden=t.classList.contains('add')?!!q:!!(q&&!text.includes(q))})})}
|
||||
function delivery(){return Math.max(0,Number($('#orderDeliveryCost')?.value||draft?.costs?.delivery||draft?.deliveryCost||0))}
|
||||
function financial(){const p=window.sunOrderPricing?.(draft);let d=p?Number(p.delivery||0):delivery(),positions=p?Number(p.itemsTotal||0):Math.max(0,Number(typeof calcOrderTotal==='function'?calcOrderTotal():0)),paid=Math.max(0,Number($('#prepayment')?.value||draft?.prepayment||0)),total=positions+d;return{positions,d,total,paid,balance:Math.max(0,total-paid)}}
|
||||
function summary(){let h=$('.order-summary');if(!h)return;let f=financial();if($('#summaryTotal'))$('#summaryTotal').textContent=fmt(f.positions);let richUI=!!$('#summaryDelivery');if(!richUI){let d=$('#sunSummaryDeliveryV1775');if(!d){d=document.createElement('p');d.id='sunSummaryDeliveryV1775';h.appendChild(d)}d.innerHTML=`<span>Доставка</span><b>${fmt(f.d)}</b>`;let t=$('#sunSummaryGrandV1775');if(!t){t=document.createElement('p');t.id='sunSummaryGrandV1775';t.className='sun-summary-grand-v1775';h.appendChild(t)}t.innerHTML=`<span>Итого</span><b>${fmt(f.total)}</b>`}else{$('#sunSummaryDeliveryV1775')?.remove();$('#sunSummaryGrandV1775')?.remove()}if($('#orderTotal'))$('#orderTotal').value=Math.round(f.total);if($('#balance'))$('#balance').value=Math.round(f.balance);if($('#summaryBalance'))$('#summaryBalance').textContent=fmt(f.balance);draft.total=f.total;draft.balance=f.balance}
|
||||
function summary(){let h=$('.order-summary');if(!h)return;let f=financial();if($('#summaryTotal'))$('#summaryTotal').textContent=fmt(f.positions);let d=$('#sunSummaryDeliveryV1775');if(!d){d=document.createElement('p');d.id='sunSummaryDeliveryV1775';h.appendChild(d)}d.innerHTML=`<span>Доставка</span><b>${fmt(f.d)}</b>`;let t=$('#sunSummaryGrandV1775');if(!t){t=document.createElement('p');t.id='sunSummaryGrandV1775';t.className='sun-summary-grand-v1775';h.appendChild(t)}t.innerHTML=`<span>Итого</span><b>${fmt(f.total)}</b>`;if($('#orderTotal'))$('#orderTotal').value=Math.round(f.total);if($('#balance'))$('#balance').value=Math.round(f.balance);if($('#summaryBalance'))$('#summaryBalance').textContent=fmt(f.balance);draft.total=f.total;draft.balance=f.balance}
|
||||
function post(){styles();searchBox();comments();summary()}
|
||||
function wrap(n){let old=window[n];if(typeof old!=='function'||old.__commentsV1775)return;let fn=function(...a){let r=old.apply(this,a);queueMicrotask(post);setTimeout(post,50);return r};fn.__commentsV1775=true;window[n]=fn}
|
||||
styles();['render','openOrder','resetDraft','updateOrderSummary','saveOrder'].forEach(wrap);post();
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
(()=>{
|
||||
'use strict';
|
||||
const VERSION='17.7.3';
|
||||
const RELEASE='20260922-order-summary-fix';
|
||||
const RELEASE='20260921-client-sync';
|
||||
|
||||
const hasStoredSession=()=>{try{return Object.keys(localStorage).some(k=>/^sb-.*-auth-token$/i.test(k)&&String(localStorage.getItem(k)||'').length>20)}catch(_){return false}};
|
||||
function installAuthBoot(){
|
||||
|
||||
@ -1,233 +0,0 @@
|
||||
(()=>{
|
||||
'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();
|
||||
})();
|
||||
@ -33,7 +33,7 @@
|
||||
const controller=new AbortController(),timeout=setTimeout(()=>controller.abort(),15000);
|
||||
try{
|
||||
const [base,banquet,extras]=await Promise.all(['catalog-v1','banquet-v1','extras-v1'].map(async name=>{
|
||||
const r=await fetch(`demo/${name}.json?v=20260922-training-photos`,{signal:controller.signal,credentials:'same-origin'});
|
||||
const r=await fetch(`demo/${name}.json?v=20260920-training`,{signal:controller.signal,credentials:'same-origin'});
|
||||
if(!r.ok)throw new Error('Не удалось загрузить учебный каталог. Проверьте интернет и повторите.');
|
||||
const value=await r.json();if(value?.version!==1)throw new Error('Неизвестная версия учебного каталога.');return value;
|
||||
}));
|
||||
@ -128,8 +128,6 @@
|
||||
card.innerHTML='<h2>Обучение и знакомство</h2><label class="ct-training-toggle"><input id="ctTrainingEnabled" type="checkbox" aria-describedby="ctTrainingDescription ctTrainingSafety"><span><b>Учебный каталог</b><small>Готовые боксы и блюда для знакомства с Caterium</small></span></label><p id="ctTrainingDescription">Фотографии, составы, технологические карты, учебные цены и поставщики. Можно собрать пробный заказ, посмотреть предложение клиенту, заготовки и закупки — без ручного заполнения каталога.</p><p id="ctTrainingSafety">Снимите галочку, чтобы скрыть учебные позиции. Свои блюда, изменения учебных карточек и созданные заказы не удаляются. Переключатель действует для вашего профиля в этой компании. Складские операции выполняются только по вашей команде.</p><p id="ctTrainingAccess" class="hint"></p><p id="ctTrainingStatus" role="status" aria-live="polite"></p>';
|
||||
host.prepend(card);
|
||||
card.querySelector('input').addEventListener('change',e=>{setEnabled(e.target.checked).catch(()=>{});});
|
||||
// The trial guide (order, stock, TTK) is mounted inside this card while training is on.
|
||||
queueMicrotask(()=>window.CateriumTrialDemo?.render?.());
|
||||
}
|
||||
const check=$('ctTrainingEnabled');check.checked=busy?desired:enabled();check.disabled=busy||!canManage()||!repo()?.catalog;
|
||||
card.setAttribute('aria-busy',String(busy));
|
||||
|
||||
@ -18,40 +18,16 @@
|
||||
function message(text){const el=$('ctDemoMessage');if(el)el.textContent=text;}
|
||||
async function refreshStatus(){status=null;statusWorkspace='';render();}
|
||||
|
||||
function openTrainingSettings(){
|
||||
const nav=[...document.querySelectorAll('header nav button')].find(b=>String(b.dataset.navLabel||b.textContent||'').trim()==='Настройки');
|
||||
if(nav)nav.click();else window.show?.('enterprise-settings');
|
||||
setTimeout(()=>{
|
||||
const card=$('ctTrainingSettings');if(!card)return;
|
||||
const tab=card.dataset.settingsTab;if(tab)document.querySelector(`[data-settings-tab-target="${tab}"]`)?.click();
|
||||
setTimeout(()=>{card.scrollIntoView({block:'start',behavior:'smooth'});card.querySelector('input')?.focus({preventScroll:true});},120);
|
||||
},150);
|
||||
}
|
||||
function ensureNotice(host){
|
||||
let notice=$('ctTrainingNotice');
|
||||
if(!notice){
|
||||
notice=document.createElement('div');notice.id='ctTrainingNotice';notice.className='ct-training-notice';notice.setAttribute('role','status');
|
||||
notice.innerHTML='<span>Включён учебный режим ·</span><button type="button" data-training-settings>отключить в настройках</button>';
|
||||
notice.querySelector('button').addEventListener('click',openTrainingSettings);
|
||||
}
|
||||
if(host.firstElementChild!==notice)host.prepend(notice);
|
||||
}
|
||||
function render(){
|
||||
const host=$('new');if(!host)return;
|
||||
const clear=()=>{$('ctTrialDemo')?.remove();$('ctTrainingNotice')?.remove();};
|
||||
if(window.CateriumTrainingCatalog&&!window.CateriumTrainingCatalog.enabled()){clear();return;}
|
||||
if(window.CateriumTrainingCatalog&&!window.CateriumTrainingCatalog.enabled()){$('ctTrialDemo')?.remove();return;}
|
||||
const demoItems=catalog().filter(b=>b.demo),items=demoItems.filter(b=>b.ttk),active=installed()&&demoItems.length>0;
|
||||
if(!signedIn()||cloud()?.isSupportMode?.()||(!active&&!status?.canInstall)){clear();return;}
|
||||
// While training is on, the order screen only carries a one-line notice; the guide lives in Settings.
|
||||
if(active)ensureNotice(host);else $('ctTrainingNotice')?.remove();
|
||||
const target=active?$('ctTrainingSettings'):host;
|
||||
let card=$('ctTrialDemo');if(card&&card.parentElement!==target){card.remove();card=null;}
|
||||
if(!target)return;
|
||||
if(!card){card=document.createElement('section');card.id='ctTrialDemo';card.className=active?'ct-demo-guide':'card';if(active)target.append(card);else target.prepend(card);}
|
||||
if(!signedIn()||cloud()?.isSupportMode?.()||(!active&&!status?.canInstall)){$('ctTrialDemo')?.remove();return;}
|
||||
let card=$('ctTrialDemo');if(!card){card=document.createElement('section');card.id='ctTrialDemo';card.className='card';host.prepend(card);}
|
||||
const signature=JSON.stringify({active,items:demoItems.map(x=>[x.id,x.name]),upgrade:status?.canUpgrade,can:writable()});
|
||||
if(card.dataset.signature===signature)return;card.dataset.signature=signature;
|
||||
const previousGuide={open:card.querySelector('details')?.open,selected:$('ctDemoBox')?.value,message:$('ctDemoMessage')?.textContent};
|
||||
card.innerHTML=active?`<div class="ct-demo-heading"><div><strong>Пробный заказ, склад и закупки</strong><p>${demoItems.length} тестовых позиций · готовые составы, учебные цены и поставщики</p></div></div>
|
||||
card.innerHTML=active?`<div class="ct-demo-heading"><div><strong>Учебный каталог включён</strong><p>${demoItems.length} тестовых позиций · готовые составы, учебные цены и поставщики</p></div><span class="ct-demo-badge">Учебные примеры</span></div>
|
||||
<p>Создайте пробный заказ, посмотрите нехватку в закупках, оформите приход на складе и спишите продукты по заказу. Все данные можно редактировать.</p>
|
||||
<div class="ct-demo-actions"><button class="primary" data-demo-order ${writable()?'':'disabled'}>Создать пробный заказ</button><button class="outline" data-demo-go="stock">Склад</button><button class="outline" data-demo-go="shopping">Закупки</button></div>
|
||||
${status?.canUpgrade?'<button class="outline" data-demo-install>Дополнить пустые вкладки демо</button>':''}
|
||||
@ -100,9 +76,9 @@
|
||||
dialog.querySelector('[data-ttk-edit]').onclick=()=>{dialog.close();window.editBox?.(box.id)};
|
||||
dialog.addEventListener('close',()=>dialog.remove(),{once:true});dialog.showModal();
|
||||
}
|
||||
function reset(){epoch++;pending=null;status=null;statusWorkspace='';$('ctTrialDemo')?.remove();$('ctTrainingNotice')?.remove();$('ctDemoTtk')?.remove();}
|
||||
function reset(){epoch++;pending=null;status=null;statusWorkspace='';$('ctTrialDemo')?.remove();$('ctDemoTtk')?.remove();}
|
||||
function boot(){
|
||||
const style=document.createElement('style');style.textContent=`#ctTrialDemo{margin:0 0 18px;padding:18px 22px}#ctTrialDemo p{font-size:13px;line-height:1.5;margin:6px 0 12px;max-width:900px}.ct-demo-heading{display:flex;justify-content:space-between;gap:14px;align-items:flex-start}.ct-demo-heading strong{font-size:18px}.ct-demo-heading h2{font-size:22px;margin:8px 0}.ct-demo-badge{border:1px solid #c3d4bd;border-radius:18px;padding:5px 10px;font-size:12px;white-space:nowrap}.ct-demo-actions{display:flex;gap:10px;align-items:end;flex-wrap:wrap;margin:12px 0}.ct-demo-actions label{min-width:0;max-width:100%;flex:1}.ct-demo-actions select{width:100%;font:inherit;padding:9px}.ct-demo-actions button{max-width:100%}#ctTrialDemo details{font-size:13px;margin-top:14px}#ctTrialDemo summary{cursor:pointer}#ctDemoMessage:empty{display:none}#ctDemoTtk{width:min(860px,calc(100vw - 24px));max-height:90dvh;box-sizing:border-box;overflow:auto;border:1px solid #d4c8b0;border-radius:18px;padding:24px;background:var(--card,#fff);color:var(--text,#27241e)}#ctDemoTtk::backdrop{background:#0008}.ct-demo-table{overflow-x:auto}.ct-demo-table table{border-collapse:collapse;min-width:580px;width:100%;font-size:13px}.ct-demo-table td,.ct-demo-table th{text-align:right;padding:9px 7px;border-bottom:1px solid #ddd}.ct-demo-table th:first-child,.ct-demo-table td:first-child{text-align:left}#ctDemoTtk li{margin:8px 0;line-height:1.5}.ct-demo-notice{background:#fff3d6;color:#604918;padding:12px;border-radius:8px}@media(max-width:600px){#ctTrialDemo{padding:14px}.ct-demo-heading{flex-wrap:wrap}.ct-demo-actions>button{flex:1 1 auto}.ct-demo-actions label{flex-basis:100%}#ctDemoTtk{padding:16px}}#ctTrainingNotice{display:flex;align-items:center;flex-wrap:wrap;gap:4px 6px;margin:0 0 12px;padding:7px 12px;border:1px solid #e6dcc0;border-radius:10px;background:#faf6e9;color:#5f4f25;font-size:12px;line-height:1.4}#ctTrainingNotice button{all:unset;box-sizing:border-box;cursor:pointer;color:#7a5c12;text-decoration:underline;font:inherit}#ctTrainingNotice button:focus-visible{outline:2px solid #b88c2e;outline-offset:2px;border-radius:3px}#ctTrainingSettings #ctTrialDemo{margin:16px 0 0;padding:16px 0 0;border:0;border-top:1px solid var(--sun-ui-border,#deded6);border-radius:0;box-shadow:none;background:none}#ctTrainingSettings #ctTrialDemo .ct-demo-heading strong{font-size:15px}@media print{#ctTrialDemo,#ctTrainingNotice{display:none!important}}`;
|
||||
const style=document.createElement('style');style.textContent=`#ctTrialDemo{margin:0 0 18px;padding:18px 22px}#ctTrialDemo p{font-size:13px;line-height:1.5;margin:6px 0 12px;max-width:900px}.ct-demo-heading{display:flex;justify-content:space-between;gap:14px;align-items:flex-start}.ct-demo-heading strong{font-size:18px}.ct-demo-heading h2{font-size:22px;margin:8px 0}.ct-demo-badge{border:1px solid #c3d4bd;border-radius:18px;padding:5px 10px;font-size:12px;white-space:nowrap}.ct-demo-actions{display:flex;gap:10px;align-items:end;flex-wrap:wrap;margin:12px 0}.ct-demo-actions label{min-width:0;max-width:100%;flex:1}.ct-demo-actions select{width:100%;font:inherit;padding:9px}.ct-demo-actions button{max-width:100%}#ctTrialDemo details{font-size:13px;margin-top:14px}#ctTrialDemo summary{cursor:pointer}#ctDemoMessage:empty{display:none}#ctDemoTtk{width:min(860px,calc(100vw - 24px));max-height:90dvh;box-sizing:border-box;overflow:auto;border:1px solid #d4c8b0;border-radius:18px;padding:24px;background:var(--card,#fff);color:var(--text,#27241e)}#ctDemoTtk::backdrop{background:#0008}.ct-demo-table{overflow-x:auto}.ct-demo-table table{border-collapse:collapse;min-width:580px;width:100%;font-size:13px}.ct-demo-table td,.ct-demo-table th{text-align:right;padding:9px 7px;border-bottom:1px solid #ddd}.ct-demo-table th:first-child,.ct-demo-table td:first-child{text-align:left}#ctDemoTtk li{margin:8px 0;line-height:1.5}.ct-demo-notice{background:#fff3d6;color:#604918;padding:12px;border-radius:8px}@media(max-width:600px){#ctTrialDemo{padding:14px}.ct-demo-heading{flex-wrap:wrap}.ct-demo-actions>button{flex:1 1 auto}.ct-demo-actions label{flex-basis:100%}#ctDemoTtk{padding:16px}}@media print{#ctTrialDemo{display:none!important}}`;
|
||||
document.head.append(style);
|
||||
window.addEventListener('sun:cloud-tenant-changing',reset);
|
||||
window.addEventListener('sun:cloud-permissions-changed',()=>{render();refreshStatus()});
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
"price": 290,
|
||||
"weight": "120 г",
|
||||
"pieces": 1,
|
||||
"photo": "demo/images/banquet-caprese.webp",
|
||||
"photo": "",
|
||||
"demo": true,
|
||||
"composition": [
|
||||
"Моцарелла мини, Томаты, Базилик, Масло оливковое"
|
||||
@ -95,7 +95,7 @@
|
||||
"price": 390,
|
||||
"weight": "100 г",
|
||||
"pieces": 1,
|
||||
"photo": "demo/images/banquet-roastbeef.webp",
|
||||
"photo": "",
|
||||
"demo": true,
|
||||
"composition": [
|
||||
"Ростбиф готовый, Корнишоны, Горчица, Сливки"
|
||||
@ -182,7 +182,7 @@
|
||||
"price": 350,
|
||||
"weight": "90 г",
|
||||
"pieces": 1,
|
||||
"photo": "demo/images/banquet-salmon-roll.webp",
|
||||
"photo": "",
|
||||
"demo": true,
|
||||
"composition": [
|
||||
"Лосось слабосолёный, Сыр сливочный, Огурцы, Укроп"
|
||||
@ -269,7 +269,7 @@
|
||||
"price": 230,
|
||||
"weight": "160 г",
|
||||
"pieces": 1,
|
||||
"photo": "demo/images/banquet-hummus.webp",
|
||||
"photo": "",
|
||||
"demo": true,
|
||||
"composition": [
|
||||
"Морковь, Огурцы, Перец сладкий, Хумус готовый"
|
||||
@ -355,7 +355,7 @@
|
||||
"price": 340,
|
||||
"weight": "180 г",
|
||||
"pieces": 1,
|
||||
"photo": "demo/images/banquet-caesar.webp",
|
||||
"photo": "",
|
||||
"demo": true,
|
||||
"composition": [
|
||||
"Филе курицы готовое, Салат листовой, Томаты черри, Хлеб тостовый, Сыр гауда, Сливки, Горчица"
|
||||
@ -483,7 +483,7 @@
|
||||
"price": 280,
|
||||
"weight": "180 г",
|
||||
"pieces": 1,
|
||||
"photo": "demo/images/banquet-olivier.webp",
|
||||
"photo": "",
|
||||
"demo": true,
|
||||
"composition": [
|
||||
"Картофель, Морковь, Ветчина из индейки, Яйцо куриное без скорлупы, Горошек зелёный консервированный, Корнишоны, Сливки, Горчица"
|
||||
@ -624,7 +624,7 @@
|
||||
"price": 290,
|
||||
"weight": "180 г",
|
||||
"pieces": 1,
|
||||
"photo": "demo/images/banquet-greek.webp",
|
||||
"photo": "",
|
||||
"demo": true,
|
||||
"composition": [
|
||||
"Томаты, Огурцы, Перец сладкий, Моцарелла мини, Маслины без косточек, Масло оливковое"
|
||||
@ -736,7 +736,7 @@
|
||||
"price": 310,
|
||||
"weight": "130 г",
|
||||
"pieces": 1,
|
||||
"photo": "demo/images/banquet-julienne.webp",
|
||||
"photo": "",
|
||||
"demo": true,
|
||||
"composition": [
|
||||
"Филе курицы готовое, Шампиньоны, Лук репчатый, Сливки, Сыр гауда, Масло оливковое"
|
||||
@ -849,7 +849,7 @@
|
||||
"price": 270,
|
||||
"weight": "120 г",
|
||||
"pieces": 1,
|
||||
"photo": "demo/images/banquet-stuffed-mushrooms.webp",
|
||||
"photo": "",
|
||||
"demo": true,
|
||||
"composition": [
|
||||
"Шампиньоны, Сыр сливочный, Сыр гауда, Укроп, Масло оливковое"
|
||||
@ -949,7 +949,7 @@
|
||||
"price": 490,
|
||||
"weight": "220 г",
|
||||
"pieces": 1,
|
||||
"photo": "demo/images/banquet-chicken.webp",
|
||||
"photo": "",
|
||||
"demo": true,
|
||||
"composition": [
|
||||
"Филе куриное сырое, Перец сладкий, Морковь, Сливки, Масло оливковое"
|
||||
@ -1049,7 +1049,7 @@
|
||||
"price": 590,
|
||||
"weight": "200 г",
|
||||
"pieces": 1,
|
||||
"photo": "demo/images/banquet-cod.webp",
|
||||
"photo": "",
|
||||
"demo": true,
|
||||
"composition": [
|
||||
"Филе трески, Томаты, Перец сладкий, Лимон, Масло оливковое"
|
||||
@ -1148,7 +1148,7 @@
|
||||
"price": 690,
|
||||
"weight": "220 г",
|
||||
"pieces": 1,
|
||||
"photo": "demo/images/banquet-beef-hot.webp",
|
||||
"photo": "",
|
||||
"demo": true,
|
||||
"composition": [
|
||||
"Ростбиф готовый, Шампиньоны, Лук репчатый, Сливки, Масло оливковое"
|
||||
@ -1247,7 +1247,7 @@
|
||||
"price": 160,
|
||||
"weight": "150 г",
|
||||
"pieces": 1,
|
||||
"photo": "demo/images/banquet-mash.webp",
|
||||
"photo": "",
|
||||
"demo": true,
|
||||
"composition": [
|
||||
"Картофель, Молоко, Масло сливочное"
|
||||
@ -1320,7 +1320,7 @@
|
||||
"price": 170,
|
||||
"weight": "150 г",
|
||||
"pieces": 1,
|
||||
"photo": "demo/images/banquet-rice.webp",
|
||||
"photo": "",
|
||||
"demo": true,
|
||||
"composition": [
|
||||
"Рис сухой, Морковь, Перец сладкий, Горошек зелёный консервированный, Масло оливковое"
|
||||
@ -1417,7 +1417,7 @@
|
||||
"price": 240,
|
||||
"weight": "110 г",
|
||||
"pieces": 1,
|
||||
"photo": "demo/images/banquet-berry-cream.webp",
|
||||
"photo": "",
|
||||
"demo": true,
|
||||
"composition": [
|
||||
"Сыр сливочный, Сливки, Крошка бисквитная, Клубника, Голубика, Пудра сахарная"
|
||||
@ -1531,7 +1531,7 @@
|
||||
"price": 310,
|
||||
"weight": "100 г",
|
||||
"pieces": 1,
|
||||
"photo": "demo/images/banquet-cheese-honey.webp",
|
||||
"photo": "",
|
||||
"demo": true,
|
||||
"composition": [
|
||||
"Сыр бри, Виноград, Орех грецкий очищенный, Мёд"
|
||||
@ -1618,7 +1618,7 @@
|
||||
"price": 290,
|
||||
"weight": "180 г",
|
||||
"pieces": 1,
|
||||
"photo": "demo/images/banquet-fruit.webp",
|
||||
"photo": "",
|
||||
"demo": true,
|
||||
"composition": [
|
||||
"Виноград, Клубника, Голубика"
|
||||
@ -1689,7 +1689,7 @@
|
||||
"price": 120,
|
||||
"weight": "60 г",
|
||||
"pieces": 1,
|
||||
"photo": "demo/images/banquet-bread.webp",
|
||||
"photo": "",
|
||||
"demo": true,
|
||||
"composition": [
|
||||
"Багет, Хлеб ржаной, Масло сливочное, Укроп"
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
"price": 3500,
|
||||
"pieces": 18,
|
||||
"weight": "710 г",
|
||||
"photo": "demo/images/extras-salmon-caprese.webp",
|
||||
"photo": "",
|
||||
"demo": true,
|
||||
"composition": [
|
||||
"Половина стандартного бокса: Канапе с лососем и сливочным сыром",
|
||||
@ -194,7 +194,7 @@
|
||||
"price": 3900,
|
||||
"pieces": 0,
|
||||
"weight": "750 г",
|
||||
"photo": "demo/images/extras-meat-cheese.webp",
|
||||
"photo": "",
|
||||
"demo": true,
|
||||
"composition": [
|
||||
"Половина стандартного бокса: Мясное ассорти с корнишонами",
|
||||
@ -432,7 +432,7 @@
|
||||
"price": 2900,
|
||||
"pieces": 22,
|
||||
"weight": "820 г",
|
||||
"photo": "demo/images/extras-mini-buffet.webp",
|
||||
"photo": "",
|
||||
"demo": true,
|
||||
"composition": [
|
||||
"Половина стандартного бокса: Брускетты с томатами и базиликом",
|
||||
@ -603,7 +603,7 @@
|
||||
"price": 90,
|
||||
"pieces": 1,
|
||||
"weight": "",
|
||||
"photo": "demo/images/extras-water.webp",
|
||||
"photo": "",
|
||||
"composition": [
|
||||
"Одна бутылка 0,5 л"
|
||||
],
|
||||
@ -624,7 +624,7 @@
|
||||
"price": 100,
|
||||
"pieces": 1,
|
||||
"weight": "",
|
||||
"photo": "demo/images/extras-sparkling.webp",
|
||||
"photo": "",
|
||||
"composition": [
|
||||
"Одна бутылка 0,5 л"
|
||||
],
|
||||
@ -645,7 +645,7 @@
|
||||
"price": 220,
|
||||
"pieces": 1,
|
||||
"weight": "",
|
||||
"photo": "demo/images/extras-juice.webp",
|
||||
"photo": "",
|
||||
"composition": [
|
||||
"Одна упаковка 1 л"
|
||||
],
|
||||
@ -666,7 +666,7 @@
|
||||
"price": 280,
|
||||
"pieces": 1,
|
||||
"weight": "",
|
||||
"photo": "demo/images/extras-mors.webp",
|
||||
"photo": "",
|
||||
"composition": [
|
||||
"Одна бутылка готового морса 1 л"
|
||||
],
|
||||
@ -687,7 +687,7 @@
|
||||
"price": 25,
|
||||
"pieces": 1,
|
||||
"weight": "",
|
||||
"photo": "demo/images/extras-plate.webp",
|
||||
"photo": "",
|
||||
"composition": [
|
||||
"Одна сервировочная тарелка"
|
||||
],
|
||||
@ -708,7 +708,7 @@
|
||||
"price": 10,
|
||||
"pieces": 1,
|
||||
"weight": "",
|
||||
"photo": "demo/images/extras-fork.webp",
|
||||
"photo": "",
|
||||
"composition": [
|
||||
"Одна вилка"
|
||||
],
|
||||
@ -729,7 +729,7 @@
|
||||
"price": 15,
|
||||
"pieces": 1,
|
||||
"weight": "",
|
||||
"photo": "demo/images/extras-glass.webp",
|
||||
"photo": "",
|
||||
"composition": [
|
||||
"Один стакан"
|
||||
],
|
||||
@ -750,7 +750,7 @@
|
||||
"price": 10,
|
||||
"pieces": 1,
|
||||
"weight": "",
|
||||
"photo": "demo/images/extras-napkin.webp",
|
||||
"photo": "",
|
||||
"composition": [
|
||||
"Одна бумажная салфетка"
|
||||
],
|
||||
@ -771,7 +771,7 @@
|
||||
"price": 180,
|
||||
"pieces": 1,
|
||||
"weight": "1 кг",
|
||||
"photo": "demo/images/extras-ice.webp",
|
||||
"photo": "",
|
||||
"composition": [
|
||||
"Один пакет пищевого льда, 1 кг"
|
||||
],
|
||||
@ -792,7 +792,7 @@
|
||||
"price": 250,
|
||||
"pieces": 1,
|
||||
"weight": "",
|
||||
"photo": "demo/images/extras-tablecloth.webp",
|
||||
"photo": "",
|
||||
"composition": [
|
||||
"Одна одноразовая скатерть 120 × 180 см"
|
||||
],
|
||||
@ -813,7 +813,7 @@
|
||||
"price": 190,
|
||||
"pieces": 1,
|
||||
"weight": "",
|
||||
"photo": "demo/images/extras-serving-kit.webp",
|
||||
"photo": "",
|
||||
"composition": [
|
||||
"Один комплект одноразовых приборов для подачи"
|
||||
],
|
||||
@ -834,7 +834,7 @@
|
||||
"price": 600,
|
||||
"pieces": 0,
|
||||
"weight": "",
|
||||
"photo": "demo/images/extras-delivery-city.webp",
|
||||
"photo": "",
|
||||
"composition": [
|
||||
"Учебный тариф за один адрес в пределах города."
|
||||
],
|
||||
@ -849,7 +849,7 @@
|
||||
"price": 1200,
|
||||
"pieces": 0,
|
||||
"weight": "",
|
||||
"photo": "demo/images/extras-delivery-outer.webp",
|
||||
"photo": "",
|
||||
"composition": [
|
||||
"Учебный тариф за один адрес в ближайшем пригороде."
|
||||
],
|
||||
|
||||
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 242 KiB |
|
Before Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 297 KiB |
|
Before Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 28 KiB |
@ -1,16 +1,15 @@
|
||||
const CACHE='sun-catering-pwa-v111-20260918-ui-stability-20260919-client-menu-support-bot-training-catalog-banquet-onepage-employee-session-mfa-recovery-promo-entry-support-mail-correct-recipient-order-summary-fix';
|
||||
const CACHE='sun-catering-pwa-v110-20260918-ui-stability-20260919-client-menu-support-bot-training-catalog-banquet-onepage-employee-session-mfa-recovery-promo-entry-support-mail-correct-recipient';
|
||||
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/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/banquet-client-menu.js?v=20260920-onepage',
|
||||
'./core/training-catalog.js?v=20260920-training',
|
||||
'./core/catalog-pricing.js?v=20260919-client-menu','./core/client-menu.css?v=20260919-client-menu',
|
||||
'./vendor/supabase-2.112.4.min.js',
|
||||
`./core/help-center.js?v=${VERSION}`,`./core/help-center.css?v=${VERSION}`,`./help/knowledge-v1.json?v=${VERSION}`,
|
||||
'./','./index.html',`./core/mobile-order.js?v=${VERSION}`,`./core/proposal-layout.js?v=${VERSION}`,'./fonts/Manrope.ttf','./fonts/PlayfairDisplay.ttf','./fonts/PlayfairDisplay-Italic.ttf',`./core/trial-demo.js?v=${VERSION}`,`./core/cloud-transport.js?v=${VERSION}`,`./core/banquet-menu.js?v=${VERSION}`,`./core/access-policy.js?v=${VERSION}`,`./core/import-archive.js?v=${VERSION}`,`./core/company-branding.js?v=${VERSION}`,`./core/signature-offer-pdf-v18.js?v=${VERSION}`,`./core/brand-theme.js?v=${VERSION}`,
|
||||
`./core/sun-safe.js?v=${VERSION}`,'./core/performance.js?v=20260922-order-summary-fix',`./core/account-center-v1780.js?v=${VERSION}`,`./core/login-signature-v1776.js?v=${VERSION}`,`./core/login-signature-v1776.css?v=${VERSION}`,`./core/data-layer-v1773.js?v=${VERSION}`,`./core/server-automation-v1770.js?v=${VERSION}`,`./core/hotfix-v1763.js?v=${VERSION}`,`./core/ops-ux-v1762.js?v=${VERSION}`,`./core/ux-fixes-v1764.js?v=${VERSION}`,`./core/pdf-engine.js?v=${VERSION}`,`./core/classic-offer-pdf-v1767.js?v=${VERSION}`,`./core/developer-console-v1768.js?v=${VERSION}`,`./core/offer-workspace-v1769.js?v=${VERSION}`,`./core/auth-security-v1774.js?v=${VERSION}`,'./core/order-enhancements-v1775.js?v=20260922-order-summary-fix',`./legacy/bootstrap.js?v=${VERSION}`,`./app-runtime.js?v=${VERSION}`,
|
||||
`./core/sun-safe.js?v=${VERSION}`,`./core/performance.js?v=${VERSION}`,`./core/account-center-v1780.js?v=${VERSION}`,`./core/login-signature-v1776.js?v=${VERSION}`,`./core/login-signature-v1776.css?v=${VERSION}`,`./core/data-layer-v1773.js?v=${VERSION}`,`./core/server-automation-v1770.js?v=${VERSION}`,`./core/hotfix-v1763.js?v=${VERSION}`,`./core/ops-ux-v1762.js?v=${VERSION}`,`./core/ux-fixes-v1764.js?v=${VERSION}`,`./core/pdf-engine.js?v=${VERSION}`,`./core/classic-offer-pdf-v1767.js?v=${VERSION}`,`./core/developer-console-v1768.js?v=${VERSION}`,`./core/offer-workspace-v1769.js?v=${VERSION}`,`./core/auth-security-v1774.js?v=${VERSION}`,`./core/order-enhancements-v1775.js?v=${VERSION}`,`./legacy/bootstrap.js?v=${VERSION}`,`./app-runtime.js?v=${VERSION}`,
|
||||
'./offer-gallery/001.jpg','./offer-gallery/002.jpg',
|
||||
'./catalog/001.jpg','./catalog/002.jpg','./catalog/003.jpg',
|
||||
'./sun-logo.png','./caterium-login-logo.png','./caterium-mark-light.svg','./pwa-icon-192.png','./pwa-icon-512.png','./manifest.webmanifest',
|
||||
@ -18,7 +17,7 @@ const CORE=[
|
||||
];
|
||||
const CRITICAL_FRESH=new Set([
|
||||
'/core/support-form.js',
|
||||
'/core/trial-promo-developer-v181.js','/core/single-item-pdf.js','/core/hotfix-v1763.js',
|
||||
'/core/trial-promo-developer-v181.js','/core/hotfix-v1763.js',
|
||||
'/core/banquet-client-menu.js',
|
||||
'/core/training-catalog.js',
|
||||
'/core/catalog-pricing.js','/core/client-menu.css',
|
||||
|
||||
@ -1,169 +0,0 @@
|
||||
-- Caterium v17.9.1
|
||||
-- Public owners may register without a promo code. No-promo workspaces are
|
||||
-- permanently limited to the Basic plan. A valid promo keeps the existing
|
||||
-- server-enforced trial plan and duration.
|
||||
|
||||
begin;
|
||||
|
||||
create or replace function public.caterium_public_signup_policy()
|
||||
returns jsonb
|
||||
language sql
|
||||
stable
|
||||
security definer
|
||||
set search_path = public
|
||||
as $function$
|
||||
select jsonb_build_object(
|
||||
'promo_optional', true,
|
||||
'default_plan', 'basic'
|
||||
);
|
||||
$function$;
|
||||
|
||||
create or replace function public.sun_create_workspace(p_name text default 'Новая компания'::text)
|
||||
returns uuid
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public, auth, pg_temp
|
||||
as $function$
|
||||
declare
|
||||
v_user uuid:=auth.uid();
|
||||
v_workspace uuid;
|
||||
v_name text:='Новая компания';
|
||||
v_email text;
|
||||
v_profile jsonb;
|
||||
v_storage jsonb;
|
||||
v_code text;
|
||||
v_has_promo boolean:=false;
|
||||
v_promo public.caterium_trial_promos%rowtype;
|
||||
v_trial_end timestamptz;
|
||||
begin
|
||||
if v_user is null then raise exception 'Authentication required'; end if;
|
||||
if not exists(select 1 from auth.users where id=v_user and email_confirmed_at is not null) then
|
||||
raise exception 'Подтвердите email';
|
||||
end if;
|
||||
if exists(select 1 from public.sun_workspace_members where user_id=v_user and is_active=true) then
|
||||
raise exception 'Аккаунт уже относится к компании';
|
||||
end if;
|
||||
|
||||
select lower(email),public.caterium_normalize_trial_code(raw_user_meta_data->>'promo_code')
|
||||
into v_email,v_code
|
||||
from auth.users
|
||||
where id=v_user;
|
||||
|
||||
v_has_promo:=coalesce(v_code,'')<>'';
|
||||
|
||||
if v_has_promo then
|
||||
if exists(select 1 from public.caterium_trial_redemptions where user_id=v_user) then
|
||||
raise exception 'Пробный период для этого аккаунта уже использован';
|
||||
end if;
|
||||
|
||||
select * into v_promo
|
||||
from public.caterium_trial_promos
|
||||
where code=v_code
|
||||
for update;
|
||||
|
||||
if not found then raise exception 'Промокод не найден'; end if;
|
||||
if not v_promo.is_active then raise exception 'Промокод отключён'; end if;
|
||||
if v_promo.valid_until is not null and v_promo.valid_until<=now() then raise exception 'Срок действия промокода истёк'; end if;
|
||||
if v_promo.use_count>=v_promo.max_uses then raise exception 'Промокод уже использован'; end if;
|
||||
if v_promo.client_email is not null and lower(v_promo.client_email)<>v_email then raise exception 'Промокод предназначен для другого email'; end if;
|
||||
|
||||
v_trial_end:=now()+make_interval(days=>v_promo.trial_days);
|
||||
end if;
|
||||
|
||||
insert into public.sun_workspaces(name,created_by)
|
||||
values(v_name,v_user)
|
||||
returning id into v_workspace;
|
||||
|
||||
insert into public.sun_workspace_members(workspace_id,user_id,role,display_name,is_active,permissions)
|
||||
values(
|
||||
v_workspace,
|
||||
v_user,
|
||||
'admin',
|
||||
coalesce((select raw_user_meta_data->>'name' from auth.users where id=v_user),split_part(coalesce(v_email,'Администратор'),'@',1)),
|
||||
true,
|
||||
public.sun_role_default_permissions('admin')
|
||||
);
|
||||
|
||||
v_profile:=jsonb_build_object(
|
||||
'name','','shortName','','logo','','tagline','','city','','phone','','email',coalesce(v_email,''),
|
||||
'website','','address','','legalName','','inn','','kpp','','ogrn','','legalAddress','',
|
||||
'bank','','bik','','account','','corrAccount','','legacyLocked',false
|
||||
);
|
||||
v_storage:=jsonb_build_object('sunCompanyProfileV1',v_profile::text);
|
||||
|
||||
insert into public.sun_app_state(workspace_id,payload,client_id)
|
||||
values(v_workspace,jsonb_build_object('format','sun-cloud-v2','version',2,'storage',v_storage),'bootstrap')
|
||||
on conflict(workspace_id) do nothing;
|
||||
|
||||
if v_has_promo then
|
||||
insert into public.sun_workspace_subscriptions(
|
||||
workspace_id,plan_id,status,trial_started_at,trial_ends_at,current_period_start,current_period_end,grace_until,source,note
|
||||
)
|
||||
values(
|
||||
v_workspace,v_promo.plan_id,'trialing',now(),v_trial_end,null,null,v_trial_end+interval '7 days','promo_trial','Trial by promo '||v_promo.code
|
||||
)
|
||||
on conflict(workspace_id) do update
|
||||
set plan_id=excluded.plan_id,
|
||||
status=excluded.status,
|
||||
trial_started_at=excluded.trial_started_at,
|
||||
trial_ends_at=excluded.trial_ends_at,
|
||||
current_period_start=null,
|
||||
current_period_end=null,
|
||||
grace_until=excluded.grace_until,
|
||||
source=excluded.source,
|
||||
note=excluded.note,
|
||||
updated_at=now();
|
||||
|
||||
insert into public.caterium_trial_redemptions(promo_id,workspace_id,user_id,email,trial_ends_at)
|
||||
values(v_promo.id,v_workspace,v_user,v_email,v_trial_end);
|
||||
|
||||
update public.caterium_trial_promos
|
||||
set use_count=use_count+1,updated_at=now()
|
||||
where id=v_promo.id;
|
||||
|
||||
insert into public.sun_platform_audit_events(actor_user_id,action,target_workspace_id,target_user_id,details)
|
||||
values(
|
||||
v_user,'trial_promo.redeem',v_workspace,v_user,
|
||||
jsonb_build_object('promo_id',v_promo.id,'code',v_promo.code,'trial_days',v_promo.trial_days,'plan',v_promo.plan_id)
|
||||
);
|
||||
else
|
||||
insert into public.sun_workspace_subscriptions(
|
||||
workspace_id,plan_id,status,trial_started_at,trial_ends_at,current_period_start,current_period_end,grace_until,source,note
|
||||
)
|
||||
values(
|
||||
v_workspace,'basic','active',null,null,now(),null,null,'public_signup_basic','Basic access without promo'
|
||||
)
|
||||
on conflict(workspace_id) do update
|
||||
set plan_id='basic',
|
||||
status='active',
|
||||
trial_started_at=null,
|
||||
trial_ends_at=null,
|
||||
current_period_start=now(),
|
||||
current_period_end=null,
|
||||
grace_until=null,
|
||||
source='public_signup_basic',
|
||||
note='Basic access without promo',
|
||||
updated_at=now();
|
||||
|
||||
insert into public.sun_platform_audit_events(actor_user_id,action,target_workspace_id,target_user_id,details)
|
||||
values(
|
||||
v_user,'public_signup.basic',v_workspace,v_user,
|
||||
jsonb_build_object('plan','basic','promo',false)
|
||||
);
|
||||
end if;
|
||||
|
||||
update auth.users
|
||||
set raw_user_meta_data=coalesce(raw_user_meta_data,'{}'::jsonb)-'promo_code'-'company_name'
|
||||
where id=v_user;
|
||||
|
||||
return v_workspace;
|
||||
end;
|
||||
$function$;
|
||||
|
||||
revoke all on function public.caterium_public_signup_policy() from public,anon,authenticated;
|
||||
grant execute on function public.caterium_public_signup_policy() to anon,authenticated;
|
||||
|
||||
revoke all on function public.sun_create_workspace(text) from public,anon;
|
||||
grant execute on function public.sun_create_workspace(text) to authenticated,service_role;
|
||||
|
||||
commit;
|
||||
@ -1,18 +0,0 @@
|
||||
-- Caterium v17.9.1
|
||||
-- The public signup policy returns constants only and does not need elevated privileges.
|
||||
|
||||
create or replace function public.caterium_public_signup_policy()
|
||||
returns jsonb
|
||||
language sql
|
||||
stable
|
||||
security invoker
|
||||
set search_path = public
|
||||
as $function$
|
||||
select jsonb_build_object(
|
||||
'promo_optional', true,
|
||||
'default_plan', 'basic'
|
||||
);
|
||||
$function$;
|
||||
|
||||
revoke all on function public.caterium_public_signup_policy() from public,anon,authenticated;
|
||||
grant execute on function public.caterium_public_signup_policy() to anon,authenticated;
|
||||
@ -1,177 +0,0 @@
|
||||
-- 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';
|
||||
@ -3,7 +3,6 @@ import fs from 'node:fs';
|
||||
const auth=fs.readFileSync('public/core/auth-security-v1774.js','utf8');
|
||||
const perf=fs.readFileSync('public/core/performance.js','utf8');
|
||||
const migration=fs.readFileSync('ops/sql/SUPABASE-V17.7.4-AUTH-EMAIL-VERIFICATION.sql','utf8');
|
||||
const basicSignup=fs.readFileSync('ops/sql/SUPABASE-V17.9.1-PUBLIC-BASIC-SIGNUP.sql','utf8');
|
||||
let bad=0;
|
||||
const check=(ok,msg)=>{console.log(`${ok?'OK':'FAIL'}: ${msg}`);if(!ok)bad++;};
|
||||
|
||||
@ -12,13 +11,6 @@ check(auth.includes('showPublicConfirmation')&&auth.includes('Подтверди
|
||||
check(auth.includes('showInviteConfirmation'),'invite signup waits for email confirmation');
|
||||
check(!auth.includes("registration_source:'caterium_public_signup'")&&!auth.includes("registration_source:'caterium_invite_signup'"),'security guard does not trust client-controlled registration_source');
|
||||
check(auth.includes("result.data?.session")&&auth.includes('signOutUnsafeSession'),'guard fails closed if server still auto-confirms');
|
||||
check(auth.includes('Промокод — необязательно')&&auth.includes('caterium_public_signup_policy'),'registration supports an optional promo only after the server policy confirms it');
|
||||
check(auth.includes("return {valid:true,plan:'basic',withoutPromo:true}"),'no-promo signup resolves to Basic in the client guard');
|
||||
check(auth.includes('signupPolicyPromise')&&auth.includes('signupPolicy.checked&&!force'),'public signup policy lookup is cached and single-flight');
|
||||
check(auth.includes("pending?.email")&&!auth.includes("!pending?.promoCode"),'owner onboarding no longer requires a promo code');
|
||||
check(basicSignup.includes("'basic','active'")&&basicSignup.includes("'public_signup_basic'"),'server assigns permanent active Basic access to no-promo owners');
|
||||
check(basicSignup.includes("if v_has_promo then")&&basicSignup.includes("'promo_trial'"),'valid promo path still assigns the promo plan and trial period');
|
||||
check(basicSignup.includes('caterium_public_signup_policy')&&basicSignup.includes("'promo_optional', true"),'server advertises optional promo capability only with the matching migration');
|
||||
check(perf.includes('loadAuthSecurity')&&perf.includes('auth-security-v1774.js'),'auth security guard is loaded by performance core');
|
||||
check(migration.includes('drop trigger if exists caterium_autoconfirm_signup_v25 on auth.users'),'autoconfirm users trigger is removed');
|
||||
check(migration.includes('drop trigger if exists caterium_autoverify_identity_v25 on auth.identities'),'autoverify identity trigger is removed');
|
||||
|
||||
@ -9,18 +9,12 @@ 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('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('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(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');
|
||||
@ -45,7 +39,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('v111-20260918-ui-stability'),'release manifest points to current PWA cache');
|
||||
check(String(releaseManifest.pwaCache||'').includes('v110-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');
|
||||
@ -72,7 +66,7 @@ check(offerWorkspace.includes('SunClassicOfferPDFV1767')&&offerWorkspace.include
|
||||
check(releaseManifest.offerWorkspaceTabs===true&&releaseManifest.offerTemplatesSeparateTab===true&&releaseManifest.offerTwoCustomGalleryPhotos===true,'release manifest records offer workspace changes');
|
||||
check(pkg.version==='17.7.3','package version is v17.7.3');
|
||||
check(index.includes('20260918-ui-stability'),'index cache bust is v17.7.3');
|
||||
check(sw.includes('20260918-ui-stability')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js'),'PWA caches v17.7.3 client foundation modules');
|
||||
check(sw.includes('v110-20260918-ui-stability')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js'),'PWA caches v17.7.3 client foundation modules');
|
||||
check(fs.existsSync(path.join(root,'public/core/data-layer-v1773.js'))&&fs.existsSync(path.join(root,'public/core/server-automation-v1770.js')),'data layer and server automation modules exist');
|
||||
check(ux.includes('CateriumServerAutomationV1770?.enabled'),'cloud browser auto completion is disabled when server automation is active');
|
||||
check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes("v17.7.3 Clients Server Read"),'stability logger reports v17.7.3');
|
||||
|
||||
@ -44,11 +44,7 @@ test('training is opt-in in an existing paid profile, adds complete examples, hi
|
||||
|
||||
test('TTKs, trial order and preparation keep their complete records after hiding and restore without resetting edits',async({page})=>{
|
||||
await boot(page);await toggle(page,true);
|
||||
await expect(page.locator('#ctTrialDemo')).toBeVisible();
|
||||
// The order screen only shows a one-line notice; its link leads back to the switch and the guide.
|
||||
await nav(page).getByRole('button',{name:'Новый заказ',exact:true}).click();
|
||||
await expect(page.locator('#ctTrainingNotice')).toContainText('Включён учебный режим');await expect(page.locator('#ctTrialDemo')).toBeHidden();
|
||||
await page.locator('#ctTrainingNotice button').click();await expect(page.locator('#ctTrialDemo')).toBeVisible();await expect(setting(page)).toBeFocused();
|
||||
await nav(page).getByRole('button',{name:'Новый заказ',exact:true}).click();await expect(page.locator('#ctTrialDemo')).toBeVisible();
|
||||
await page.evaluate(()=>{window.ctGuideEvents=[];const card=document.getElementById('ctTrialDemo');new MutationObserver(rs=>{ctGuideEvents.push(...rs.map(r=>({type:r.type,attribute:r.attributeName,open:card.querySelector('details')?.open,signature:card.dataset.signature})));}).observe(card,{childList:true,subtree:true,attributes:true,attributeFilter:['open','data-signature']});});
|
||||
await page.locator('#ctTrialDemo summary').click();await page.locator('[data-demo-ttk]').click();await expect(page.locator('#ctDemoTtk')).toContainText('Брутто');await expect(page.locator('#ctDemoTtk')).toContainText('840 г');await page.getByRole('button',{name:'Закрыть ТТК',exact:true}).click();
|
||||
await page.locator('[data-demo-order]').click();await expect(page.locator('#ctDemoMessage')).toContainText('9 700 ₽');
|
||||
|
||||
@ -26,15 +26,10 @@ async function ready(page){
|
||||
for(const [key,value] of Object.entries({sunBoxes:d.boxes,sunStock:d.stock,sunSuppliers:d.suppliers,sunStockMoves:[],sunOrders:[],sunTrialDemoV1:{version:1,batch:d.batch,scenario:d.scenario,trainingCatalog:{workspaceId:'test',profiles:{test:true}}}}))localStorage.setItem(key,JSON.stringify(value));
|
||||
boxes=structuredClone(d.boxes);orders=[];window.dispatchEvent(new Event('sun:cloud-state-applied'));window.dispatchEvent(new Event('sun:cloud-permissions-changed'));window.render();window.CateriumTrialDemo.render();
|
||||
},completeDemo);
|
||||
await expect(page.locator('#ctTrainingNotice')).toBeVisible();
|
||||
}
|
||||
// The trial guide (order, stock, TTK) lives in Settings; the order screen only shows a notice.
|
||||
async function guide(page){
|
||||
await page.locator('header nav').getByRole('button',{name:'Настройки',exact:true}).click();
|
||||
await expect(page.locator('#ctTrialDemo')).toBeVisible();
|
||||
}
|
||||
test('demo TTK, order, shortages, receipt and one-time write-off work in the full app',async({page})=>{
|
||||
await ready(page);await guide(page);
|
||||
await ready(page);
|
||||
await page.locator('#ctTrialDemo summary').click();await page.locator('[data-demo-ttk]').click();
|
||||
await expect(page.locator('#ctDemoTtk')).toBeVisible();await expect(page.locator('#ctDemoTtk')).toContainText('840 г');await expect(page.locator('#ctDemoTtk')).toContainText('Брутто');
|
||||
expect(await page.locator('#ctDemoTtk').evaluate(el=>el.getBoundingClientRect().width<=innerWidth)).toBe(true);
|
||||
|
||||