Compare commits

...

36 Commits

Author SHA1 Message Date
pavlov346346-source
c985714ad6 ci(gitea): run e2e tests with a single worker, VPS can't handle parallel chromium+webkit
Some checks failed
Caterium QA / qa (push) Failing after 58m54s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 16:42:41 +03:00
pavlov346346-source
3c15562e08 ci(gitea): install php-curl, proxy-http tests need CURLOPT_* constants
Some checks are pending
Caterium QA / qa (push) Waiting to run
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:29:27 +03:00
pavlov346346-source
66ab47de72 ci(gitea): install php-cli, act_runner image doesn't ship with php
Some checks failed
Caterium QA / qa (push) Failing after 1m33s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:27:17 +03:00
pavlov346346-source
893f2f6a67 ci(gitea): don't fail pipeline on npm audit, npmmirror.com lacks the advisories endpoint
Some checks failed
Caterium QA / qa (push) Failing after 1m20s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:24:50 +03:00
pavlov346346-source
e5e4e4208d ci(gitea): route npm through npmmirror.com, registry.npmjs.org unreachable from this host
Some checks failed
Caterium QA / qa (push) Failing after 1m12s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:13:54 +03:00
pavlov346346-source
8f5fff421a ci: add Gitea Actions workflow mirroring GitHub qa.yml
Some checks failed
Caterium QA / qa (push) Failing after 8m6s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 13:33:13 +03:00
pavlov346346-source
ab6d41ba0e feat: server-side order audit log for company owners
Some checks failed
Caterium QA / qa (push) Has been cancelled
Owners/admins can now see, on the Аккаунт settings tab, a
tamper-proof journal of who created, edited or deleted each order
and when — including employees who have orders.* permissions. It is
written from inside sun_save_app_state itself (which already
diffs orders server-side for permission checks), so it can't be
spoofed or wiped by the client, unlike the old per-browser
'История изменений' list which only covered the current device and
had a 'Clear history' button anyone could press.

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 18:48:20 +03:00
pavlov346346-source
3aa268907e fix: always show the per-guest price in client banquet menu
Per-item prices next to each dish were already removed; the
per-guest price footer is no longer an optional checkbox — it now
always renders, since it should never be hidden, only the per-dish
prices were meant to be removed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 18:00:52 +03:00
pavlov346346-source
ab2bc5f47e Verify single item PDF release wiring 2026-09-22 22:58:19 +08:00
pavlov346346-source
6561a4e43e Check single item PDF module syntax 2026-09-22 22:58:14 +08:00
pavlov346346-source
41ebe89f8f Verify single item PDF module in production 2026-09-22 22:57:44 +08:00
pavlov346346-source
dee3252dc8 Cache single item PDF module 2026-09-22 22:57:41 +08:00
pavlov346346-source
fb8f7c351a Load single item PDF export 2026-09-22 22:57:37 +08:00
pavlov346346-source
4265a88c34 Add single catalog item PDF export 2026-09-22 22:57:23 +08:00
pavlov346346-source
923ea64650 feat: hide per-item prices in client banquet menu, add 3 layout themes
The one-page banquet menu PDF for clients no longer prints a price
next to each dish, and the footer no longer shows a grand total —
only the price per guest is shown, so the document can't be read
as a per-dish price list. Added a theme selector (Золото/Ночь/
Минимал) with three visually distinct color/typography treatments
for the same one-page layout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 17:49:36 +03:00
pavlov346346-source
a6bd7f911b fix: remove duplicate delivery/total line in order cost summary
order-enhancements-v1775.js appended its own Доставка/Итого
paragraphs to .order-summary even after app-runtime.js's
ensureDiscountUI() already renders a full breakdown (positions,
discount, promo, delivery, total, prepayment, balance) — resulting
in a duplicate Доставка line at the bottom of the order details
cost panel. Now it skips the extra append when the richer summary
UI is present.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 17:32:09 +03:00
pavlov346346-source
40b5c146c2 feat: add photos for the 34 training-catalog items that had none
The banquet menu (18 dishes) and extras/supplies/delivery items (16)
in the training catalog were seeded with photo:'', which the catalog
tiles render as a plain red placeholder square. Sourced a real photo
for each item (mostly Pexels, free license, picked and visually
reviewed one by one to avoid mismatches -- several first search hits
were wrong: a live chicken for "chicken caesar salad", a person in
frame, branded bottles/cups) and two AI-generated (banquet-caprese,
banquet-roastbeef). Converted to square 1024x1024 WebP with sharp.

Wires the paths through the actual source of truth: ops/demo/trial-
banquet-data.mjs and trial-extras-data.mjs now set photo to the real
path, then ops/demo/build-trial-*.mjs regenerated public/demo/*.json
and the matching Supabase seed-function migrations, so the client's
static JSON fetch and the server-side company-provisioning function
stay in sync.

Bumped the demo-catalog fetch's own cache-busting query string in
training-catalog.js (and its script-tag/precache version), since it's
fetched by the client with a separate version from the outer script
tags -- the same stale-cache class of bug fixed earlier this session.

Verified: all 34 referenced files resolve (200) and decode as real
1024x1024 images; confirmed via the actual rendered catalog tiles
(#tiles img[src]) that the extras category -- where the user's
red-square screenshot was taken -- now serves the real photo paths.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 17:00:45 +03:00
pavlov346346-source
72d8ca6c1a Test cached signup policy lookup 2026-09-22 19:25:04 +08:00
pavlov346346-source
8e0da59b09 Cache public signup policy lookup 2026-09-22 19:24:45 +08:00
pavlov346346-source
646edfefde Track public signup policy security migration 2026-09-22 19:06:05 +08:00
pavlov346346-source
403676ab69 Harden public signup policy function 2026-09-22 19:06:02 +08:00
pavlov346346-source
a7252e4065 Remove obsolete database-secret migration workflow 2026-09-22 19:04:39 +08:00
pavlov346346-source
16308a35c4 Remove superseded Basic signup migration timestamp 2026-09-22 19:04:36 +08:00
pavlov346346-source
e6376c80d4 Align Basic signup migration with Supabase history 2026-09-22 19:04:33 +08:00
pavlov346346-source
7c05b63846 Support existing database secret names for Basic signup migration 2026-09-22 18:45:27 +08:00
pavlov346346-source
7ed7fdad79 Apply Basic signup migration safely 2026-09-22 18:43:52 +08:00
pavlov346346-source
941a568f4c Verify auth guard in production deploy 2026-09-22 18:34:19 +08:00
pavlov346346-source
111760f9f2 Load Basic signup auth release 2026-09-22 18:34:15 +08:00
pavlov346346-source
ae44b3eb6c Refresh auth module for Basic signup 2026-09-22 18:34:09 +08:00
pavlov346346-source
ca402418c4 Test Basic signup without promo 2026-09-22 18:33:57 +08:00
pavlov346346-source
b103d4aa24 Add public Basic signup migration 2026-09-22 18:33:24 +08:00
pavlov346346-source
c7aa7371d0 Add server policy for Basic signup without promo 2026-09-22 18:33:21 +08:00
pavlov346346-source
3d712eb19a Allow Basic registration without promo code 2026-09-22 18:32:47 +08:00
pavlov346346-source
10415fd539 Fix order deletion, line removal and Telegram provenance in documents 2026-09-22 16:05:38 +08:00
pavlov346346-source
32d6eb86c8 feat: replace the large training banner with a one-line notice
The big "Учебный каталог включён" card with its buttons sat on top of the
order screen. While training mode is on, the order screen now shows only
"Включён учебный режим · отключить в настройках"; the link opens Settings
at the training switch. The guide (trial order, stock, purchasing, TTK)
moves into the "Обучение и знакомство" settings card, so nothing is lost.
The invitation card for companies with an empty catalog is unchanged.

Specs updated for the new placement, plus a check that the notice link
lands on (and focuses) the switch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 17:36:00 +03:00
64 changed files with 1393 additions and 349 deletions

78
.gitea/workflows/qa.yml Normal file
View File

@ -0,0 +1,78 @@
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

View File

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

View File

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

View File

@ -0,0 +1,39 @@
{
"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"}
]
}

View File

@ -68,7 +68,7 @@ const recipes=[
export const banquet=recipes.map(([key,name,catalogSection,price,quantities,allergens,steps,waterGrams=0],index)=>{ 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 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)); 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:true, return {id:`demo-banquet-v1-${key}`,name,category:6,catalogSection,price,weight:`${outputGrams} г`,pieces:1,photo:`demo/images/banquet-${key}.webp`,demo:true,
composition:[rows.map(r=>r.name).join(', ')],ingredients:rows.map(r=>[r.name,r.gross,r.unit]), 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, 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, ingredientCost:Math.round(rows.reduce((sum,r)=>sum+r.gross*r.unitCost,0)*100)/100,

View File

@ -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(); 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)} 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)); 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:true, return {id:`demo-extras-v1-${key}`,name,category:5,catalogSection:'Демонстрационные премиум-сеты',price,pieces,weight:`${outputGrams} г`,photo:`demo/images/extras-${key}.webp`,demo:true,
composition:sources.map(b=>'Половина стандартного бокса: '+b.name),ingredients:rows.map(r=>[r.name,r.gross,r.unit]), 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, ttk:{number:`ДЕМО-П${index+1}`,basis:'На 1 готовый премиум-сет',outputGrams,pieces,rows,
steps:['Приготовить две мини-подборки в половинном объёме по указанным нормам.',...sources.flatMap(b=>b.ttk.steps.map(s=>b.name+': '+s)),'Уложить обе подборки в одну общую коробку.'], steps:['Приготовить две мини-подборки в половинном объёме по указанным нормам.',...sources.flatMap(b=>b.ttk.steps.map(s=>b.name+': '+s)),'Уложить обе подборки в одну общую коробку.'],
@ -31,6 +31,6 @@ const specs=[
['serving-kit',2,'Набор для подачи: щипцы и лопатка',190,'Демо: набор щипцы и лопатка',85,'шт.','pack','Один комплект одноразовых приборов для подачи'] ['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 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:'',composition:[description],ingredients:[[productName,1,unit]],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:`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:'',composition:[note],ingredients:[],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}));
export const extrasDemo={version:1,items:[...premium,...packaged,...delivery],stock}; export const extrasDemo={version:1,items:[...premium,...packaged,...delivery],stock};

View File

@ -0,0 +1,169 @@
-- 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;

View File

@ -4,7 +4,7 @@
"version": "17.7.3", "version": "17.7.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"check:syntax": "node --check public/core/catalog-pricing.js && node --check public/app-runtime.js && node --check public/service-worker.js && node --check public/legacy/bootstrap.js && node --check public/core/sun-safe.js && node --check public/core/account-center-v1780.js && node --check public/core/performance.js && node --check public/core/auth-security-v1774.js && node --check public/core/trial-promo-developer-v181.js && node --check public/core/order-enhancements-v1775.js && node --check public/core/data-layer-v1773.js && node --check public/core/server-automation-v1770.js && node --check public/core/hotfix-v1763.js && node --check public/core/ops-ux-v1762.js && node --check public/core/ux-fixes-v1764.js && node --check public/core/pdf-engine.js && node --check public/core/classic-offer-pdf-v1767.js && node --check public/core/signature-offer-pdf-v18.js && node --check public/core/developer-console-v1768.js && node --check public/core/offer-workspace-v1769.js && node --check public/core/brand-theme.js && node --check public/core/company-branding.js && node --check public/core/import-archive.js && node --check public/core/access-policy.js && node --check public/core/banquet-menu.js && node --check public/core/cloud-transport.js && node --check public/core/trial-demo.js && node --check public/core/proposal-layout.js && node --check public/core/mobile-order.js && node --check public/core/help-center.js", "check:syntax": "node --check public/core/catalog-pricing.js && node --check public/app-runtime.js && node --check public/service-worker.js && node --check public/legacy/bootstrap.js && node --check public/core/sun-safe.js && node --check public/core/account-center-v1780.js && node --check public/core/performance.js && node --check public/core/single-item-pdf.js && node --check public/core/auth-security-v1774.js && node --check public/core/trial-promo-developer-v181.js && node --check public/core/order-enhancements-v1775.js && node --check public/core/data-layer-v1773.js && node --check public/core/server-automation-v1770.js && node --check public/core/hotfix-v1763.js && node --check public/core/ops-ux-v1762.js && node --check public/core/ux-fixes-v1764.js && node --check public/core/pdf-engine.js && node --check public/core/classic-offer-pdf-v1767.js && node --check public/core/signature-offer-pdf-v18.js && node --check public/core/developer-console-v1768.js && node --check public/core/offer-workspace-v1769.js && node --check public/core/brand-theme.js && node --check public/core/company-branding.js && node --check public/core/import-archive.js && node --check public/core/access-policy.js && node --check public/core/banquet-menu.js && node --check public/core/cloud-transport.js && node --check public/core/trial-demo.js && node --check public/core/proposal-layout.js && node --check public/core/mobile-order.js && node --check public/core/help-center.js",
"test:static": "node tests/static-security.mjs && node tests/auth-security-v1774.mjs && node tests/employee-create-v1774.mjs && node tests/html-integrity-v1774.mjs && node tests/edge-security-v1774.mjs && node tests/branding-v1774.mjs && node tests/order-enhancements-v1775.mjs && node tests/client-sync-v1780.mjs", "test:static": "node tests/static-security.mjs && node tests/auth-security-v1774.mjs && node tests/employee-create-v1774.mjs && node tests/html-integrity-v1774.mjs && node tests/edge-security-v1774.mjs && node tests/branding-v1774.mjs && node tests/order-enhancements-v1775.mjs && node tests/client-sync-v1780.mjs",
"check:release": "node tests/release-check.mjs", "check:release": "node tests/release-check.mjs",
"check:deploy": "npm run check:syntax && npm run test:static && npm run check:release && node tests/backend-cutover.mjs && npm run test:db", "check:deploy": "npm run check:syntax && npm run test:static && npm run check:release && node tests/backend-cutover.mjs && npm run test:db",

View File

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

View File

@ -1,7 +1,7 @@
(()=>{ (()=>{
'use strict'; 'use strict';
const VERSION='17.8.5-single-session-login'; const VERSION='17.9.1-public-basic-signup';
const PENDING_REGISTRATION_KEY='sunPendingRegistrationV23'; const PENDING_REGISTRATION_KEY='sunPendingRegistrationV23';
let busy=false; let busy=false;
@ -9,7 +9,26 @@
const cloud=()=>window.SunCloudV2||null; const cloud=()=>window.SunCloudV2||null;
const client=()=>cloud()?.getClient?.()||null; const client=()=>cloud()?.getClient?.()||null;
const esc=value=>window.SunSafe?.escapeHTML?window.SunSafe.escapeHTML(String(value??'')):String(value??''); 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 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(_){}} 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(_){}}
@ -23,16 +42,20 @@
const fields=$('sunGateRegisterFieldsV27');if(!fields)return; const fields=$('sunGateRegisterFieldsV27');if(!fields)return;
const company=$('sunGateCompanyV3');if(company?.parentElement)company.parentElement.remove(); const company=$('sunGateCompanyV3');if(company?.parentElement)company.parentElement.remove();
if(!$('sunGatePromoV181')){ if(!$('sunGatePromoV181')){
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>'; 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>';
fields.appendChild(label); fields.appendChild(label);
const input=$('sunGatePromoV181');input?.addEventListener('input',()=>{input.value=input.value.toUpperCase().replace(/\s+/g,'');}); 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(!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='Не удалось проверить промокод.';}}); 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='Не удалось проверить промокод.';}});
} }
void refreshSignupPolicy().then(renderPromoPolicy);
} }
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;} 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;
}
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});} 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){ async function publicLogin(gate){
@ -53,11 +76,12 @@
if(busy)return;const c=client();if(!c){setError(gate,'Облачный сервис не подключён.');return;} 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(); 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;} 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,'Проверяю промокод…'); busy=true;const button=$('sunGateSubmitV3');if(button)button.disabled=true;setError(gate,promoCode?'Проверяю промокод…':'Проверяю Базовый тариф…');
try{ try{
await validatePromo(c,email,promoCode);savePendingRegistration(email,promoCode);setError(gate,'Создаю аккаунт…'); 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 existing=await c.auth.signInWithPassword({email,password});if(!existing.error&&existing.data?.session){location.reload();return;}
const result=await c.auth.signUp({email,password,options:{emailRedirectTo:redirectUrl(),data:{promo_code:promoCode}}});if(result.error)throw result.error; 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;
if(result.data?.session){await signOutUnsafeSession(c);throw new Error('Подтверждение email не включено на сервере. Регистрация остановлена.');} if(result.data?.session){await signOutUnsafeSession(c);throw new Error('Подтверждение email не включено на сервере. Регистрация остановлена.');}
showPublicConfirmation(gate,email); showPublicConfirmation(gate,email);
}catch(error){clearPendingRegistration();setError(gate,friendlySignupError(error));if(button)button.disabled=false;}finally{busy=false;} }catch(error){clearPendingRegistration();setError(gate,friendlySignupError(error));if(button)button.disabled=false;}finally{busy=false;}
@ -73,11 +97,11 @@
} }
async function finishOwnerOnboarding(gate){ async function finishOwnerOnboarding(gate){
if(busy)return false;const c=client(),api=cloud(),pending=pendingRegistration();if(!c||!api||!pending?.promoCode)return false; if(busy)return false;const c=client(),api=cloud(),pending=pendingRegistration();if(!c||!api||!pending?.email)return false;
const session=api.getSession?.();const email=String(session?.user?.email||'').trim().toLowerCase();if(!email||email!==String(pending.email||'').toLowerCase())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,'Подключаю рабочую базу…'); busy=true;setError(gate,'Подключаю рабочую базу…');
try{ try{
const user=session.user;const metadata={...(user.user_metadata||{}),promo_code:pending.promoCode}; const user=session.user;const metadata={...(user.user_metadata||{}),promo_code:pending.promoCode||null};
const updated=await c.auth.updateUser({data:metadata});if(updated.error)throw updated.error; 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; const created=await c.rpc('sun_create_workspace',{p_name:'Новая компания'});if(created.error)throw created.error;
clearPendingRegistration();await api.reloadMemberships?.();location.reload();return true; clearPendingRegistration();await api.reloadMemberships?.();location.reload();return true;
@ -90,7 +114,7 @@
const recovery=$('sunGateRecoveryCompanyV25'); const recovery=$('sunGateRecoveryCompanyV25');
if(recovery){ if(recovery){
recovery.closest('label')?.remove(); 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;setError(gate,'Аккаунт не привязан к компании. Если вы сотрудник — попросите владельца добавить вас в «Пользователи и права». Для новой компании зарегистрируйте пробный период по промокоду.');}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;await refreshSignupPolicy();setError(gate,signupPolicy.promoOptional?'Аккаунт не привязан к компании. Если вы сотрудник — попросите владельца добавить вас в «Пользователи и права». Для новой компании зарегистрируйтесь: без промокода доступен Базовый тариф.':'Аккаунт не привязан к компании. Если вы сотрудник — попросите владельца добавить вас в «Пользователи и права». Для новой компании нужен действующий промокод.');}catch(e){setError(gate,e?.message||String(e));}};}
const title=gate.querySelector('h2');if(title)title.textContent='Нет доступа к компании'; const title=gate.querySelector('h2');if(title)title.textContent='Нет доступа к компании';
const hints=gate.querySelectorAll('.hint');if(hints[1])hints[1].textContent='Этот аккаунт пока не привязан к рабочей компании.'; const hints=gate.querySelectorAll('.hint');if(hints[1])hints[1].textContent='Этот аккаунт пока не привязан к рабочей компании.';
} }

View File

@ -5,6 +5,12 @@
if(window.CateriumBanquetClientMenu)return; if(window.CateriumBanquetClientMenu)return;
const W=1000,H=1414,M=64,SANS='"Caterium Menu Sans",Arial,sans-serif',SERIF='"Caterium Menu Serif",Georgia,serif'; const W=1000,H=1414,M=64,SANS='"Caterium Menu Sans",Arial,sans-serif',SERIF='"Caterium Menu Serif",Georgia,serif';
const groups=[['cold','Холодные закуски'],['salads','Салаты'],['starters','Горячие закуски'],['main','Горячее'],['sides','Гарниры'],['desserts','Десерты'],['fruit','Фрукты и ягоды'],['bread','Хлеб и масло'],['other','Другие блюда']]; const groups=[['cold','Холодные закуски'],['salads','Салаты'],['starters','Горячие закуски'],['main','Горячее'],['sides','Гарниры'],['desserts','Десерты'],['fruit','Фрукты и ягоды'],['bread','Хлеб и масло'],['other','Другие блюда']];
const THEMES={
gold:{label:'Золото',paper:'#fbf8f1',ink:'#243b30',muted:'#657269',accent:'#9b793e',rule:'#dcd5c6',titleAlign:'left'},
noir:{label:'Ночь',paper:'#1c1c20',ink:'#f4f1e9',muted:'#a79f8f',accent:'#c9a24a',rule:'#3c3b3d',titleAlign:'left'},
mono:{label:'Минимал',paper:'#ffffff',ink:'#161616',muted:'#6c6c6c',accent:'#161616',rule:'#e6e6e6',titleAlign:'center'}
};
const themeIds=Object.keys(THEMES);
const clean=v=>String(v??'').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g,'').trim(); const clean=v=>String(v??'').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g,'').trim();
const money=v=>Number(v).toLocaleString('ru-RU',{maximumFractionDigits:2})+' ₽'; const money=v=>Number(v).toLocaleString('ru-RU',{maximumFractionDigits:2})+' ₽';
const number=v=>{const n=Number(String(v??'').replace(',','.'));return Number.isFinite(n)?n:0;}; const number=v=>{const n=Number(String(v??'').replace(',','.'));return Number.isFinite(n)?n:0;};
@ -73,16 +79,17 @@
} }
function dateLabel(value){if(!/^\d{4}-\d{2}-\d{2}$/.test(value))return value;const d=new Date(value+'T12:00:00');return Number.isNaN(d.getTime())?value:d.toLocaleDateString('ru-RU',{day:'numeric',month:'long',year:'numeric'});} function dateLabel(value){if(!/^\d{4}-\d{2}-\d{2}$/.test(value))return value;const d=new Date(value+'T12:00:00');return Number.isNaN(d.getTime())?value:d.toLocaleDateString('ru-RU',{day:'numeric',month:'long',year:'numeric'});}
function guestLabel(n){const a=n%100,b=n%10;return `${n.toLocaleString('ru-RU')} ${a>10&&a<20?'гостей':b===1?'гость':b>=2&&b<=4?'гостя':'гостей'}`;} function guestLabel(n){const a=n%100,b=n%10;return `${n.toLocaleString('ru-RU')} ${a>10&&a<20?'гостей':b===1?'гость':b>=2&&b<=4?'гостя':'гостей'}`;}
function plan(ctx,s,showPrices,top,bottom){ function plan(ctx,s,top,bottom){
const ordered=groups.flatMap(([id])=>s.items.filter(i=>i.group===id)); const ordered=groups.flatMap(([id])=>s.items.filter(i=>i.group===id));
// Keep every dish and every character. Never crop, use ellipses or make a // Keep every dish and every character. Never crop, use ellipses or make a
// second page. A genuinely oversized menu gets a clear, recoverable error. // second page. A genuinely oversized menu gets a clear, recoverable error.
// Per-item prices are never shown to the client — only the per-guest total in the footer.
for(let font=24;font>=17;font--){ for(let font=24;font>=17;font--){
const choices=ordered.length<=8?[1,2]:[2,1]; const choices=ordered.length<=8?[1,2]:[2,1];
for(const columns of choices){ for(const columns of choices){
const width=(W-M*2-(columns-1)*42)/columns; const width=(W-M*2-(columns-1)*42)/columns;
ctx.font=`${font}px ${SANS}`; ctx.font=`${font}px ${SANS}`;
const rows=ordered.map(item=>({item,lines:wrap(ctx,item.name,width),meta:[item.weight,showPrices?money(item.cents/100):''].filter(Boolean).join(' · ')})); const rows=ordered.map(item=>({item,lines:wrap(ctx,item.name,width),meta:[item.weight].filter(Boolean).join(' · ')}));
for(const row of rows){ctx.font=`${Math.max(13,font-5)}px ${SANS}`;row.metaLines=wrap(ctx,row.meta,width);row.height=row.lines.length*font*1.3+row.metaLines.length*(font-2)+10;} for(const row of rows){ctx.font=`${Math.max(13,font-5)}px ${SANS}`;row.metaLines=wrap(ctx,row.meta,width);row.height=row.lines.length*font*1.3+row.metaLines.length*(font-2)+10;}
const column=list=>{let group='',height=0;const entries=[];for(const row of list){const newGroup=row.item.group!==group;if(newGroup){height+=40;group=row.item.group;}entries.push({...row,heading:newGroup?groups.find(g=>g[0]===group)[1]:null});height+=row.height;}return {entries,height};}; const column=list=>{let group='',height=0;const entries=[];for(const row of list){const newGroup=row.item.group!==group;if(newGroup){height+=40;group=row.item.group;}entries.push({...row,heading:newGroup?groups.find(g=>g[0]===group)[1]:null});height+=row.height;}return {entries,height};};
let candidates=columns===1?[[column(rows)]]:Array.from({length:rows.length-1},(_,i)=>[column(rows.slice(0,i+1)),column(rows.slice(i+1))]); let candidates=columns===1?[[column(rows)]]:Array.from({length:rows.length-1},(_,i)=>[column(rows.slice(0,i+1)),column(rows.slice(i+1))]);
@ -92,27 +99,28 @@
} }
throw new Error('Меню не помещается на одну страницу без слишком мелкого текста. Сократите названия или количество выбранных блюд. Ни одно блюдо не было обрезано.'); throw new Error('Меню не помещается на одну страницу без слишком мелкого текста. Сократите названия или количество выбранных блюд. Ни одно блюдо не было обрезано.');
} }
async function render(s,{showPrices=true}={}){ async function render(s,{themeId='gold'}={}){
const theme=THEMES[themeId]||THEMES.gold;
await fonts();const [pdf,logo]=await Promise.all([engine(),logoImage(s.brand.logo)]); await fonts();const [pdf,logo]=await Promise.all([engine(),logoImage(s.brand.logo)]);
const canvas=document.createElement('canvas');canvas.width=W*2;canvas.height=H*2; const canvas=document.createElement('canvas');canvas.width=W*2;canvas.height=H*2;
const c=canvas.getContext('2d',{alpha:false});c.scale(2,2);c.textBaseline='top'; const c=canvas.getContext('2d',{alpha:false});c.scale(2,2);c.textBaseline='top';
const drawnText=[],ink='#243b30',muted='#657269',gold='#9b793e',paper='#fbf8f1',rule='#dcd5c6'; const drawnText=[],{ink,muted,accent:gold,paper,rule}=theme,centered=theme.titleAlign==='center';
const text=(value,x,y,width,font,color=ink,lh=24,align='left')=>{c.font=font;c.fillStyle=color;c.textAlign=align;const lines=wrap(c,value,width);for(let i=0;i<lines.length;i++){c.fillText(lines[i],x,y+i*lh);drawnText.push(lines[i]);}return lines.length*lh;}; const text=(value,x,y,width,font,color=ink,lh=24,align='left')=>{c.font=font;c.fillStyle=color;c.textAlign=align;const lines=wrap(c,value,width);for(let i=0;i<lines.length;i++){c.fillText(lines[i],align==='center'?x+width/2:x,y+i*lh);drawnText.push(lines[i]);}return lines.length*lh;};
const line=(x,y,x2,color=rule)=>{c.strokeStyle=color;c.lineWidth=1;c.beginPath();c.moveTo(x,y);c.lineTo(x2,y);c.stroke();}; const line=(x,y,x2,color=rule)=>{c.strokeStyle=color;c.lineWidth=1;c.beginPath();c.moveTo(x,y);c.lineTo(x2,y);c.stroke();};
c.fillStyle=paper;c.fillRect(0,0,W,H);c.strokeStyle=rule;c.lineWidth=1;c.strokeRect(24,24,W-48,H-48); c.fillStyle=paper;c.fillRect(0,0,W,H);c.strokeStyle=rule;c.lineWidth=1;c.strokeRect(24,24,W-48,H-48);
const brandHeight=text(s.brand.name,M,58,logo?590:W-M*2,`600 21px ${SANS}`,ink,27); const brandHeight=text(s.brand.name,M,58,logo?590:W-M*2,`600 21px ${SANS}`,ink,27,centered?'center':'left');
let headerBottom=Math.max(112,58+brandHeight); let headerBottom=Math.max(112,58+brandHeight);
if(logo){const scale=Math.min(210/logo.width,66/logo.height);c.drawImage(logo,W-M-logo.width*scale,54,logo.width*scale,logo.height*scale);headerBottom=Math.max(headerBottom,125);} if(logo&&!centered){const scale=Math.min(210/logo.width,66/logo.height);c.drawImage(logo,W-M-logo.width*scale,54,logo.width*scale,logo.height*scale);headerBottom=Math.max(headerBottom,125);}
line(M,headerBottom+14,W-M,gold); line(M,headerBottom+14,W-M,gold);
let y=headerBottom+42; let y=headerBottom+42;
y+=text('Банкетное меню',M,y,W-M*2,`500 52px ${SERIF}`,ink,63)+14; y+=text('Банкетное меню',M,y,W-M*2,`500 52px ${SERIF}`,ink,63,centered?'center':'left')+14;
if(s.event)y+=text(s.event,M,y,W-M*2,`20px ${SANS}`,muted,27)+10; if(s.event)y+=text(s.event,M,y,W-M*2,`20px ${SANS}`,muted,27,centered?'center':'left')+10;
const facts=[s.date?dateLabel(s.date):'',guestLabel(s.guests)].filter(Boolean).join(' · '); const facts=[s.date?dateLabel(s.date):'',guestLabel(s.guests)].filter(Boolean).join(' · ');
y+=text(facts,M,y,W-M*2,`600 16px ${SANS}`,gold,23)+24; y+=text(facts,M,y,W-M*2,`600 16px ${SANS}`,gold,23,centered?'center':'left')+24;
line(M,y,W-M);const top=y+20; line(M,y,W-M);const top=y+20;
c.font=`13px ${SANS}`;const contacts=wrap(c,s.brand.contacts,W-M*2),contactHeight=contacts.length*19; c.font=`13px ${SANS}`;const contacts=wrap(c,s.brand.contacts,W-M*2),contactHeight=contacts.length*19;
const footerHeight=(showPrices?142:62)+contactHeight+(s.estimated?22:0),bottom=H-64-footerHeight; const footerHeight=108+contactHeight+(s.estimated?22:0),bottom=H-64-footerHeight;
const layout=plan(c,s,showPrices,top,bottom); const layout=plan(c,s,top,bottom);
for(let j=0;j<layout.columns.length;j++){ for(let j=0;j<layout.columns.length;j++){
const col=layout.columns[j],x=M+j*(layout.width+42);let yy=top; const col=layout.columns[j],x=M+j*(layout.width+42);let yy=top;
for(const row of col.entries){ for(const row of col.entries){
@ -123,13 +131,13 @@
} }
} }
let fy=bottom+22;line(M,fy,W-M,gold);fy+=20; let fy=bottom+22;line(M,fy,W-M,gold);fy+=20;
if(showPrices){ // Only the per-guest price is ever shown to the client — never a per-item price
text('НА ОДНОГО ГОСТЯ',M,fy,380,`700 12px ${SANS}`,muted,17); // and never the grand total, so the menu can't be read as a full price list.
text(`МЕНЮ НА ${s.guests.toLocaleString('ru-RU')} ГОСТЕЙ`,W/2+20,fy,416,`700 12px ${SANS}`,muted,17);fy+=24; // It is not optional: the client always sees what one guest costs.
const fitMoney=(v,x)=>{let size=30;c.font=`600 ${size}px ${SERIF}`;while(size>14&&c.measureText(v).width>400)c.font=`600 ${--size}px ${SERIF}`;text(v,x,fy,400,c.font,ink,size+4);}; text('ЦЕНА НА ОДНОГО ГОСТЯ',M,fy,W-M*2,`700 12px ${SANS}`,muted,17,centered?'center':'left');fy+=24;
fitMoney(money(s.cents/100),M);fitMoney(money(s.totalCents/100),W/2+20);fy+=45; let size=34;c.font=`600 ${size}px ${SERIF}`;const v=money(s.cents/100);while(size>16&&c.measureText(v).width>W-M*2)c.font=`600 ${--size}px ${SERIF}`;
text('Только выбранные блюда. Доставка и услуги не включены.',M,fy,W-M*2,`12px ${SANS}`,muted,18);fy+=24; text(v,M,fy,W-M*2,c.font,ink,size+4,centered?'center':'left');fy+=size+16;
}else if(s.grams){text(`Выход по указанным порциям: ${s.grams.toLocaleString('ru-RU')} г на гостя`,M,fy,W-M*2,`14px ${SANS}`,muted,20);fy+=26;} text('За гостя, только выбранные блюда. Доставка и услуги не включены.',M,fy,W-M*2,`12px ${SANS}`,muted,18,centered?'center':'left');fy+=24;
if(s.estimated){text('≈ Вес и стоимость предварительные — уточняются при согласовании.',M,fy,W-M*2,`12px ${SANS}`,muted,18);fy+=22;} if(s.estimated){text('≈ Вес и стоимость предварительные — уточняются при согласовании.',M,fy,W-M*2,`12px ${SANS}`,muted,18);fy+=22;}
if(s.brand.contacts)text(s.brand.contacts,M,fy+5,W-M*2,`13px ${SANS}`,muted,19); if(s.brand.contacts)text(s.brand.contacts,M,fy+5,W-M*2,`13px ${SANS}`,muted,19);
const jpeg=await new Promise((resolve,reject)=>canvas.toBlob(b=>b?resolve(b):reject(new Error('Не удалось сформировать страницу меню.')),'image/jpeg',.96)); const jpeg=await new Promise((resolve,reject)=>canvas.toBlob(b=>b?resolve(b):reject(new Error('Не удалось сформировать страницу меню.')),'image/jpeg',.96));
@ -143,7 +151,7 @@
#ctBanquetClientDialog{width:min(980px,calc(100vw - 24px));max-width:none;max-height:94dvh;padding:0;border:1px solid #d5cebd;border-radius:16px;background:#f8f5ed;color:#243b30;overflow:auto;box-sizing:border-box} #ctBanquetClientDialog{width:min(980px,calc(100vw - 24px));max-width:none;max-height:94dvh;padding:0;border:1px solid #d5cebd;border-radius:16px;background:#f8f5ed;color:#243b30;overflow:auto;box-sizing:border-box}
#ctBanquetClientDialog::backdrop{background:#0009}#ctBanquetClientDialog .ct-bcm-toolbar{position:sticky;top:0;z-index:1;display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap;padding:15px 18px;background:#f8f5ed;border-bottom:1px solid #ddd5c4} #ctBanquetClientDialog::backdrop{background:#0009}#ctBanquetClientDialog .ct-bcm-toolbar{position:sticky;top:0;z-index:1;display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap;padding:15px 18px;background:#f8f5ed;border-bottom:1px solid #ddd5c4}
#ctBanquetClientDialog h2{font-size:18px;margin:0}#ctBanquetClientDialog .ct-bcm-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center}#ctBanquetClientDialog .ct-bcm-actions label{display:flex;flex-direction:row;align-items:center;gap:7px;font-size:12px;margin:0} #ctBanquetClientDialog h2{font-size:18px;margin:0}#ctBanquetClientDialog .ct-bcm-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center}#ctBanquetClientDialog .ct-bcm-actions label{display:flex;flex-direction:row;align-items:center;gap:7px;font-size:12px;margin:0}
#ctBanquetClientDialog input[type=checkbox]{width:18px!important;height:18px;min-height:0;margin:0}#ctBanquetClientDialog button{min-height:40px;padding:9px 12px;border-radius:9px;border:1px solid #c8c9bb;background:#fff;color:#243b30;font:600 12px Arial;cursor:pointer}#ctBanquetClientDialog [data-bcm-download]{background:#243b30;color:#fff;border-color:#243b30} #ctBanquetClientDialog input[type=checkbox]{width:18px!important;height:18px;min-height:0;margin:0}#ctBanquetClientDialog select{min-height:34px;padding:5px 8px;border-radius:8px;border:1px solid #c8c9bb;background:#fff;color:#243b30;font:600 12px Arial}#ctBanquetClientDialog button{min-height:40px;padding:9px 12px;border-radius:9px;border:1px solid #c8c9bb;background:#fff;color:#243b30;font:600 12px Arial;cursor:pointer}#ctBanquetClientDialog [data-bcm-download]{background:#243b30;color:#fff;border-color:#243b30}
#ctBanquetClientDialog button:disabled{opacity:.45;cursor:default}#ctBanquetClientDialog [data-bcm-status]{margin:14px 18px;font-size:13px;line-height:1.5}#ctBanquetClientDialog [data-bcm-preview]{padding:0 18px 18px}#ctBanquetClientDialog img{display:block;width:100%;max-width:700px;height:auto;margin:auto;box-shadow:0 3px 20px #0002} #ctBanquetClientDialog button:disabled{opacity:.45;cursor:default}#ctBanquetClientDialog [data-bcm-status]{margin:14px 18px;font-size:13px;line-height:1.5}#ctBanquetClientDialog [data-bcm-preview]{padding:0 18px 18px}#ctBanquetClientDialog img{display:block;width:100%;max-width:700px;height:auto;margin:auto;box-shadow:0 3px 20px #0002}
#ctBanquetClientDialog [hidden]{display:none!important}#ctBanquetClientDialog :focus-visible{outline:3px solid #ac8a4b;outline-offset:2px}@media(max-width:600px){#ctBanquetClientDialog .ct-bcm-toolbar{padding:12px}#ctBanquetClientDialog .ct-bcm-actions{gap:6px}#ctBanquetClientDialog [data-bcm-preview]{padding:0 8px 10px}} #ctBanquetClientDialog [hidden]{display:none!important}#ctBanquetClientDialog :focus-visible{outline:3px solid #ac8a4b;outline-offset:2px}@media(max-width:600px){#ctBanquetClientDialog .ct-bcm-toolbar{padding:12px}#ctBanquetClientDialog .ct-bcm-actions{gap:6px}#ctBanquetClientDialog [data-bcm-preview]{padding:0 8px 10px}}
`;document.head.append(el); `;document.head.append(el);
@ -152,21 +160,22 @@
if(!allowed())throw new Error('Предложения клиенту недоступны для текущих прав или тарифа.'); if(!allowed())throw new Error('Предложения клиенту недоступны для текущих прав или тарифа.');
const s=snapshot(input),started=scope();close();style(); const s=snapshot(input),started=scope();close();style();
const dialog=document.createElement('dialog');dialog.id='ctBanquetClientDialog';dialog.setAttribute('aria-labelledby','ctBanquetClientTitle'); const dialog=document.createElement('dialog');dialog.id='ctBanquetClientDialog';dialog.setAttribute('aria-labelledby','ctBanquetClientTitle');
dialog.innerHTML='<div class="ct-bcm-toolbar"><h2 id="ctBanquetClientTitle">Меню для клиента</h2><div class="ct-bcm-actions"><label><input type="checkbox" data-bcm-prices checked>Показывать стоимость</label><button type="button" data-bcm-download disabled>Скачать PDF</button><button type="button" data-bcm-share hidden disabled>Поделиться</button><button type="button" data-bcm-close aria-label="Закрыть меню для клиента">Закрыть</button></div></div><p data-bcm-status role="status" aria-live="polite">Оформляю меню на одной странице…</p><div data-bcm-preview></div>'; const themeOptions=themeIds.map(id=>`<option value="${id}">${THEMES[id].label}</option>`).join('');
dialog.innerHTML=`<div class="ct-bcm-toolbar"><h2 id="ctBanquetClientTitle">Меню для клиента</h2><div class="ct-bcm-actions"><label>Оформление<select data-bcm-theme>${themeOptions}</select></label><button type="button" data-bcm-download disabled>Скачать PDF</button><button type="button" data-bcm-share hidden disabled>Поделиться</button><button type="button" data-bcm-close aria-label="Закрыть меню для клиента">Закрыть</button></div></div><p data-bcm-status role="status" aria-live="polite">Оформляю меню на одной странице…</p><div data-bcm-preview></div>`;
const state={dialog,snapshot:s,generation:0,url:null,result:null,restore:document.activeElement};panel=state; const state={dialog,snapshot:s,generation:0,url:null,result:null,restore:document.activeElement};panel=state;
const get=q=>dialog.querySelector(q),status=get('[data-bcm-status]'),download=get('[data-bcm-download]'),share=get('[data-bcm-share]'),check=get('[data-bcm-prices]'); const get=q=>dialog.querySelector(q),status=get('[data-bcm-status]'),download=get('[data-bcm-download]'),share=get('[data-bcm-share]'),themeSelect=get('[data-bcm-theme]');
document.body.append(dialog);dialog.addEventListener('cancel',e=>{e.preventDefault();close();});get('[data-bcm-close]').onclick=close;dialog.showModal(); document.body.append(dialog);dialog.addEventListener('cancel',e=>{e.preventDefault();close();});get('[data-bcm-close]').onclick=close;dialog.showModal();
const valid=()=>panel===state&&scope()===started&&allowed(); const valid=()=>panel===state&&scope()===started&&allowed();
const build=async()=>{ const build=async()=>{
const ticket=++state.generation;download.disabled=true;share.disabled=true;state.result=null;status.textContent='Оформляю меню на одной странице…'; const ticket=++state.generation;download.disabled=true;share.disabled=true;state.result=null;status.textContent='Оформляю меню на одной странице…';
try{const result=await render(s,{showPrices:check.checked});if(!valid()||ticket!==state.generation)return; try{const result=await render(s,{themeId:themeSelect.value});if(!valid()||ticket!==state.generation)return;
if(state.url)URL.revokeObjectURL(state.url);state.url=URL.createObjectURL(result.jpeg);state.result=result; if(state.url)URL.revokeObjectURL(state.url);state.url=URL.createObjectURL(result.jpeg);state.result=result;
const img=document.createElement('img');img.src=state.url;img.alt='Банкетное меню: одна страница A4';get('[data-bcm-preview]').replaceChildren(img); const img=document.createElement('img');img.src=state.url;img.alt='Банкетное меню: одна страница A4';get('[data-bcm-preview]').replaceChildren(img);
status.textContent=`Готово: одна страница A4 · ${s.items.length} позиций. ${check.checked?'Стоимость только выбранных блюд.':'Без указания стоимости.'}`;download.disabled=false; status.textContent=`Готово: одна страница A4 · ${s.items.length} позиций. Показана только цена за гостя.`;download.disabled=false;
const file=new File([result.blob],'Банкетное меню.pdf',{type:'application/pdf'});share.hidden=!(navigator.share&&navigator.canShare?.({files:[file]}));share.disabled=false; const file=new File([result.blob],'Банкетное меню.pdf',{type:'application/pdf'});share.hidden=!(navigator.share&&navigator.canShare?.({files:[file]}));share.disabled=false;
}catch(error){if(valid()&&ticket===state.generation){status.textContent=error.message;get('[data-bcm-preview]').replaceChildren();}} }catch(error){if(valid()&&ticket===state.generation){status.textContent=error.message;get('[data-bcm-preview]').replaceChildren();}}
}; };
check.onchange=build; themeSelect.onchange=build;
download.onclick=()=>{if(!valid()||!state.result)return;const url=URL.createObjectURL(state.result.blob),a=document.createElement('a');a.href=url;a.download=`Банкетное меню${s.date?' '+s.date:''}.pdf`;document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),60000);}; download.onclick=()=>{if(!valid()||!state.result)return;const url=URL.createObjectURL(state.result.blob),a=document.createElement('a');a.href=url;a.download=`Банкетное меню${s.date?' '+s.date:''}.pdf`;document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),60000);};
share.onclick=async()=>{if(!valid()||!state.result)return;try{await navigator.share({files:[new File([state.result.blob],'Банкетное меню.pdf',{type:'application/pdf'})],title:'Банкетное меню'});}catch(error){if(valid()&&error.name!=='AbortError')status.textContent='Не удалось открыть отправку. Сохраните PDF и отправьте его вручную.';}}; share.onclick=async()=>{if(!valid()||!state.result)return;try{await navigator.share({files:[new File([state.result.blob],'Банкетное меню.pdf',{type:'application/pdf'})],title:'Банкетное меню'});}catch(error){if(valid()&&error.name!=='AbortError')status.textContent='Не удалось открыть отправку. Сохраните PDF и отправьте его вручную.';}};
await build();return state.result; await build();return state.result;

View File

@ -48,7 +48,7 @@
if(!window.CateriumBanquetClientMenu)await new Promise((resolve,reject)=>{ if(!window.CateriumBanquetClientMenu)await new Promise((resolve,reject)=>{
const script=document.createElement('script');let timer; const script=document.createElement('script');let timer;
const fail=()=>{clearTimeout(timer);script.remove();reject(new Error('Не удалось загрузить оформление меню. Повторите попытку.'));}; const fail=()=>{clearTimeout(timer);script.remove();reject(new Error('Не удалось загрузить оформление меню. Повторите попытку.'));};
script.src='core/banquet-client-menu.js?v=20260920-onepage';script.onload=()=>{clearTimeout(timer);window.CateriumBanquetClientMenu?resolve():fail();};script.onerror=fail;timer=setTimeout(fail,12000);document.head.append(script); script.src='core/banquet-client-menu.js?v=20260922-guest-price-always';script.onload=()=>{clearTimeout(timer);window.CateriumBanquetClientMenu?resolve():fail();};script.onerror=fail;timer=setTimeout(fail,12000);document.head.append(script);
}); });
if(!root.isConnected||who!==JSON.stringify([window.SunCloudV2?.getSession?.()?.user?.id,window.SunCloudV2?.getWorkspace?.()?.id]))return; if(!root.isConnected||who!==JSON.stringify([window.SunCloudV2?.getSession?.()?.user?.id,window.SunCloudV2?.getWorkspace?.()?.id]))return;
await window.CateriumBanquetClientMenu.open(input); await window.CateriumBanquetClientMenu.open(input);

View File

@ -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 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 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 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 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 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 post(){styles();searchBox();comments();summary()} 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} 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(); styles();['render','openOrder','resetDraft','updateOrderSummary','saveOrder'].forEach(wrap);post();

View File

@ -1,7 +1,7 @@
(()=>{ (()=>{
'use strict'; 'use strict';
const VERSION='17.7.3'; const VERSION='17.7.3';
const RELEASE='20260921-client-sync'; const RELEASE='20260922-order-summary-fix';
const hasStoredSession=()=>{try{return Object.keys(localStorage).some(k=>/^sb-.*-auth-token$/i.test(k)&&String(localStorage.getItem(k)||'').length>20)}catch(_){return false}}; 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(){ function installAuthBoot(){

View File

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

View File

@ -33,7 +33,7 @@
const controller=new AbortController(),timeout=setTimeout(()=>controller.abort(),15000); const controller=new AbortController(),timeout=setTimeout(()=>controller.abort(),15000);
try{ try{
const [base,banquet,extras]=await Promise.all(['catalog-v1','banquet-v1','extras-v1'].map(async name=>{ const [base,banquet,extras]=await Promise.all(['catalog-v1','banquet-v1','extras-v1'].map(async name=>{
const r=await fetch(`demo/${name}.json?v=20260920-training`,{signal:controller.signal,credentials:'same-origin'}); const r=await fetch(`demo/${name}.json?v=20260922-training-photos`,{signal:controller.signal,credentials:'same-origin'});
if(!r.ok)throw new Error('Не удалось загрузить учебный каталог. Проверьте интернет и повторите.'); if(!r.ok)throw new Error('Не удалось загрузить учебный каталог. Проверьте интернет и повторите.');
const value=await r.json();if(value?.version!==1)throw new Error('Неизвестная версия учебного каталога.');return value; const value=await r.json();if(value?.version!==1)throw new Error('Неизвестная версия учебного каталога.');return value;
})); }));
@ -128,6 +128,8 @@
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>'; 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); host.prepend(card);
card.querySelector('input').addEventListener('change',e=>{setEnabled(e.target.checked).catch(()=>{});}); 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; const check=$('ctTrainingEnabled');check.checked=busy?desired:enabled();check.disabled=busy||!canManage()||!repo()?.catalog;
card.setAttribute('aria-busy',String(busy)); card.setAttribute('aria-busy',String(busy));

View File

@ -18,16 +18,40 @@
function message(text){const el=$('ctDemoMessage');if(el)el.textContent=text;} function message(text){const el=$('ctDemoMessage');if(el)el.textContent=text;}
async function refreshStatus(){status=null;statusWorkspace='';render();} 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(){ function render(){
const host=$('new');if(!host)return; const host=$('new');if(!host)return;
if(window.CateriumTrainingCatalog&&!window.CateriumTrainingCatalog.enabled()){$('ctTrialDemo')?.remove();return;} const clear=()=>{$('ctTrialDemo')?.remove();$('ctTrainingNotice')?.remove();};
if(window.CateriumTrainingCatalog&&!window.CateriumTrainingCatalog.enabled()){clear();return;}
const demoItems=catalog().filter(b=>b.demo),items=demoItems.filter(b=>b.ttk),active=installed()&&demoItems.length>0; 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)){$('ctTrialDemo')?.remove();return;} if(!signedIn()||cloud()?.isSupportMode?.()||(!active&&!status?.canInstall)){clear();return;}
let card=$('ctTrialDemo');if(!card){card=document.createElement('section');card.id='ctTrialDemo';card.className='card';host.prepend(card);} // 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);}
const signature=JSON.stringify({active,items:demoItems.map(x=>[x.id,x.name]),upgrade:status?.canUpgrade,can:writable()}); 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; if(card.dataset.signature===signature)return;card.dataset.signature=signature;
const previousGuide={open:card.querySelector('details')?.open,selected:$('ctDemoBox')?.value,message:$('ctDemoMessage')?.textContent}; 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><span class="ct-demo-badge">Учебные примеры</span></div> card.innerHTML=active?`<div class="ct-demo-heading"><div><strong>Пробный заказ, склад и закупки</strong><p>${demoItems.length} тестовых позиций · готовые составы, учебные цены и поставщики</p></div></div>
<p>Создайте пробный заказ, посмотрите нехватку в закупках, оформите приход на складе и спишите продукты по заказу. Все данные можно редактировать.</p> <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> <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>':''} ${status?.canUpgrade?'<button class="outline" data-demo-install>Дополнить пустые вкладки демо</button>':''}
@ -76,9 +100,9 @@
dialog.querySelector('[data-ttk-edit]').onclick=()=>{dialog.close();window.editBox?.(box.id)}; dialog.querySelector('[data-ttk-edit]').onclick=()=>{dialog.close();window.editBox?.(box.id)};
dialog.addEventListener('close',()=>dialog.remove(),{once:true});dialog.showModal(); dialog.addEventListener('close',()=>dialog.remove(),{once:true});dialog.showModal();
} }
function reset(){epoch++;pending=null;status=null;statusWorkspace='';$('ctTrialDemo')?.remove();$('ctDemoTtk')?.remove();} function reset(){epoch++;pending=null;status=null;statusWorkspace='';$('ctTrialDemo')?.remove();$('ctTrainingNotice')?.remove();$('ctDemoTtk')?.remove();}
function boot(){ 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}}@media print{#ctTrialDemo{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}}#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}}`;
document.head.append(style); document.head.append(style);
window.addEventListener('sun:cloud-tenant-changing',reset); window.addEventListener('sun:cloud-tenant-changing',reset);
window.addEventListener('sun:cloud-permissions-changed',()=>{render();refreshStatus()}); window.addEventListener('sun:cloud-permissions-changed',()=>{render();refreshStatus()});

View File

@ -9,7 +9,7 @@
"price": 290, "price": 290,
"weight": "120 г", "weight": "120 г",
"pieces": 1, "pieces": 1,
"photo": "", "photo": "demo/images/banquet-caprese.webp",
"demo": true, "demo": true,
"composition": [ "composition": [
"Моцарелла мини, Томаты, Базилик, Масло оливковое" "Моцарелла мини, Томаты, Базилик, Масло оливковое"
@ -95,7 +95,7 @@
"price": 390, "price": 390,
"weight": "100 г", "weight": "100 г",
"pieces": 1, "pieces": 1,
"photo": "", "photo": "demo/images/banquet-roastbeef.webp",
"demo": true, "demo": true,
"composition": [ "composition": [
"Ростбиф готовый, Корнишоны, Горчица, Сливки" "Ростбиф готовый, Корнишоны, Горчица, Сливки"
@ -182,7 +182,7 @@
"price": 350, "price": 350,
"weight": "90 г", "weight": "90 г",
"pieces": 1, "pieces": 1,
"photo": "", "photo": "demo/images/banquet-salmon-roll.webp",
"demo": true, "demo": true,
"composition": [ "composition": [
"Лосось слабосолёный, Сыр сливочный, Огурцы, Укроп" "Лосось слабосолёный, Сыр сливочный, Огурцы, Укроп"
@ -269,7 +269,7 @@
"price": 230, "price": 230,
"weight": "160 г", "weight": "160 г",
"pieces": 1, "pieces": 1,
"photo": "", "photo": "demo/images/banquet-hummus.webp",
"demo": true, "demo": true,
"composition": [ "composition": [
"Морковь, Огурцы, Перец сладкий, Хумус готовый" "Морковь, Огурцы, Перец сладкий, Хумус готовый"
@ -355,7 +355,7 @@
"price": 340, "price": 340,
"weight": "180 г", "weight": "180 г",
"pieces": 1, "pieces": 1,
"photo": "", "photo": "demo/images/banquet-caesar.webp",
"demo": true, "demo": true,
"composition": [ "composition": [
"Филе курицы готовое, Салат листовой, Томаты черри, Хлеб тостовый, Сыр гауда, Сливки, Горчица" "Филе курицы готовое, Салат листовой, Томаты черри, Хлеб тостовый, Сыр гауда, Сливки, Горчица"
@ -483,7 +483,7 @@
"price": 280, "price": 280,
"weight": "180 г", "weight": "180 г",
"pieces": 1, "pieces": 1,
"photo": "", "photo": "demo/images/banquet-olivier.webp",
"demo": true, "demo": true,
"composition": [ "composition": [
"Картофель, Морковь, Ветчина из индейки, Яйцо куриное без скорлупы, Горошек зелёный консервированный, Корнишоны, Сливки, Горчица" "Картофель, Морковь, Ветчина из индейки, Яйцо куриное без скорлупы, Горошек зелёный консервированный, Корнишоны, Сливки, Горчица"
@ -624,7 +624,7 @@
"price": 290, "price": 290,
"weight": "180 г", "weight": "180 г",
"pieces": 1, "pieces": 1,
"photo": "", "photo": "demo/images/banquet-greek.webp",
"demo": true, "demo": true,
"composition": [ "composition": [
"Томаты, Огурцы, Перец сладкий, Моцарелла мини, Маслины без косточек, Масло оливковое" "Томаты, Огурцы, Перец сладкий, Моцарелла мини, Маслины без косточек, Масло оливковое"
@ -736,7 +736,7 @@
"price": 310, "price": 310,
"weight": "130 г", "weight": "130 г",
"pieces": 1, "pieces": 1,
"photo": "", "photo": "demo/images/banquet-julienne.webp",
"demo": true, "demo": true,
"composition": [ "composition": [
"Филе курицы готовое, Шампиньоны, Лук репчатый, Сливки, Сыр гауда, Масло оливковое" "Филе курицы готовое, Шампиньоны, Лук репчатый, Сливки, Сыр гауда, Масло оливковое"
@ -849,7 +849,7 @@
"price": 270, "price": 270,
"weight": "120 г", "weight": "120 г",
"pieces": 1, "pieces": 1,
"photo": "", "photo": "demo/images/banquet-stuffed-mushrooms.webp",
"demo": true, "demo": true,
"composition": [ "composition": [
"Шампиньоны, Сыр сливочный, Сыр гауда, Укроп, Масло оливковое" "Шампиньоны, Сыр сливочный, Сыр гауда, Укроп, Масло оливковое"
@ -949,7 +949,7 @@
"price": 490, "price": 490,
"weight": "220 г", "weight": "220 г",
"pieces": 1, "pieces": 1,
"photo": "", "photo": "demo/images/banquet-chicken.webp",
"demo": true, "demo": true,
"composition": [ "composition": [
"Филе куриное сырое, Перец сладкий, Морковь, Сливки, Масло оливковое" "Филе куриное сырое, Перец сладкий, Морковь, Сливки, Масло оливковое"
@ -1049,7 +1049,7 @@
"price": 590, "price": 590,
"weight": "200 г", "weight": "200 г",
"pieces": 1, "pieces": 1,
"photo": "", "photo": "demo/images/banquet-cod.webp",
"demo": true, "demo": true,
"composition": [ "composition": [
"Филе трески, Томаты, Перец сладкий, Лимон, Масло оливковое" "Филе трески, Томаты, Перец сладкий, Лимон, Масло оливковое"
@ -1148,7 +1148,7 @@
"price": 690, "price": 690,
"weight": "220 г", "weight": "220 г",
"pieces": 1, "pieces": 1,
"photo": "", "photo": "demo/images/banquet-beef-hot.webp",
"demo": true, "demo": true,
"composition": [ "composition": [
"Ростбиф готовый, Шампиньоны, Лук репчатый, Сливки, Масло оливковое" "Ростбиф готовый, Шампиньоны, Лук репчатый, Сливки, Масло оливковое"
@ -1247,7 +1247,7 @@
"price": 160, "price": 160,
"weight": "150 г", "weight": "150 г",
"pieces": 1, "pieces": 1,
"photo": "", "photo": "demo/images/banquet-mash.webp",
"demo": true, "demo": true,
"composition": [ "composition": [
"Картофель, Молоко, Масло сливочное" "Картофель, Молоко, Масло сливочное"
@ -1320,7 +1320,7 @@
"price": 170, "price": 170,
"weight": "150 г", "weight": "150 г",
"pieces": 1, "pieces": 1,
"photo": "", "photo": "demo/images/banquet-rice.webp",
"demo": true, "demo": true,
"composition": [ "composition": [
"Рис сухой, Морковь, Перец сладкий, Горошек зелёный консервированный, Масло оливковое" "Рис сухой, Морковь, Перец сладкий, Горошек зелёный консервированный, Масло оливковое"
@ -1417,7 +1417,7 @@
"price": 240, "price": 240,
"weight": "110 г", "weight": "110 г",
"pieces": 1, "pieces": 1,
"photo": "", "photo": "demo/images/banquet-berry-cream.webp",
"demo": true, "demo": true,
"composition": [ "composition": [
"Сыр сливочный, Сливки, Крошка бисквитная, Клубника, Голубика, Пудра сахарная" "Сыр сливочный, Сливки, Крошка бисквитная, Клубника, Голубика, Пудра сахарная"
@ -1531,7 +1531,7 @@
"price": 310, "price": 310,
"weight": "100 г", "weight": "100 г",
"pieces": 1, "pieces": 1,
"photo": "", "photo": "demo/images/banquet-cheese-honey.webp",
"demo": true, "demo": true,
"composition": [ "composition": [
"Сыр бри, Виноград, Орех грецкий очищенный, Мёд" "Сыр бри, Виноград, Орех грецкий очищенный, Мёд"
@ -1618,7 +1618,7 @@
"price": 290, "price": 290,
"weight": "180 г", "weight": "180 г",
"pieces": 1, "pieces": 1,
"photo": "", "photo": "demo/images/banquet-fruit.webp",
"demo": true, "demo": true,
"composition": [ "composition": [
"Виноград, Клубника, Голубика" "Виноград, Клубника, Голубика"
@ -1689,7 +1689,7 @@
"price": 120, "price": 120,
"weight": "60 г", "weight": "60 г",
"pieces": 1, "pieces": 1,
"photo": "", "photo": "demo/images/banquet-bread.webp",
"demo": true, "demo": true,
"composition": [ "composition": [
"Багет, Хлеб ржаной, Масло сливочное, Укроп" "Багет, Хлеб ржаной, Масло сливочное, Укроп"

View File

@ -9,7 +9,7 @@
"price": 3500, "price": 3500,
"pieces": 18, "pieces": 18,
"weight": "710 г", "weight": "710 г",
"photo": "", "photo": "demo/images/extras-salmon-caprese.webp",
"demo": true, "demo": true,
"composition": [ "composition": [
"Половина стандартного бокса: Канапе с лососем и сливочным сыром", "Половина стандартного бокса: Канапе с лососем и сливочным сыром",
@ -194,7 +194,7 @@
"price": 3900, "price": 3900,
"pieces": 0, "pieces": 0,
"weight": "750 г", "weight": "750 г",
"photo": "", "photo": "demo/images/extras-meat-cheese.webp",
"demo": true, "demo": true,
"composition": [ "composition": [
"Половина стандартного бокса: Мясное ассорти с корнишонами", "Половина стандартного бокса: Мясное ассорти с корнишонами",
@ -432,7 +432,7 @@
"price": 2900, "price": 2900,
"pieces": 22, "pieces": 22,
"weight": "820 г", "weight": "820 г",
"photo": "", "photo": "demo/images/extras-mini-buffet.webp",
"demo": true, "demo": true,
"composition": [ "composition": [
"Половина стандартного бокса: Брускетты с томатами и базиликом", "Половина стандартного бокса: Брускетты с томатами и базиликом",
@ -603,7 +603,7 @@
"price": 90, "price": 90,
"pieces": 1, "pieces": 1,
"weight": "", "weight": "",
"photo": "", "photo": "demo/images/extras-water.webp",
"composition": [ "composition": [
"Одна бутылка 0,5 л" "Одна бутылка 0,5 л"
], ],
@ -624,7 +624,7 @@
"price": 100, "price": 100,
"pieces": 1, "pieces": 1,
"weight": "", "weight": "",
"photo": "", "photo": "demo/images/extras-sparkling.webp",
"composition": [ "composition": [
"Одна бутылка 0,5 л" "Одна бутылка 0,5 л"
], ],
@ -645,7 +645,7 @@
"price": 220, "price": 220,
"pieces": 1, "pieces": 1,
"weight": "", "weight": "",
"photo": "", "photo": "demo/images/extras-juice.webp",
"composition": [ "composition": [
"Одна упаковка 1 л" "Одна упаковка 1 л"
], ],
@ -666,7 +666,7 @@
"price": 280, "price": 280,
"pieces": 1, "pieces": 1,
"weight": "", "weight": "",
"photo": "", "photo": "demo/images/extras-mors.webp",
"composition": [ "composition": [
"Одна бутылка готового морса 1 л" "Одна бутылка готового морса 1 л"
], ],
@ -687,7 +687,7 @@
"price": 25, "price": 25,
"pieces": 1, "pieces": 1,
"weight": "", "weight": "",
"photo": "", "photo": "demo/images/extras-plate.webp",
"composition": [ "composition": [
"Одна сервировочная тарелка" "Одна сервировочная тарелка"
], ],
@ -708,7 +708,7 @@
"price": 10, "price": 10,
"pieces": 1, "pieces": 1,
"weight": "", "weight": "",
"photo": "", "photo": "demo/images/extras-fork.webp",
"composition": [ "composition": [
"Одна вилка" "Одна вилка"
], ],
@ -729,7 +729,7 @@
"price": 15, "price": 15,
"pieces": 1, "pieces": 1,
"weight": "", "weight": "",
"photo": "", "photo": "demo/images/extras-glass.webp",
"composition": [ "composition": [
"Один стакан" "Один стакан"
], ],
@ -750,7 +750,7 @@
"price": 10, "price": 10,
"pieces": 1, "pieces": 1,
"weight": "", "weight": "",
"photo": "", "photo": "demo/images/extras-napkin.webp",
"composition": [ "composition": [
"Одна бумажная салфетка" "Одна бумажная салфетка"
], ],
@ -771,7 +771,7 @@
"price": 180, "price": 180,
"pieces": 1, "pieces": 1,
"weight": "1 кг", "weight": "1 кг",
"photo": "", "photo": "demo/images/extras-ice.webp",
"composition": [ "composition": [
"Один пакет пищевого льда, 1 кг" "Один пакет пищевого льда, 1 кг"
], ],
@ -792,7 +792,7 @@
"price": 250, "price": 250,
"pieces": 1, "pieces": 1,
"weight": "", "weight": "",
"photo": "", "photo": "demo/images/extras-tablecloth.webp",
"composition": [ "composition": [
"Одна одноразовая скатерть 120 × 180 см" "Одна одноразовая скатерть 120 × 180 см"
], ],
@ -813,7 +813,7 @@
"price": 190, "price": 190,
"pieces": 1, "pieces": 1,
"weight": "", "weight": "",
"photo": "", "photo": "demo/images/extras-serving-kit.webp",
"composition": [ "composition": [
"Один комплект одноразовых приборов для подачи" "Один комплект одноразовых приборов для подачи"
], ],
@ -834,7 +834,7 @@
"price": 600, "price": 600,
"pieces": 0, "pieces": 0,
"weight": "", "weight": "",
"photo": "", "photo": "demo/images/extras-delivery-city.webp",
"composition": [ "composition": [
"Учебный тариф за один адрес в пределах города." "Учебный тариф за один адрес в пределах города."
], ],
@ -849,7 +849,7 @@
"price": 1200, "price": 1200,
"pieces": 0, "pieces": 0,
"weight": "", "weight": "",
"photo": "", "photo": "demo/images/extras-delivery-outer.webp",
"composition": [ "composition": [
"Учебный тариф за один адрес в ближайшем пригороде." "Учебный тариф за один адрес в ближайшем пригороде."
], ],

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because one or more lines are too long

View File

@ -1,15 +1,16 @@
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 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 VERSION='20260918-ui-stability'; const VERSION='20260918-ui-stability';
const CORE=[ const CORE=[
'./core/support-form.js?v=20260921-support-recipient', './core/support-form.js?v=20260921-support-recipient',
'./core/trial-promo-developer-v181.js?v=20260921-promo-entry', './core/trial-promo-developer-v181.js?v=20260921-promo-entry',
'./core/banquet-client-menu.js?v=20260920-onepage', './core/single-item-pdf.js?v=20260922-single-item-pdf-v1',
'./core/training-catalog.js?v=20260920-training', './core/banquet-client-menu.js?v=20260922-guest-price-always',
'./core/training-catalog.js?v=20260922-training-photos',
'./core/catalog-pricing.js?v=20260919-client-menu','./core/client-menu.css?v=20260919-client-menu', './core/catalog-pricing.js?v=20260919-client-menu','./core/client-menu.css?v=20260919-client-menu',
'./vendor/supabase-2.112.4.min.js', './vendor/supabase-2.112.4.min.js',
`./core/help-center.js?v=${VERSION}`,`./core/help-center.css?v=${VERSION}`,`./help/knowledge-v1.json?v=${VERSION}`, `./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}`, './','./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=${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}`, `./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}`,
'./offer-gallery/001.jpg','./offer-gallery/002.jpg', './offer-gallery/001.jpg','./offer-gallery/002.jpg',
'./catalog/001.jpg','./catalog/002.jpg','./catalog/003.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', './sun-logo.png','./caterium-login-logo.png','./caterium-mark-light.svg','./pwa-icon-192.png','./pwa-icon-512.png','./manifest.webmanifest',
@ -17,7 +18,7 @@ const CORE=[
]; ];
const CRITICAL_FRESH=new Set([ const CRITICAL_FRESH=new Set([
'/core/support-form.js', '/core/support-form.js',
'/core/trial-promo-developer-v181.js','/core/hotfix-v1763.js', '/core/trial-promo-developer-v181.js','/core/single-item-pdf.js','/core/hotfix-v1763.js',
'/core/banquet-client-menu.js', '/core/banquet-client-menu.js',
'/core/training-catalog.js', '/core/training-catalog.js',
'/core/catalog-pricing.js','/core/client-menu.css', '/core/catalog-pricing.js','/core/client-menu.css',

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,169 @@
-- 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;

View File

@ -0,0 +1,18 @@
-- 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;

View File

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

View File

@ -3,6 +3,7 @@ import fs from 'node:fs';
const auth=fs.readFileSync('public/core/auth-security-v1774.js','utf8'); const auth=fs.readFileSync('public/core/auth-security-v1774.js','utf8');
const perf=fs.readFileSync('public/core/performance.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 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; let bad=0;
const check=(ok,msg)=>{console.log(`${ok?'OK':'FAIL'}: ${msg}`);if(!ok)bad++;}; const check=(ok,msg)=>{console.log(`${ok?'OK':'FAIL'}: ${msg}`);if(!ok)bad++;};
@ -11,6 +12,13 @@ check(auth.includes('showPublicConfirmation')&&auth.includes('Подтверди
check(auth.includes('showInviteConfirmation'),'invite signup waits for email confirmation'); 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("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("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(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_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'); check(migration.includes('drop trigger if exists caterium_autoverify_identity_v25 on auth.identities'),'autoverify identity trigger is removed');

View File

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

View File

@ -44,7 +44,11 @@ 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})=>{ 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 boot(page);await toggle(page,true);
await nav(page).getByRole('button',{name:'Новый заказ',exact:true}).click();await expect(page.locator('#ctTrialDemo')).toBeVisible(); 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 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.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('#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 ₽'); await page.locator('[data-demo-order]').click();await expect(page.locator('#ctDemoMessage')).toContainText('9 700 ₽');

View File

@ -26,10 +26,15 @@ 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)); 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(); 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); },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(); await expect(page.locator('#ctTrialDemo')).toBeVisible();
} }
test('demo TTK, order, shortages, receipt and one-time write-off work in the full app',async({page})=>{ test('demo TTK, order, shortages, receipt and one-time write-off work in the full app',async({page})=>{
await ready(page); await ready(page);await guide(page);
await page.locator('#ctTrialDemo summary').click();await page.locator('[data-demo-ttk]').click(); 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('Брутто'); 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); expect(await page.locator('#ctDemoTtk').evaluate(el=>el.getBoundingClientRect().width<=innerWidth)).toBe(true);