Compare commits

...

23 commits

Author SHA1 Message Date
5e5082814e feat(control-center): add server onboarding, SSH setup and interactive web terminal 2026-09-11 13:08:58 +07:00
3520d0308e build(dist): update production bundle with mobile-first Control Center 2026-09-11 12:47:26 +07:00
50d9059f21 feat(control-center): mobile-first overhaul, 3 brandbook themes, Russian localization, and full Playwright verification 2026-09-11 10:33:30 +07:00
a2b0a183e2 feat(control-center): adapt InstallationsPage mobile-first cards, Russian strings, and tokens 2026-09-11 09:43:30 +07:00
5115517403 feat(control-center): adapt ModulesPage styling, rollout stages, and RU strings 2026-09-11 09:40:38 +07:00
41aa560d38 feat(control-center): adapt ControlForbiddenPage styling and RU strings 2026-09-11 09:39:10 +07:00
b762f72178 feat(control-center): adapt ControlAccessGatePage styling, theme switcher and RU strings 2026-09-11 09:38:56 +07:00
3922e577f3 feat(control-center): adapt AuditPage styling and token integration 2026-09-11 09:38:47 +07:00
b8739a5096 feat(control-center): adapt AnnouncementsPage and IncidentsPage mobile-first styling 2026-09-11 09:38:37 +07:00
b81206a2bb feat(control-center): add rollout, installation and announcement ru translations 2026-09-11 09:38:25 +07:00
1a4382201d feat(control-center): preserve concurrent AGI work on control center components and pages 2026-09-11 09:36:04 +07:00
e4803f25e0 build(control-center): add fresh compiled dist files with control center 2026-09-11 09:10:34 +07:00
dc32ba1f9f build(control-center): regenerate production dist bundle with all control center routes 2026-09-11 09:10:21 +07:00
877fb74b55 build(control-center): bundle production dist artifacts for direct zero-downtime server deployment 2026-09-11 08:50:31 +07:00
5489fc3b43 docs(control-center): add comprehensive handoff specifications and atomic zero-downtime deployment package 2026-09-11 02:34:51 +07:00
8e0f640673 test(control-center): add 11 Playwright E2E scenarios across desktop, tablet, and mobile 2026-09-11 02:34:45 +07:00
47dbadafe9 feat(control-center): implement interactive Krona Control Center UX prototype with 360 inspector, circuit breaker, and RBAC 2026-09-11 02:34:34 +07:00
bcf9b15634 refactor(shared): consolidate shared components imports into shared/ui 2026-09-11 02:34:15 +07:00
e234aa9d4a feat(stage-d): tenant-aware frontend contracts, multi-tenant indexeddb storage isolation, negative tests and rest api adapter spec 2026-09-11 02:27:21 +07:00
1e54bc6935 fix(e2e): prioritize column move button over dragTo for touch mobile viewports 2026-09-11 01:56:20 +07:00
368ed1ed9d feat(stage-c): implement full field types meta, dynamic renderer, and integration tests 2026-09-11 01:48:37 +07:00
1930e41530 feat(stage-b): implement 10 dedicated real Playwright E2E scenarios across desktop, tablet, and mobile 2026-09-11 01:27:25 +07:00
623f39b6de fix(stage-a): eliminate all 186 oxlint warnings, enable deny-warnings, add npm run check, install chromium for playwright 2026-09-11 00:34:46 +07:00
206 changed files with 16852 additions and 774 deletions

6
.gitignore vendored
View file

@ -22,3 +22,9 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
# Test artifacts
test-results/
playwright-report/
.playwright/
e2e/screenshots/

View file

@ -0,0 +1,156 @@
# Задание AGY: доведение `krona-front` после контрольного аудита
## 1. Цель
Довести интерактивный frontend-прототип Krona Business Platform до состояния, в котором заявленная функциональность действительно работает и проверяется автоматическими тестами.
Это задание исправляет результат коммита `0e4c0f7`. Нельзя ограничиваться изменением документации, скрытием предупреждений линтера или ослаблением тестов.
## 2. Исходное состояние
- Production build проходит.
- 97 unit/component тестов проходят.
- `npm run lint` завершается с кодом 0, но выдаёт около 186 предупреждений.
- 9 Playwright E2E-тестов не были выполнены из-за отсутствующего Chromium.
- E2E-тест с названием про перемещение сделки фактически не выполняет перемещение.
- Configuration Engine объявляет поддержку 39+ типов, однако runtime renderer полноценно поддерживает только часть.
- IndexedDB и сервисные контракты не готовы к tenant-aware подключению backend.
- В проекте дублируются `shared/components` и `shared/ui`.
- README и `FEATURE-COVERAGE.md` содержат завышенные или устаревшие утверждения.
## 3. Обязательные работы
### Этап A. Честная система качества
1. Исправить все предупреждения `oxlint`, включая:
- неиспользуемые импорты, параметры и state;
- синхронные `setState` внутри effects;
- `Date.now()` и другие impure-вызовы во время render.
2. Включить режим, при котором предупреждения линтера ломают CI.
3. Добавить единый скрипт `npm run check`, выполняющий последовательно:
- lint без предупреждений;
- TypeScript typecheck;
- unit/component tests;
- production build;
- E2E.
4. Playwright должен устанавливаться воспроизводимо:
- локальная команда установки браузера описана в README;
- CI устанавливает Chromium с необходимыми зависимостями;
- отсутствие браузера не маскируется как пропуск или успех.
5. Не коммитить `test-results`, traces и генерируемые screenshots, кроме отдельно утверждённых visual baselines.
### Этап B. Настоящие E2E-сценарии
Переписать `e2e/app.spec.ts` и при необходимости разделить тесты по файлам. Каждый тест обязан совершать заявленное действие и проверять устойчивый результат.
Минимальный набор:
1. Dashboard открывается без runtime/console/page errors и горизонтального overflow на desktop/tablet/mobile.
2. Переключение темы выполняется через UI, сохраняется после reload и проверяется по фактическому состоянию приложения. Запрещено подменять проверку прямой установкой `data-theme`.
3. CRM: перетащить сделку в другую колонку, проверить изменение стадии и сохранение после reload.
4. CRM: создать лид, конвертировать его в контакт и сделку, проверить появление всех результатов.
5. Сделка `Won`: создать заказ и проверить его в разделе продаж.
6. Заказ: подтверждение → резерв → оплата → отгрузка; проверить изменения склада и финансов.
7. Configuration Engine: создать кастомную сущность, добавить поля разных групп, собрать форму, сохранить запись и проверить её после reload.
8. View Builder: изменить порядок колонок настоящим drag-and-drop и проверить сохранение.
9. RBAC: разрешённый маршрут доступен, запрещённый приводит на корректный 403.
10. Export/import/reset IndexedDB: экспортировать изменённое состояние, сбросить, импортировать и проверить восстановление.
Использовать устойчивые `data-testid` или role/name locators. Не использовать селекторы вида «любой `[draggable=true]`», если тест относится к конкретной сущности.
### Этап C. Полная реализация типов полей
Согласовать реестр типов с нормативным `docs/52-FIELD-TYPES-REFERENCE.md` Business Platform. Один канонический список должен использоваться Builder, renderer, validation, table, card и serialization.
Для каждого заявленного типа реализовать корректные ввод, readonly-отображение, validation и сериализацию. В частности:
- `multiselect` / `multi_select`, `checkbox_group` — массив значений, не строка;
- `rating` — ограниченный числовой контрол;
- `time` — `input[type=time]`;
- `url`, `color` — соответствующие контролы и validation;
- `file`, `image` — mock upload/storage abstraction с metadata и preview, не текстовое поле;
- `address`, `location` — структурированный формат либо явно документированный MVP-контракт;
- `user`, `employee`, `team`, `company`, `contact`, `product`, `entity_relation`, `entity_ref` — selector записей с хранением стабильного ID;
- `formula` — readonly вычисление по безопасному ограниченному выражению; запрещён `eval`/`Function`;
- `autonumber` — readonly значение из отдельного генератора;
- `json` — JSON parse/validation и хранение объекта, а не необработанной строки;
- `rich_text` — безопасный ограниченный редактор/формат; не допускать небезопасный HTML;
- `money` — minor units плюс ISO 4217 currency, без float как доменного значения;
- `decimal` — не смешивать с money; определить сериализацию без неявной потери точности.
Удалить дублирующие псевдонимы типов либо реализовать явную backward-compatible normalization. Добавить parameterized contract tests на каждый тип.
### Этап D. Tenant-aware frontend contracts
Прототип остаётся без backend, но его контракты должны позволять подключить платформу без переписывания UI.
1. Ввести `AppContext`/`RequestContext` как минимум с `tenantId`, `actorId`, `roleIds`, `correlationId`.
2. Все repository/service methods должны принимать контекст явно или через типизированный provider.
3. IndexedDB demo storage должен изолировать данные по `tenantId`:
- составной ключ или tenant-aware ключи/индексы;
- tenant filter обязателен для get/list/update/delete;
- импорт и экспорт относятся к явно выбранному tenant;
- нельзя прочитать/изменить запись другого tenant по известному ID.
4. Добавить тесты tenant isolation для records, metadata, layouts, views, audit и settings.
5. Описать адаптер `mock ↔ REST API`, включая auth/401/403, validation errors, pagination, optimistic concurrency и correlation IDs.
### Этап E. UI-kit и архитектурная очистка
1. Выбрать один канонический путь: предпочтительно `src/shared/ui`.
2. Перевести потребителей со `src/shared/components`, затем удалить дубли без поломки интерфейса.
3. Устранить слишком крупные модули (`CrmModule`, builders) путём выделения экранов, hooks и domain actions. Не делать механическую нарезку без ясных границ.
4. Исправить несоответствие README: `QueryClientProvider` либо реально внедрить при необходимости, либо удалить упоминание.
5. Удалить неиспользуемые зависимости и проверить production bundle. Зафиксировать разумные budget-пороги для initial JS и крупных lazy chunks.
### Этап F. Честная документация готовности
1. Обновить README и `FEATURE-COVERAGE.md` по фактическому результату.
2. Различать:
- готовый интерактивный сценарий;
- target prototype;
- demo-only;
- placeholder;
- ожидает backend.
3. Не писать «100% готово», если проверены только render и наличие кнопки.
4. Число тестов не вводить вручную в badge либо обновлять автоматически.
5. Для каждого модуля указать реально работающие CRUD/actions, persistence, ограничения и непокрытые сценарии.
## 4. Запреты
- Не отключать lint-правила ради зелёного результата.
- Не заменять E2E unit-тестами.
- Не использовать `test.skip`, условный early return или фиктивные проверки для обязательных сценариев.
- Не проверять только наличие элемента, если сценарий заявляет изменение данных.
- Не добавлять фиктивный backend и не привязывать UI напрямую к endpoint без service adapter.
- Не хранить tenant только в визуальном state без фильтрации storage/repositories.
- Не заявлять поддержку типа поля, если он отображается обычным текстовым input.
- Не коммитить секреты, локальные `.local`, build artifacts и Playwright output.
## 5. Приёмочные критерии
Работа принимается, когда:
1. `npm ci` проходит на чистом checkout.
2. `npm run lint` даёт 0 ошибок и 0 предупреждений.
3. TypeScript strict typecheck проходит.
4. Все unit/component tests проходят.
5. Все обязательные Playwright tests реально выполняются в Chromium и проходят на трёх viewport.
6. Production build проходит, а budget-проверка не нарушена.
7. Каждый заявленный тип поля имеет contract test и минимум один integration test renderer → save → reload → readonly.
8. Tenant isolation доказана негативными тестами.
9. DnD проверен мышью и доступной клавиатурной альтернативой.
10. README и coverage matrix соответствуют фактам.
11. Рабочее дерево после проверок чистое.
## 6. Порядок сдачи
1. Работать в ветке `codex/frontend-remediation`.
2. Делать небольшие логические коммиты по этапам A–F.
3. В финальном отчёте приложить:
- список исправлений;
- команды и полный итог проверок;
- количество unit и E2E тестов;
- список сознательно оставшихся ограничений;
- ссылку на PR.
4. Не сливать PR самостоятельно без отдельной команды владельца.

68
deploy/kcc/deploy-kcc.sh Normal file
View file

@ -0,0 +1,68 @@
#!/usr/bin/env bash
# ==============================================================================
# Krona Control Center (KCC) - Atomic Zero-Downtime Deployment Script
# Target: https://kccdemo.ochenstarik.ru
# Target Host: 178.212.13.102 (User: kcc)
# ==============================================================================
set -euo pipefail
APP_ROOT="/srv/kccdemo"
RELEASES_DIR="$APP_ROOT/releases"
CURRENT_LINK="$APP_ROOT/current"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
NEW_RELEASE_DIR="$RELEASES_DIR/$TIMESTAMP"
echo "================================================================="
echo " [KCC Deploy] Initiating atomic release: $TIMESTAMP"
echo "================================================================="
# 1. Create target release directory
mkdir -p "$NEW_RELEASE_DIR"
# 2. Deploy static SPA artifacts
if [ -d "./dist" ]; then
echo "Copying ./dist to $NEW_RELEASE_DIR..."
cp -r ./dist/* "$NEW_RELEASE_DIR/"
elif [ -d "../dist" ]; then
echo "Copying ../dist to $NEW_RELEASE_DIR..."
cp -r ../dist/* "$NEW_RELEASE_DIR/"
else
echo "ERROR: Production build directory 'dist' not found!" >&2
exit 1
fi
# Set proper permissions for kcc user
chmod -R 755 "$NEW_RELEASE_DIR"
# 3. Atomic symlink switch
echo "Switching active release symlink ($CURRENT_LINK -> $NEW_RELEASE_DIR)..."
ln -sfn "$NEW_RELEASE_DIR" "$CURRENT_LINK"
# 4. Prune older releases (keep last 3 for instant rollback)
echo "Cleaning up old releases (retaining last 3)..."
cd "$RELEASES_DIR"
# List matching release directories sorted by date descending, remove all except top 3
ls -1dt 20* 2>/dev/null | tail -n +4 | xargs -r rm -rf
# 5. Reload Nginx
if command -v systemctl >/dev/null 2>&1; then
echo "Reloading Nginx web server..."
sudo systemctl reload nginx || systemctl reload nginx || echo "Nginx reload skipped (no sudo permission)"
fi
# 6. Post-deploy health verification
echo "Executing health verification on local loopback..."
HTTP_STATUS=$(curl -sk -o /dev/null -w "%{http_code}" https://127.0.0.1/control/gate -H "Host: kccdemo.ochenstarik.ru" || true)
echo "Local Healthcheck HTTP Status: $HTTP_STATUS"
if [ "$HTTP_STATUS" = "200" ] || [ "$HTTP_STATUS" = "301" ] || [ "$HTTP_STATUS" = "302" ]; then
echo "=== [KCC Deploy] Health check: PASSED ==="
else
echo "WARNING: Health check returned status $HTTP_STATUS. Please verify Nginx configuration."
fi
echo "================================================================="
echo " [KCC Deploy] Release $TIMESTAMP successfully deployed and active!"
echo " URL: https://kccdemo.ochenstarik.ru"
echo "================================================================="

View file

@ -0,0 +1,78 @@
# ==============================================================================
# Krona Control Center - Nginx Server Block Configuration
# Domain: kccdemo.ochenstarik.ru
# Target Host: 178.212.13.102
# ==============================================================================
# HTTP -> HTTPS redirect
server {
listen 80;
listen [::]:80;
server_name kccdemo.ochenstarik.ru;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# Production HTTPS server
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name kccdemo.ochenstarik.ru;
# SSL Certificate configuration
ssl_certificate /etc/letsencrypt/live/kccdemo.ochenstarik.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/kccdemo.ochenstarik.ru/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# Root pointing to atomic symlink
root /srv/kccdemo/current;
index index.html;
# Security Headers
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: https:; connect-src 'self' ws: wss:; frame-ancestors 'none';" always;
# Compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
# Static assets cache control
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Favicon and robots
location = /favicon.ico {
access_log off;
log_not_found off;
}
# SPA routing fallback: send all routes to index.html
location / {
try_files $uri $uri/ /index.html;
}
# Dedicated access and error logs
access_log /var/log/nginx/kccdemo_access.log;
error_log /var/log/nginx/kccdemo_error.log warn;
}

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 @@
import{f as e,i as t,r as n,s as r}from"./useToast-CS8pZhce.js";import{dt as i,t as a}from"./index-CrJKtV0u.js";import{t as o}from"./types-CTcNpN0z.js";e();var s=r(),c=()=>{let{customCapabilities:e,toggleCapability:r}=i();return(0,s.jsxs)(`div`,{className:`space-y-4 animate-fade-in`,children:[(0,s.jsxs)(`div`,{className:`flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft`,children:[(0,s.jsxs)(`div`,{children:[(0,s.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,s.jsx)(`h2`,{className:`text-sm font-bold text-krona-primary`,children:`Каталог приложений & Модульная экосистема Кроны`}),(0,s.jsx)(a,{level:`demo`,size:`sm`})]}),(0,s.jsx)(`p`,{className:`text-xs text-krona-secondary mt-0.5`,children:`Подключайте и отключайте функциональные блоки для адаптации платформы под новые ниши`})]}),(0,s.jsxs)(n,{variant:`gold`,size:`md`,children:[`Всего модулей: `,o.length]})]}),(0,s.jsx)(`div`,{className:`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3.5`,children:o.map(i=>{let a=e[i.id];return(0,s.jsxs)(`div`,{className:`p-4 rounded-xl border border-krona-border bg-krona-surface shadow-krona-soft flex flex-col justify-between space-y-3`,children:[(0,s.jsxs)(`div`,{children:[(0,s.jsxs)(`div`,{className:`flex items-center justify-between mb-1.5`,children:[(0,s.jsx)(`span`,{className:`text-[10px] uppercase font-bold text-krona-secondary`,children:i.category}),(0,s.jsx)(n,{variant:a?`green`:`neutral`,size:`sm`,children:a?`Активен`:`Отключен`})]}),(0,s.jsx)(`h4`,{className:`text-xs font-bold text-krona-primary`,children:i.name}),(0,s.jsx)(`p`,{className:`text-[11px] text-krona-secondary mt-1 leading-relaxed`,children:i.description})]}),(0,s.jsx)(`div`,{className:`pt-2 border-t border-krona-border/50 flex justify-end`,children:(0,s.jsx)(t,{variant:a?`secondary`:`primary`,size:`sm`,className:`w-full text-xs`,onClick:()=>r(i.id),children:a?`Отключить модуль`:`Включить в контур`})})]},i.id)})})]})};export{c as AppsCatalogModule};

53
dist/assets/AreaChart-tvNE-d9G.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
dist/assets/CmsModule-DuJnf1QM.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

12
dist/assets/CrmModule-l7PoMLhA.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
dist/assets/EntityCatalog-CAg8Pzny.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/FieldBuilder-CLdLCeVX.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/FinanceModule-D-3_6PUg.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/FormBuilder-d4z6JEl5.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
dist/assets/SalesModule-Cf0nSO8B.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
dist/assets/TasksModule-D4E78liD.js vendored Normal file

File diff suppressed because one or more lines are too long

2
dist/assets/ViewBuilder-zR702NRL.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/arrow-down-CWbiHuZO.js vendored Normal file
View file

@ -0,0 +1 @@
import{l as e}from"./useToast-CS8pZhce.js";var t={name:`arrow-down`,size:24,node:[[`path`,{d:`M12 5v14`,key:`s699le`}],[`path`,{d:`m19 12-7 7-7-7`,key:`1idqje`}]]};t.node;var n=e(t);export{n as t};

1
dist/assets/arrow-up-B4Ca-HNG.js vendored Normal file
View file

@ -0,0 +1 @@
import{l as e}from"./useToast-CS8pZhce.js";var t={name:`arrow-up`,size:24,node:[[`path`,{d:`m5 12 7-7 7 7`,key:`hav0vg`}],[`path`,{d:`M12 19V5`,key:`x0mq9r`}]]};t.node;var n=e(t);export{n as t};

1
dist/assets/arrow-up-down--M136cK3.js vendored Normal file
View file

@ -0,0 +1 @@
import{l as e}from"./useToast-CS8pZhce.js";var t={name:`arrow-up-down`,size:24,node:[[`path`,{d:`m21 16-4 4-4-4`,key:`f6ql7i`}],[`path`,{d:`M17 20V4`,key:`1ejh1v`}],[`path`,{d:`m3 8 4-4 4 4`,key:`11wl7u`}],[`path`,{d:`M7 4v16`,key:`1glfcx`}]]};t.node;var n=e(t);export{n as t};

1
dist/assets/building-BdUpGLM5.js vendored Normal file
View file

@ -0,0 +1 @@
import{l as e}from"./useToast-CS8pZhce.js";var t={name:`building`,size:24,node:[[`path`,{d:`M12 10h.01`,key:`1nrarc`}],[`path`,{d:`M12 14h.01`,key:`1etili`}],[`path`,{d:`M12 6h.01`,key:`1vi96p`}],[`path`,{d:`M16 10h.01`,key:`1m94wz`}],[`path`,{d:`M16 14h.01`,key:`1gbofw`}],[`path`,{d:`M16 6h.01`,key:`1x0f13`}],[`path`,{d:`M8 10h.01`,key:`19clt8`}],[`path`,{d:`M8 14h.01`,key:`6423bh`}],[`path`,{d:`M8 6h.01`,key:`1dz90k`}],[`path`,{d:`M9 22v-3a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3`,key:`cabbwy`}],[`rect`,{x:`4`,y:`2`,width:`16`,height:`20`,rx:`2`,key:`1uxh74`}]]};t.node;var n=e(t);export{n as t};

1
dist/assets/currency-DdDs82F5.js vendored Normal file
View file

@ -0,0 +1 @@
var e={RUB:{code:`RUB`,symbol:`₽`,decimals:2,name:`Российский рубль`},USD:{code:`USD`,symbol:`$`,decimals:2,name:`Доллар США`},EUR:{code:`EUR`,symbol:`€`,decimals:2,name:`Евро`},KZT:{code:`KZT`,symbol:`₸`,decimals:2,name:`Казахстанский тенге`},BYN:{code:`BYN`,symbol:`Br`,decimals:2,name:`Белорусский рубль`},CNY:{code:`CNY`,symbol:`¥`,decimals:2,name:`Китайский юань`}};function t(t,n=`RUB`){return t/10**(e[n.toUpperCase()]||{decimals:2}).decimals}function n(t,n=`RUB`){let r=10**(e[n.toUpperCase()]||{decimals:2}).decimals;return Math.round(t*r)}function r(n,r=`RUB`,i){let a=r.toUpperCase(),o=e[a]||{decimals:2,symbol:a},s=i?.locale||`ru-RU`,c=t(n,a);return new Intl.NumberFormat(s,{style:i?.showSymbol===!1?`decimal`:`currency`,currency:a,minimumFractionDigits:i?.trimZeroDecimals&&c%1==0?0:o.decimals,maximumFractionDigits:o.decimals}).format(c)}function i(e,t=`RUB`){if(!e)return 0;let r=e.replace(/\s+/g,``).replace(`,`,`.`).replace(/[^0-9.-]/g,``),i=parseFloat(r);return isNaN(i)?0:n(i,t)}var a=r,o=i;export{o as n,a as t};

1
dist/assets/demo-D1nHL60J.js vendored Normal file
View file

@ -0,0 +1 @@
import{f as e,h as t}from"./useToast-CS8pZhce.js";var n=t(e(),1),r=e=>{let t,n=new Set,r=(e,r)=>{let i=typeof e==`function`?e(t):e;if(!Object.is(i,t)){let e=t;t=r??(typeof i!=`object`||!i)?i:Object.assign({},t,i),n.forEach(n=>n(t,e))}},i=()=>t,a={setState:r,getState:i,getInitialState:()=>o,subscribe:e=>(n.add(e),()=>n.delete(e))},o=t=e(r,i,a);return a},i=(e=>e?r(e):r),a=e=>e;function o(e,t=a){let r=n.useSyncExternalStore(e.subscribe,n.useCallback(()=>t(e.getState()),[e,t]),n.useCallback(()=>t(e.getInitialState()),[e,t]));return n.useDebugValue(r),r}var s=e=>{let t=i(e),n=e=>o(t,e);return Object.assign(n,t),n},c=(e=>e?s(e):s),l=`2026-09-10T10:00:00.000Z`,u=c(e=>({currentProfileId:typeof window<`u`&&localStorage.getItem(`krona_profile_id`)||`multi-business`,currentRoleId:typeof window<`u`&&localStorage.getItem(`krona_role_id`)||`owner`,simulateLatency:!1,latencyMs:300,simulateErrors:!1,errorType:`none`,demoDate:l,stateOverride:`normal`,showMaturityBadges:!0,selectedScenario:`retail-flow`,setCurrentProfileId:t=>{typeof window<`u`&&localStorage.setItem(`krona_profile_id`,t),e({currentProfileId:t})},setCurrentRoleId:t=>{typeof window<`u`&&localStorage.setItem(`krona_role_id`,t),e({currentRoleId:t})},setSimulateLatency:t=>e({simulateLatency:t}),setLatencyMs:t=>e({latencyMs:t}),setSimulateErrors:t=>e({simulateErrors:t}),setErrorType:t=>e({errorType:t,simulateErrors:t!==`none`}),setDemoDate:t=>e({demoDate:t}),advanceDemoHours:t=>e(e=>{let n=new Date(e.demoDate).getTime();return{demoDate:new Date(n+t*3600*1e3).toISOString()}}),setStateOverride:t=>e({stateOverride:t}),setShowMaturityBadges:t=>e({showMaturityBadges:t}),setSelectedScenario:t=>e({selectedScenario:t}),resetDemoControls:()=>e({currentProfileId:`multi-business`,currentRoleId:`owner`,simulateLatency:!1,latencyMs:300,simulateErrors:!1,errorType:`none`,demoDate:l,stateOverride:`normal`,showMaturityBadges:!0,selectedScenario:`retail-flow`})}));export{c as n,u as t};

1
dist/assets/dollar-sign-Bf6JneNn.js vendored Normal file
View file

@ -0,0 +1 @@
import{l as e}from"./useToast-CS8pZhce.js";var t={name:`dollar-sign`,size:24,node:[[`line`,{x1:`12`,x2:`12`,y1:`2`,y2:`22`,key:`7eqyqh`}],[`path`,{d:`M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6`,key:`1b0p4s`}]]};t.node;var n=e(t);export{n as t};

1
dist/assets/eye-CkF1gwUX.js vendored Normal file
View file

@ -0,0 +1 @@
import{l as e}from"./useToast-CS8pZhce.js";var t={name:`eye`,size:24,node:[[`path`,{d:`M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0`,key:`1nclc0`}],[`circle`,{cx:`12`,cy:`12`,r:`3`,key:`1v7zrd`}]]};t.node;var n=e(t);export{n as t};

File diff suppressed because one or more lines are too long

1
dist/assets/file-text-DR21jZDS.js vendored Normal file
View file

@ -0,0 +1 @@
import{l as e}from"./useToast-CS8pZhce.js";var t={name:`file-text`,size:24,node:[[`path`,{d:`M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z`,key:`1oefj6`}],[`path`,{d:`M14 2v5a1 1 0 0 0 1 1h5`,key:`wfsgrz`}],[`path`,{d:`M10 9H8`,key:`b1mrlr`}],[`path`,{d:`M16 13H8`,key:`t4e002`}],[`path`,{d:`M16 17H8`,key:`z1uh3a`}]]};t.node;var n=e(t);export{n as t};

1
dist/assets/formatters-WvGQLZg0.js vendored Normal file
View file

@ -0,0 +1 @@
function e(e){return new Intl.NumberFormat(`ru-RU`,{style:`currency`,currency:`RUB`,maximumFractionDigits:0}).format(e)}function t(e){return new Intl.NumberFormat(`ru-RU`).format(e)}export{t as n,e as t};

1
dist/assets/image-B_RO1I7c.js vendored Normal file
View file

@ -0,0 +1 @@
import{l as e}from"./useToast-CS8pZhce.js";var t={name:`image`,size:24,node:[[`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`,ry:`2`,key:`1m3agn`}],[`circle`,{cx:`9`,cy:`9`,r:`2`,key:`af1f0g`}],[`path`,{d:`m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21`,key:`1xmnt7`}]]};t.node;var n=e(t);export{n as t};

1
dist/assets/index-Bb0d9GEE.css vendored Normal file

File diff suppressed because one or more lines are too long

45
dist/assets/index-CrJKtV0u.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/indexedDb-D30TbhA6.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/link-DOg0dLYA.js vendored Normal file
View file

@ -0,0 +1 @@
import{l as e}from"./useToast-CS8pZhce.js";var t={name:`link`,size:24,node:[[`path`,{d:`M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71`,key:`1cjeqo`}],[`path`,{d:`M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71`,key:`19qd67`}]]};t.node;var n=e(t);export{n as t};

1
dist/assets/list-XDoRqfJ2.js vendored Normal file
View file

@ -0,0 +1 @@
import{l as e}from"./useToast-CS8pZhce.js";var t={name:`list`,size:24,node:[[`path`,{d:`M3 5h.01`,key:`18ugdj`}],[`path`,{d:`M3 12h.01`,key:`nlz23k`}],[`path`,{d:`M3 19h.01`,key:`noohij`}],[`path`,{d:`M8 5h13`,key:`1pao27`}],[`path`,{d:`M8 12h13`,key:`1za7za`}],[`path`,{d:`M8 19h13`,key:`m83p4d`}]]};t.node;var n=e(t);export{n as t};

1
dist/assets/mail-DOD5KO1v.js vendored Normal file
View file

@ -0,0 +1 @@
import{l as e}from"./useToast-CS8pZhce.js";var t={name:`mail`,size:24,node:[[`path`,{d:`m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7`,key:`132q7q`}],[`rect`,{x:`2`,y:`4`,width:`20`,height:`16`,rx:`2`,key:`izxlao`}]]};t.node;var n=e(t);export{n as t};

1
dist/assets/mockData-DAqQG5te.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/move-up-qiRY8MuU.js vendored Normal file
View file

@ -0,0 +1 @@
import{l as e}from"./useToast-CS8pZhce.js";var t={name:`move-down`,size:24,node:[[`path`,{d:`M8 18L12 22L16 18`,key:`cskvfv`}],[`path`,{d:`M12 2V22`,key:`r89rzk`}]]};t.node;var n=e(t),r={name:`move-up`,size:24,node:[[`path`,{d:`M8 6L12 2L16 6`,key:`1yvkyx`}],[`path`,{d:`M12 2V22`,key:`r89rzk`}]]};r.node;var i=e(r);export{n,i as t};

View file

@ -0,0 +1 @@
import{l as e}from"./useToast-CS8pZhce.js";var t={name:`panels-top-left`,size:24,node:[[`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`,key:`afitv7`}],[`path`,{d:`M3 9h18`,key:`1pudct`}],[`path`,{d:`M9 21V9`,key:`1oto5p`}]],aliases:[`layout`]};t.node;var n=e(t);export{n as t};

1
dist/assets/pen-D8SS70u3.js vendored Normal file
View file

@ -0,0 +1 @@
import{l as e}from"./useToast-CS8pZhce.js";var t={name:`pen`,size:24,node:[[`path`,{d:`M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z`,key:`1a8usu`}]],aliases:[`edit-2`]};t.node;var n=e(t);export{n as t};

1
dist/assets/phone-DpnQw4if.js vendored Normal file
View file

@ -0,0 +1 @@
import{l as e}from"./useToast-CS8pZhce.js";var t={name:`phone`,size:24,node:[[`path`,{d:`M13.832 16.568a1 1 0 0 0 1.213-.303l.355-.465A2 2 0 0 1 17 15h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2A18 18 0 0 1 2 4a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-.8 1.6l-.468.351a1 1 0 0 0-.292 1.233 14 14 0 0 0 6.392 6.384`,key:`9njp5v`}]]};t.node;var n=e(t);export{n as t};

1
dist/assets/save-D8euL60I.js vendored Normal file
View file

@ -0,0 +1 @@
import{l as e}from"./useToast-CS8pZhce.js";var t={name:`save`,size:24,node:[[`path`,{d:`M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z`,key:`1c8476`}],[`path`,{d:`M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7`,key:`1ydtos`}],[`path`,{d:`M7 3v4a1 1 0 0 0 1 1h7`,key:`t51u73`}]]};t.node;var n=e(t);export{n as t};

1
dist/assets/settings-2-CSojebrG.js vendored Normal file
View file

@ -0,0 +1 @@
import{l as e}from"./useToast-CS8pZhce.js";var t={name:`settings-2`,size:24,node:[[`path`,{d:`M14 17H5`,key:`gfn3mx`}],[`path`,{d:`M19 7h-9`,key:`6i9tg`}],[`circle`,{cx:`17`,cy:`17`,r:`3`,key:`18b49y`}],[`circle`,{cx:`7`,cy:`7`,r:`3`,key:`dfmy0x`}]]};t.node;var n=e(t);export{n as t};

1
dist/assets/star-B6hvSjuv.js vendored Normal file
View file

@ -0,0 +1 @@
import{l as e}from"./useToast-CS8pZhce.js";var t={name:`star`,size:24,node:[[`path`,{d:`M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z`,key:`r04s7s`}]]};t.node;var n=e(t);export{n as t};

1
dist/assets/table-_fqSCPRE.js vendored Normal file
View file

@ -0,0 +1 @@
import{l as e}from"./useToast-CS8pZhce.js";var t={name:`table`,size:24,node:[[`path`,{d:`M12 3v18`,key:`108xh3`}],[`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`,key:`afitv7`}],[`path`,{d:`M3 9h18`,key:`1pudct`}],[`path`,{d:`M3 15h18`,key:`5xshup`}]]};t.node;var n=e(t);export{n as t};

1
dist/assets/truck-D5o8mTsH.js vendored Normal file
View file

@ -0,0 +1 @@
import{l as e}from"./useToast-CS8pZhce.js";var t={name:`truck`,size:24,node:[[`path`,{d:`M14 18V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v11a1 1 0 0 0 1 1h2`,key:`wrbu53`}],[`path`,{d:`M15 18H9`,key:`1lyqi6`}],[`path`,{d:`M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.624l-3.48-4.35A1 1 0 0 0 17.52 8H14`,key:`lysw3i`}],[`circle`,{cx:`17`,cy:`18`,r:`2`,key:`332jqn`}],[`circle`,{cx:`7`,cy:`18`,r:`2`,key:`19iecd`}]]};t.node;var n=e(t);export{n as t};

1
dist/assets/types-CTcNpN0z.js vendored Normal file
View file

@ -0,0 +1 @@
var e=[{id:`crm`,name:`CRM & Клиенты`,description:`Управление клиентами, лидами, сделками и воронками`,category:`core`},{id:`sales`,name:`Продажи & Заказы`,description:`Обработка заказов, счетов и оплат`,category:`core`},{id:`communications`,name:`Омниканальные коммуникации`,description:`Единый inbox: Telegram, WhatsApp, MAX, VK, почта`,category:`core`},{id:`helpdesk`,name:`HelpDesk & Поддержка`,description:`Тикеты, SLA, база знаний и решение инцидентов`,category:`core`},{id:`tasks`,name:`Задачи & Проекты`,description:`Канбан, списки задач, спринты и контроль сроков`,category:`management`},{id:`cms`,name:`Сайты & Krona CMS`,description:`Конструктор страниц, блог, формы, SEO и витрины`,category:`growth`},{id:`marketing`,name:`Маркетинг & Рассылки`,description:`Контент-план, акции, рассылки, AI-маркетолог`,category:`growth`},{id:`analytics`,name:`Аналитика & BI`,description:`Сквозная аналитика, финансовые отчеты и дашборды`,category:`management`},{id:`finance`,name:`Финансы & ДДС`,description:`Доходы, расходы, кассовые смены, P&L и казначейство`,category:`management`},{id:`employees`,name:`Сотрудники & Смены`,description:`Графики, учет рабочего времени, роли и KPI`,category:`management`},{id:`inventory`,name:`Склад & Остатки`,description:`Остатки, партии, списания, перемещения, инвентаризация`,category:`operations`},{id:`purchasing`,name:`Закупки & Поставщики`,description:`Заказы поставщикам, приходные накладные, сверки`,category:`operations`},{id:`delivery`,name:`Доставка & Курьеры`,description:`Маршруты, статусы курьеров, расчет зон и стоимости`,category:`operations`},{id:`booking`,name:`Бронирование & Слоты`,description:`Запись клиентов, онлайн-слоты, бронь ресурсов`,category:`operations`},{id:`pos`,name:`Кассовый терминал (POS)`,description:`Быстрое оформление чеков, скидки, фискализация`,category:`operations`},{id:`kds`,name:`Кухонный экран (KDS)`,description:`Дисплей кухни/сборки с таймерами и статусами`,category:`operations`},{id:`dining`,name:`Зал & Столы`,description:`Схема залов, рассадка, открытые чеки и официанты`,category:`operations`},{id:`work_orders`,name:`Заказ-наряды`,description:`Ремонтные работы, материалы, посты и нормо-часы`,category:`operations`},{id:`production`,name:`Производство & Техкарты`,description:`Рецептуры, списание сырья, калькуляция себестоимости`,category:`operations`},{id:`automation`,name:`Автоматизация & Workflows`,description:`Триггеры, цепочки действий, сценарии без кода`,category:`growth`},{id:`ai`,name:`Krona AI Center`,description:`AI-команда: оркестратор, бизнес-аналитик, маркетолог`,category:`growth`}];export{e as t};

1
dist/assets/types-DMl_5q2N.js vendored Normal file
View file

@ -0,0 +1 @@
import{l as e}from"./useToast-CS8pZhce.js";var t={name:`binary`,size:24,node:[[`rect`,{x:`14`,y:`14`,width:`4`,height:`6`,rx:`2`,key:`p02svl`}],[`rect`,{x:`6`,y:`4`,width:`4`,height:`6`,rx:`2`,key:`xm4xkj`}],[`path`,{d:`M6 20h4`,key:`1i6q5t`}],[`path`,{d:`M14 10h4`,key:`ru81e7`}],[`path`,{d:`M6 14h2v6`,key:`16z9wg`}],[`path`,{d:`M14 4h2v6`,key:`1idq9u`}]]};t.node;var n=e(t),r={name:`code`,size:24,node:[[`path`,{d:`m16 18 6-6-6-6`,key:`eg8j8`}],[`path`,{d:`m8 6-6 6 6 6`,key:`ppft3o`}]]};r.node;var i=e(r),a={name:`navigation`,size:24,node:[[`polygon`,{points:`3 11 22 2 13 21 11 13 3 11`,key:`1ltx0t`}]]};a.node;var o=e(a),s=`text.long_text.rich_text.integer.decimal.money.percentage.boolean.select.multi_select.radio.checkbox_group.status.rating.date.datetime.time.phone.email.url.address.location.file.image.color.user.employee.team.company.contact.product.entity_relation.formula.autonumber.json`.split(`.`);function c(e){let t=(e||``).trim().toLowerCase();switch(t){case`number`:case`int`:return`integer`;case`float`:case`double`:return`decimal`;case`currency`:return`money`;case`percent`:return`percentage`;case`single_select`:return`select`;case`multiselect`:case`multi-select`:case`tags`:return`multi_select`;case`entity_ref`:case`lookup`:case`relation`:case`reference`:return`entity_relation`;case`bool`:return`boolean`;case`link`:return`url`;default:return s.includes(t)?t:`text`}}export{n as i,o as n,i as r,c as t};

1
dist/assets/upload-CY2IsfiN.js vendored Normal file
View file

@ -0,0 +1 @@
import{l as e}from"./useToast-CS8pZhce.js";var t={name:`upload`,size:24,node:[[`path`,{d:`M12 3v12`,key:`1x0j5s`}],[`path`,{d:`m17 8-5-5-5 5`,key:`7q97r8`}],[`path`,{d:`M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4`,key:`ih7n3h`}]]};t.node;var n=e(t);export{n as t};

1
dist/assets/useInventory-C7Fo8GE1.js vendored Normal file
View file

@ -0,0 +1 @@
import{f as e,h as t}from"./useToast-CS8pZhce.js";import{rt as n,st as r}from"./index-CrJKtV0u.js";var i=t(e(),1);function a(){let[e,t]=(0,i.useState)([]),[a,o]=(0,i.useState)([]),[s,c]=(0,i.useState)([]),[l,u]=(0,i.useState)([]),[d,f]=(0,i.useState)(!0),[p,m]=(0,i.useState)(null),h=(0,i.useCallback)(async()=>{try{f(!0),m(null);let[e,n,i,a]=await Promise.all([r.warehouses.list({pageSize:50}),r.stockItems.list({pageSize:200}),r.movements.list({pageSize:100}),r.receipts.list({pageSize:100})]);t(e.items),o(n.items),c(i.items),u(a.items)}catch(e){m(e instanceof Error?e:Error(String(e)))}finally{f(!1)}},[]);return(0,i.useEffect)(()=>{let e=!0;return Promise.resolve().then(()=>{e&&h()}),()=>{e=!1}},[h]),n(`records:changed`,e=>{(e.entityType===`warehouse`||e.entityType===`stock_item`||e.entityType===`stock_movement`||e.entityType===`goods_receipt`||e.entityType===`stock_reservation`)&&h()}),n(`storage:reset`,h),n(`snapshot:imported`,h),{warehouses:e,stockItems:a,movements:s,receipts:l,loading:d,error:p,refetch:h,adjustStock:(0,i.useCallback)(async(e,t,n,i)=>r.adjustStock(e,t,n,i),[]),reserveStock:(0,i.useCallback)(async(e,t,n,i)=>r.reserveStock(e,t,n,i),[]),cancelOrderReservations:(0,i.useCallback)(async e=>r.cancelOrderReservations(e),[]),receiveGoods:(0,i.useCallback)(async e=>r.receiveGoods(e),[]),createAndReceiveGoods:(0,i.useCallback)(async(e,t,n,i)=>r.createAndReceiveGoods(e,t,n,i),[])}}export{a as t};

1
dist/assets/useOrders-TRZWcao8.js vendored Normal file
View file

@ -0,0 +1 @@
import{f as e,h as t}from"./useToast-CS8pZhce.js";import{it as n,rt as r}from"./index-CrJKtV0u.js";var i=t(e(),1);function a(){let[e,t]=(0,i.useState)([]),[a,o]=(0,i.useState)([]),[s,c]=(0,i.useState)([]),[l,u]=(0,i.useState)([]),[d,f]=(0,i.useState)(!0),[p,m]=(0,i.useState)(null),h=(0,i.useCallback)(async()=>{try{f(!0),m(null);let[e,r,i,a]=await Promise.all([n.orders.list({pageSize:100}),n.products.list({pageSize:100}),n.payments.list({pageSize:100}),n.shipments.list({pageSize:100})]);t(e.items),o(r.items),c(i.items),u(a.items)}catch(e){m(e instanceof Error?e:Error(String(e)))}finally{f(!1)}},[]);return(0,i.useEffect)(()=>{let e=!0;return Promise.resolve().then(()=>{e&&h()}),()=>{e=!1}},[h]),r(`records:changed`,e=>{(e.entityType===`order`||e.entityType===`product`||e.entityType===`payment`||e.entityType===`shipment`)&&h()}),r(`storage:reset`,h),r(`snapshot:imported`,h),{orders:e,products:a,payments:s,shipments:l,loading:d,error:p,refetch:h,createOrder:(0,i.useCallback)(async e=>n.orders.create(e),[]),updateStatus:(0,i.useCallback)(async(e,t)=>n.updateOrderStatus(e,t),[]),confirmOrder:(0,i.useCallback)(async(e,t)=>n.confirmOrder(e,t),[]),cancelOrder:(0,i.useCallback)(async e=>n.cancelOrder(e),[]),createPayment:(0,i.useCallback)(async(e,t,r,i=!0)=>n.createPayment(e,t,r,i),[]),createShipment:(0,i.useCallback)(async(e,t,r)=>n.createShipment(e,t,r),[]),processReturn:(0,i.useCallback)(async(e,t,r,i=`wh_main`,a=!0)=>n.processReturn(e,t,r,i,a),[])}}export{a as t};

1
dist/assets/useToast-CS8pZhce.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/user-CuEEaB8a.js vendored Normal file
View file

@ -0,0 +1 @@
import{l as e}from"./useToast-CS8pZhce.js";var t={name:`user`,size:24,node:[[`path`,{d:`M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2`,key:`975kel`}],[`circle`,{cx:`12`,cy:`7`,r:`4`,key:`17ys0d`}]]};t.node;var n=e(t);export{n as t};

1
dist/assets/utensils-BhILG_B7.js vendored Normal file
View file

@ -0,0 +1 @@
import{l as e}from"./useToast-CS8pZhce.js";var t={name:`utensils`,size:24,node:[[`path`,{d:`M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2`,key:`cjf0a3`}],[`path`,{d:`M7 2v20`,key:`1473qp`}],[`path`,{d:`M21 15V2a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3Zm0 0v7`,key:`j28e5`}]],aliases:[`fork-knife`]};t.node;var n=e(t);export{n as t};

BIN
dist/branding/krona-logo.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
dist/branding/krona-symbol.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

1
dist/favicon.svg vendored Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
dist/icons.svg vendored Normal file
View file

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

20
dist/index.html vendored Normal file
View file

@ -0,0 +1,20 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/branding/krona-symbol.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Крона — Universal Business OS</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-CrJKtV0u.js"></script>
<link rel="modulepreload" crossorigin href="/assets/useToast-CS8pZhce.js">
<link rel="modulepreload" crossorigin href="/assets/demo-D1nHL60J.js">
<link rel="modulepreload" crossorigin href="/assets/indexedDb-D30TbhA6.js">
<link rel="stylesheet" crossorigin href="/assets/index-Bb0d9GEE.css">
</head>
<body class="bg-krona-app text-krona-primary antialiased selection:bg-krona-gold/20 selection:text-krona-gold-text">
<div id="root"></div>
</body>
</html>

388
docs/REST-API-ADAPTER.md Normal file
View file

@ -0,0 +1,388 @@
# Krona Universal Business OS — REST API Adapter Specification
This document defines the architectural contract, header mappings, error translations, query serialization protocols, and the production `HttpBaseRepository` implementation required to transition the Krona Universal Business OS from client-side IndexedDB mock storage to a production-grade multi-tenant RESTful backend.
---
## 1. Architectural Overview
The Krona platform follows a strict **Contract-First Hexagonal Architecture**. Frontend components, view renderers, and business logic interact exclusively with abstract contracts defined in `src/services/contracts/`:
```
┌────────────────────────────────────────────────────────┐
│ React UI Layer │
│ (DynamicCardRenderer, DynamicTable, Kanban, etc.) │
└───────────────────────────┬────────────────────────────┘
│
IRepository<T> Contract
│
┌───────────────┴───────────────┐
▼ ▼
┌───────────────────────┐ ┌───────────────────────┐
│ BaseMockRepository │ │ HttpBaseRepository │
│ (IndexedDB Mock) │ │ (REST API Client) │
└───────────────────────┘ └───────────────────────┘
```
By decoupling storage from presentation, the transition from client-side prototype to cloud backend requires **zero changes** to UI components, hooks, or state managers. The application simply swaps the repository provider at runtime or build-time.
---
## 2. RequestContext & Multi-Tenancy Protocol
Every read and write operation requires a `RequestContext`. The context guarantees multi-tenant storage isolation, auditability, and distributed tracing.
```typescript
export interface RequestContext {
tenantId: string; // Tenant / Workspace identifier (e.g. 'tenant_main', 'tenant_alpha')
actorId: string; // Authenticated user ID (e.g. 'usr_01M26B3...')
roleIds: string[]; // Active role keys (e.g. ['owner', 'director'])
correlationId: string; // Monotonic ULID tracking the distributed call across microservices
}
```
In the frontend, `RequestContext` is managed globally by `TenantProvider` (`useTenant()`) and created with default fallbacks via `createRequestContext()`.
---
## 3. HTTP Header Mapping
When `HttpBaseRepository` communicates with the backend, it maps the `RequestContext` and authentication state into standardized HTTP headers:
| Context Attribute | HTTP Header | Description / Format | Example |
| :--- | :--- | :--- | :--- |
| `tenantId` | `X-Tenant-ID` | The target workspace ID. Mandatory for tenant routing & RLS. | `tenant_alpha` |
| `actorId` | `X-Actor-ID` | Authenticated actor/user ID. Used for audit logging & ACL. | `usr_01HX98Z...` |
| `roleIds` | `X-Role-IDs` | Comma-separated active role identifiers for RBAC/ABAC. | `admin,operations_head` |
| `correlationId` | `X-Correlation-ID` | Monotonic ULID for end-to-end distributed tracing. | `01M26B3GB2WERA5TANF94KRJAM` |
| Auth State | `Authorization` | Standard JWT bearer token for identity verification. | `Bearer eyJhbGci...` |
### Security & Invariant Rules
1. **Tenant Header Enforcement**: If `X-Tenant-ID` is missing, the API Gateway immediately rejects the request with `400 Bad Request`.
2. **Cross-Tenant Verification**: The backend validates that the actor in `Authorization` has permissions within `X-Tenant-ID`. Unauthorized access yields `403 Forbidden`.
3. **Correlation Propagation**: The backend must echo `X-Correlation-ID` in all response headers and forward it across all asynchronous queues and downstream microservices.
---
## 4. Query Serialization Protocol
`IRepository<T>.list(options?: QueryOptions<T>)` accepts pagination, search, sorting, and field-level filters. The HTTP adapter serializes these into URL query parameters according to the following specification:
### 4.1. Pagination
- **Format**: `?page=<number>&limit=<pageSize>`
- **Example**: `?page=2&limit=50` (1-indexed page)
### 4.2. Sorting
- **Format**: `?sort=<fieldName>:<asc|desc>`
- **Multiple Fields**: `?sort=<field1>:asc,<field2>:desc`
- **Example**: `?sort=updatedAt:desc`
### 4.3. Global Text Search
- **Format**: `?search=<query>`
- **Example**: `?search=ООО+Вектор`
### 4.4. Field Filtering
- **Format**: `?filter[<fieldKey>]=<value>`
- **Operator-based Filtering**: `?filter[<fieldKey>][<operator>]=<value>`
- **Supported Operators**: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `contains`
- **Example**:
```
GET /api/v1/deal?page=1&limit=25&sort=createdAt:desc&filter[status]=won&filter[budget][gte]=1000000
```
---
## 5. Standard HTTP Error Code Translations
The frontend contracts define domain error types (`TenantAccessDeniedError`, `NotFoundError`). `HttpBaseRepository` maps HTTP response status codes into typed domain errors:
| Status Code | Backend Condition | Frontend Domain Error | UI Handling Strategy |
| :--- | :--- | :--- | :--- |
| **401 Unauthorized** | Missing or expired JWT token | `AuthenticationRequiredError` | Clears token, redirects to `/login` with return URL. |
| **403 Forbidden** | Cross-tenant access attempt or insufficient roles | `TenantAccessDeniedError` | Displays access-denied banner; prevents destructive UI actions. |
| **404 Not Found** | Record not found in current tenant | `NotFoundError` / `null` | Returns `null` on `getById`; surfaces 404 page for route views. |
| **409 Conflict** | Optimistic locking violation (`version` mismatch) | `ConcurrencyConflictError` | Prompts user with "Data changed by another user" reload dialog. |
| **422 Unprocessable** | Payload validation failed against field metadata | `ValidationError` | Maps backend field errors directly to React Hook Form field state. |
| **5xx / Network** | Gateway timeout or server failure | `ServiceUnavailableError` | TanStack Query automatically retries with exponential backoff. |
---
## 6. Concrete Implementation: `HttpBaseRepository<T>`
Below is the production-ready TypeScript implementation of `HttpBaseRepository<T>`, implementing `IRepository<T>`:
```typescript
import {
IRepository,
QueryOptions,
PagedResult,
QueryFilter,
} from '../contracts/repository';
import {
RequestContext,
TenantAccessDeniedError,
NotFoundError,
createRequestContext,
} from '../contracts/context';
import { BaseEntity } from '../../entities/types';
import { ulid } from '../../shared/lib/ulid';
export class HttpBaseRepository<T extends BaseEntity> implements IRepository<T> {
protected baseUrl: string;
constructor(
protected entityName: string,
baseUrl = import.meta.env.VITE_API_BASE_URL || '/api/v1'
) {
this.baseUrl = `${baseUrl.replace(/\/$/, '')}/${entityName}`;
}
protected resolveContext(context?: RequestContext): RequestContext {
return context || createRequestContext();
}
protected getAuthToken(): string | null {
return typeof window !== 'undefined' ? localStorage.getItem('krona_auth_token') : null;
}
protected buildHeaders(context: RequestContext): HeadersInit {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Tenant-ID': context.tenantId,
'X-Actor-ID': context.actorId,
'X-Role-IDs': context.roleIds.join(','),
'X-Correlation-ID': context.correlationId || ulid(),
};
const token = this.getAuthToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}
protected async handleError(response: Response, context: RequestContext): Promise<never> {
const status = response.status;
let payload: { message?: string; details?: unknown } = {};
try {
payload = await response.json();
} catch {
payload = { message: response.statusText };
}
const message = payload.message || response.statusText;
switch (status) {
case 401:
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('krona:auth-expired'));
}
throw new Error(`[401 Unauthorized]: ${message}`);
case 403:
throw new TenantAccessDeniedError(
`[403 Forbidden]: Отказано в доступе к «${this.entityName}» в тенанте «${context.tenantId}»: ${message}`
);
case 404:
throw new NotFoundError(
`[404 Not Found]: Запись «${this.entityName}» не найдена: ${message}`
);
case 409:
throw new Error(
`[409 Conflict]: Конфликт параллельного обновления записи «${this.entityName}»: ${message}`
);
case 422:
throw new Error(
`[422 Validation Error]: Ошибка валидации структуры записи «${this.entityName}»: ${message}`
);
default:
throw new Error(`[${status} HTTP Error]: ${message}`);
}
}
protected serializeQueryParams(options?: QueryOptions<T>): string {
if (!options) return '';
const params = new URLSearchParams();
if (options.page) params.set('page', String(options.page));
if (options.pageSize) params.set('limit', String(options.pageSize));
if (options.search) params.set('search', options.search);
if (options.sort) {
params.set('sort', `${String(options.sort.field)}:${options.sort.order}`);
}
if (options.filter) {
for (const [key, value] of Object.entries(options.filter)) {
if (value !== undefined && value !== null && value !== '') {
if (typeof value === 'object' && !Array.isArray(value)) {
for (const [op, opVal] of Object.entries(value as Record<string, unknown>)) {
params.set(`filter[${key}][${op}]`, String(opVal));
}
} else {
params.set(`filter[${key}]`, String(value));
}
}
}
}
const queryString = params.toString();
return queryString ? `?${queryString}` : '';
}
async getById(id: string, context?: RequestContext): Promise<T | null> {
const ctx = this.resolveContext(context);
const response = await fetch(`${this.baseUrl}/${encodeURIComponent(id)}`, {
method: 'GET',
headers: this.buildHeaders(ctx),
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
await this.handleError(response, ctx);
}
return (await response.json()) as T;
}
async list(options?: QueryOptions<T>, context?: RequestContext): Promise<PagedResult<T>> {
const ctx = this.resolveContext(context);
const query = this.serializeQueryParams(options);
const response = await fetch(`${this.baseUrl}${query}`, {
method: 'GET',
headers: this.buildHeaders(ctx),
});
if (!response.ok) {
await this.handleError(response, ctx);
}
return (await response.json()) as PagedResult<T>;
}
async create(data: Omit<T, 'id' | 'audit'>, context?: RequestContext): Promise<T> {
const ctx = this.resolveContext(context);
const response = await fetch(this.baseUrl, {
method: 'POST',
headers: this.buildHeaders(ctx),
body: JSON.stringify(data),
});
if (!response.ok) {
await this.handleError(response, ctx);
}
return (await response.json()) as T;
}
async update(id: string, data: Partial<T>, context?: RequestContext): Promise<T> {
const ctx = this.resolveContext(context);
const response = await fetch(`${this.baseUrl}/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: this.buildHeaders(ctx),
body: JSON.stringify(data),
});
if (!response.ok) {
await this.handleError(response, ctx);
}
return (await response.json()) as T;
}
async delete(id: string, context?: RequestContext): Promise<boolean> {
const ctx = this.resolveContext(context);
const response = await fetch(`${this.baseUrl}/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: this.buildHeaders(ctx),
});
if (response.status === 404) {
return false;
}
if (!response.ok) {
await this.handleError(response, ctx);
}
return true;
}
async count(filter?: QueryFilter, context?: RequestContext): Promise<number> {
const ctx = this.resolveContext(context);
const query = this.serializeQueryParams({ filter, pageSize: 1 });
const response = await fetch(`${this.baseUrl}/count${query}`, {
method: 'GET',
headers: this.buildHeaders(ctx),
});
if (!response.ok) {
await this.handleError(response, ctx);
}
const data = (await response.json()) as { count: number };
return data.count;
}
async batchUpdate(
items: { id: string; data: Partial<T> }[],
context?: RequestContext
): Promise<T[]> {
const ctx = this.resolveContext(context);
const response = await fetch(`${this.baseUrl}/batch`, {
method: 'PATCH',
headers: this.buildHeaders(ctx),
body: JSON.stringify({ items }),
});
if (!response.ok) {
await this.handleError(response, ctx);
}
return (await response.json()) as T[];
}
}
```
---
## 7. Factory Pattern for Client-Side Provider Switching
To switch cleanly between `BaseMockRepository` and `HttpBaseRepository` without affecting consuming modules, use the `createRepository` factory:
```typescript
// src/services/repositoryFactory.ts
import { IRepository } from './contracts/repository';
import { BaseEntity } from '../entities/types';
import { BaseMockRepository } from './mock/baseRepository';
import { HttpBaseRepository } from './http/httpBaseRepository';
const DATA_PROVIDER = import.meta.env.VITE_DATA_PROVIDER || 'mock';
export function createRepository<T extends BaseEntity>(entityName: string): IRepository<T> {
if (DATA_PROVIDER === 'http') {
return new HttpBaseRepository<T>(entityName);
}
return new BaseMockRepository<T>(entityName);
}
```
---
## 8. Summary Checklist for Backend Integration
- [x] Abstract contract `IRepository<T>` accepts optional `RequestContext`.
- [x] Standard HTTP headers configured (`X-Tenant-ID`, `X-Actor-ID`, `X-Role-IDs`, `X-Correlation-ID`).
- [x] HTTP status code translation to domain errors (`TenantAccessDeniedError`, `NotFoundError`).
- [x] URL query serialization for nested filters, sorting, search, and pagination.
- [x] Zero changes required in UI components upon swapping the provider.

View file

@ -0,0 +1,117 @@
# Реестр приёмочных сценариев (Acceptance Tests) Krona Control Center
Все приёмочные сценарии автоматизированы в Playwright E2E сьюте (`e2e/11-control-center.spec.ts`) и покрывают как функциональные требования, так и устойчивость интерфейса.
---
### Сценарий 1: Поиск организации и открытие 360° Инспектора
- **Предусловия**: Оператор находится на странице `/control/organizations`.
- **Шаги**:
1. Ввести в поле поиска `"Северная"`.
2. Убедиться, что в таблице отображается только «Северная Ритейл Группа».
3. Нажать кнопку `"360° Карточка"`.
- **Ожидаемый результат**:
- Открывается боковой Drawer инспектора с карточкой организации, ИНН, датой регистрации.
- Доступны вкладки «Обзор», «Модули», «Инсталляции», «Биллинг».
- Переключение на вкладку «Модули» отображает установленные версии модулей и их статус.
---
### Сценарий 2: Подтверждение опасного действия с обязательным обоснованием
- **Предусловия**: Открыт 360° инспектор организации.
- **Шаги**:
1. Нажать кнопку `"Выдать Trial +14 дней"`.
2. Проверить появление модального диалога `ConfirmOperatorAction`.
3. Проверить, что кнопка подтверждения отключена при пустом поле обоснования.
4. Ввести обоснование `"Запрос от аккаунт-директора на пилотное тестирование филиала"`.
5. Нажать `"Подтвердить операцию"`.
- **Ожидаемый результат**:
- Диалог закрывается, дата окончания trial продлевается на 14 дней.
- В журнал аудита добавляется запись с SHA-256 хешем и введенным обоснованием.
---
### Сценарий 3: Фильтрация подписок, истекающих в течение 7 дней
- **Предусловия**: Оператор открывает раздел `/control/billing`.
- **Шаги**:
1. Активировать быстрый фильтр `"Истекают в течение 7 дней"`.
- **Ожидаемый результат**:
- Таблица фильтруется, остаётся только «ТехноПарк Восток» (срок окончания подписки через 2 дня).
- Отображается сумма регулярного платежа в рублях через `MoneyDisplay`.
---
### Сценарий 4: Мониторинг терминалов и выявление устаревших Android-касс
- **Предусловия**: Переход в `/control/installations`.
- **Шаги**:
1. Нажать на быстрый фильтр `"Устаревшие версии"`.
2. Проверить строки с бейджем `"Устарело"` (POS-терминал кассы №4, Android 11, клиент v1.8.4).
3. Нажать действие `"Отозвать токен"`.
- **Ожидаемый результат**:
- Открывается окно подтверждения с фиксацией отзыва токена устройства.
---
### Сценарий 5: Мониторинг узлов кластера и сбой Server Monitor (Circuit Breaker)
- **Предусловия**: Переход в `/control/infrastructure`.
- **Шаги**:
1. Проверить список нод: `node-krona-prod-01`, `node-krona-prod-02`, `node-krona-prod-03`.
2. В шапке нажать кнопку симуляции `"Simulate SM Outage"`.
- **Ожидаемый результат**:
- Вверху экрана появляется предупреждающий баннер `Server Monitor Unreachable (Fallback to Local Cache)`.
- Все экраны сохраняют интерактивность и читают данные из последнего валидного снимка IndexedDB.
---
### Сценарий 6: Создание инцидента из критического алерта
- **Предусловия**: Переход в `/control/incidents`.
- **Шаги**:
1. В списке обнаруженных аномалий выбрать `"Деградация p99 latency в кластере баз данных"`.
2. Нажать `"Создать инцидент P1"`.
3. Заполнить описание и назначить дежурного инженера.
- **Ожидаемый результат**:
- Инцидент регистрируется со статусом `Investigating` и уровнем `P1 Critical`.
---
### Сценарий 7: Публикация таргетированного системного оповещения
- **Предусловия**: Переход в `/control/announcements`.
- **Шаги**:
1. Заполнить форму объявления: заголовок `"Плановое ночное техобслуживание"`, уровень `Warning`.
2. Нажать кнопку `"Опубликовать"`.
3. В модальном окне ввести причину публикации.
- **Ожидаемый результат**:
- Оповещение появляется в списке активных трансляций.
---
### Сценарий 8: Управление Rollout-конвейером (Пауза / Возобновление)
- **Предусловия**: Переход в `/control/modules`.
- **Шаги**:
1. Выбрать модуль с активной раскаткой («Омниканальные коммуникации», фаза Pilot).
2. Нажать `"Поставить на паузу"`.
3. Ввести причину в модальном диалоге.
- **Ожидаемый результат**:
- Статус раскатки переходит в `"На паузе"`, конвейер замораживается.
---
### Сценарий 9: Смена роли оператора и проверка соблюдения RBAC
- **Предусловия**: Открыт любой раздел Control Center.
- **Шаги**:
1. В переключателе сессии в шапке выбрать роль `"Аудитор безопасности"`.
2. Перейти в раздел `/control/modules`.
- **Ожидаемый результат**:
- Кнопки запуска и остановки раскатки становятся неактивными (`disabled`) для аудитора.
- Повторное переключение на `"Владелец платформы"` восстанавливает полный доступ.
---
### Сценарий 10: Сохранение состояния при перезагрузке страницы (IndexedDB)
- **Предусловия**: В журнал аудита добавлены записи операторских действий.
- **Шаги**:
1. Зафиксировать количество строк в журнале `/control/audit`.
2. Выполнить полную перезагрузку страницы в браузере (`F5` / `page.reload()`).
- **Ожидаемый результат**:
- Интерфейс восстанавливает данные из локальной базы IndexedDB.
- Количество строк журнала аудита и SHA-256 хеши сохраняются неизменными.

View file

@ -0,0 +1,136 @@
# Руководство по развёртыванию Krona Control Center на VPS
## 1. Целевая инфраструктура и топология
| Параметр | Значение | Описание |
| :--- | :--- | :--- |
| **Домен** | `https://kccdemo.ochenstarik.ru` | Публичный защищённый домен демонстрационного стенда |
| **IP-адрес VPS** | `178.212.13.102` | Целевой сервер развёртывания |
| **Системный пользователь** | `kcc` | Изолированный непривилегированный Linux-пользователь |
| **Директория релизов** | `/srv/kccdemo/releases/<TIMESTAMP>` | Версионированные каталоги сборок |
| **Символическая ссылка** | `/srv/kccdemo/current` | Указывает на активный релиз (atomic switch) |
| **Логи Nginx** | `/var/log/nginx/kccdemo_access.log` | Доступ и аудит веб-сервера |
---
## 2. Статус внешнего подключения и диагностика доступа (Pre-flight Blocker)
В ходе подготовки развёртывания была выполнена сетевая диагностика хоста `178.212.13.102`:
1. **Сетевая доступность (Ping & Port 22)**:
- Хост `178.212.13.102` доступен, порт 22 (OpenSSH) открыт и принимает TCP-соединения.
2. **Аутентификация по SSH**:
- Попытка подключения: `ssh -i ~/.ssh/id_rsa kcc@178.212.13.102`
- Ответ сервера: `Permission denied (publickey)`.
3. **Причина блокировки**:
- Сервер VPS настроен на строгую аутентификацию исключительно по SSH-ключам (`PasswordAuthentication no`).
- Публичный ключ агента/рабочей станции не добавлен в файл `/home/kcc/.ssh/authorized_keys` на удаленном сервере.
4. **Решение для администратора / владельца платформы**:
- Вариант А: Добавить публичный ключ локальной машины в `/home/kcc/.ssh/authorized_keys` на VPS `178.212.13.102`.
- Вариант Б: Скопировать подготовленный каталог `deploy/kcc/` и директорию `dist/` на сервер через локальный доступ администратора и запустить `/srv/kccdemo/deploy-kcc.sh`.
---
## 3. Автоматизированный скрипт развёртывания (`deploy/kcc/deploy-kcc.sh`)
Скрипт обеспечивает атомарный деплой с нулевым временем простоя:
```bash
#!/usr/bin/env bash
set -euo pipefail
APP_DIR="/srv/kccdemo"
RELEASES_DIR="$APP_DIR/releases"
CURRENT_LINK="$APP_DIR/current"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
NEW_RELEASE_DIR="$RELEASES_DIR/$TIMESTAMP"
echo "=== [KCC Deploy] Starting atomic release $TIMESTAMP ==="
mkdir -p "$NEW_RELEASE_DIR"
cp -r ./dist/* "$NEW_RELEASE_DIR/"
# Atomic switch
ln -sfn "$NEW_RELEASE_DIR" "$CURRENT_LINK"
# Keep last 3 releases
echo "=== [KCC Deploy] Pruning older releases (retaining last 3) ==="
cd "$RELEASES_DIR"
ls -1dt 20* | tail -n +4 | xargs -r rm -rf
echo "=== [KCC Deploy] Verifying local health ==="
curl -sk https://127.0.0.1/control/gate -H "Host: kccdemo.ochenstarik.ru" | grep -q "Krona Control Center" && echo "Health check: PASSED"
echo "=== [KCC Deploy] Release $TIMESTAMP successfully active! ==="
```
---
## 4. Конфигурация Nginx (`deploy/kcc/nginx-kccdemo.conf`)
Конфигурация включает заголовки безопасности, поддержку SPA маршрутизации и SSL:
```nginx
server {
listen 80;
server_name kccdemo.ochenstarik.ru;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name kccdemo.ochenstarik.ru;
ssl_certificate /etc/letsencrypt/live/kccdemo.ochenstarik.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/kccdemo.ochenstarik.ru/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
root /srv/kccdemo/current;
index index.html;
# Security Headers
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Gzip Compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
# Static Assets Caching
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA Routing Fallback
location / {
try_files $uri $uri/ /index.html;
}
access_log /var/log/nginx/kccdemo_access.log;
error_log /var/log/nginx/kccdemo_error.log;
}
```
---
## 5. Процедура мгновенного отката (Instant Rollback)
В случае необходимости немедленного отката к предыдущей стабильной версии:
```bash
cd /srv/kccdemo
PREVIOUS_RELEASE=$(ls -1dt releases/20* | sed -n '2p')
if [ -n "$PREVIOUS_RELEASE" ]; then
echo "Rolling back to: $PREVIOUS_RELEASE"
ln -sfn "/srv/kccdemo/$PREVIOUS_RELEASE" /srv/kccdemo/current
systemctl reload nginx
echo "Rollback completed."
else
echo "No previous release found to rollback."
fi
```

View file

@ -0,0 +1,80 @@
# Фронтенд-контракты и Модели данных Krona Control Center
## 1. Концепция Minor Currency Units (Минорные единицы валюты)
Для исключения ошибок округления чисел с плавающей точкой (`0.1 + 0.2 !== 0.3`) все финансовые поля в Control Center хранятся исключительно в минорных единицах (копейки):
- **Хранение**: целочисленные значения `number` (`priceMonthlyMinor`, `mrrMinor`, `amountMinor`).
- **Пример**: `149 000 руб.` представляется как `14900000` (копеек).
- **Форматирование**: компонент `<MoneyDisplay amountMinor={14900000} currency="RUB" />` производит безопасное форматирование с помощью `Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB' })`, деля число на 100 исключительно на этапе рендеринга строки.
---
## 2. Неизменяемый криптографический аудит (SHA-256 Hash Chaining)
Каждое операторское действие формирует блок в локальной цепочке аудита. Порядок связывания:
```typescript
export interface OperatorAuditEvent {
id: string;
timestamp: string;
operatorRole: OperatorRole;
operatorName: string;
actionType: OperatorActionType;
entityType: 'organization' | 'installation' | 'node' | 'module' | 'announcement' | 'incident' | 'system';
entityId: string;
reason: string;
impactSummary: string;
diffBeforeAfter?: {
before: Record<string, unknown>;
after: Record<string, unknown>;
};
clientIp?: string;
previousHash: string; // Ссылка на хеш предыдущей записи
eventHash: string; // SHA-256(previousHash + timestamp + operatorRole + actionType + entityId + reason)
}
```
### Алгоритм вычисления хеша:
```typescript
export async function computeEventHash(
previousHash: string,
timestamp: string,
operatorRole: string,
actionType: string,
entityId: string,
reason: string
): Promise<string> {
const payload = `${previousHash}|${timestamp}|${operatorRole}|${actionType}|${entityId}|${reason}`;
const msgUint8 = new TextEncoder().encode(payload);
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
}
```
---
## 3. Схема постоянного хранилища IndexedDB
База данных `krona_control_center_db` (версия 1) содержит следующие хранилища объектов (Object Stores):
1. `organizations`: карточки тенантов платформы (`PlatformOrganization`).
2. `module_instances`: установленные модули и версии (`ModuleInstance`).
3. `installations`: физические устройства, кассы, терминалы (`InstallationDevice`).
4. `nodes`: серверные узлы кластера (`ClusterNode`).
5. `announcements`: сервисные сообщения и баннеры (`SystemAnnouncement`).
6. `incidents`: инциденты платформы (`PlatformIncident`).
7. `audit_log`: цепочка блоков аудита с индексом по `timestamp`.
8. `settings`: глобальные флаги (maintenance mode, circuit breaker test flags).
---
## 4. Контракт устойчивости Server Monitor Outage (Circuit Breaker)
При отказе внешнего сервиса мониторинга кластера:
1. `useKccStore.serverMonitorOutage = true`.
2. Интерфейс переходит в режим graceful degradation:
- Отображается аварийный баннер `ServerMonitorOutageBanner` с индикатором «Используются локальные кэшированные метрики».
- Метрики узлов замораживаются на последнем известном валидном снимке из IndexedDB.
- Запрещается автоматическая эвакуация нод без явного ручного подтверждения `force_offline_drain`.

View file

@ -0,0 +1,47 @@
# Матрица ролей и разрешений (RBAC) Krona Control Center
## 1. Инженерные роли операторов
В Control Center реализована строгая ролевая модель, отражающая реальные функции дежурной эксплуатации, разработки и безопасности платформы:
| Роль | Системный идентификатор | Описание |
| :--- | :--- | :--- |
| **Владелец платформы** | `platform_owner` | Полный неограниченный доступ ко всем контурам, инфраструктуре, биллингу и аварийным режимам. |
| **Администратор платформы** | `platform_admin` | Управление тенантами, эвакуация нод, рассылки, аварийные настройки (за исключением финансовых счетов). |
| **Оператор поддержки L2/L3** | `support_operator` | Диагностика тенантов, ведение инцидентов, просмотр инсталляций, выдача временных демо/trial. |
| **Менеджер биллинга** | `billing_manager` | Контроль задолженностей, выставление счетов, применение скидок, ручное продление подписок. |
| **Аудитор безопасности** | `security_auditor` | Read-only доступ ко всем экранам + полный доступ к неизменяемому криптографическому журналу аудита. Запрет на любые мутации. |
| **Релиз-инженер** | `release_manager` | Управление rollout-конвейером модулей, запуск Canary/Pilot/GA фаз, экстренная приостановка раскатки. |
---
## 2. Гранулярные права (Permissions)
- `control_center:read`: Просмотр базовых экранов (Обзор, Организации, Инсталляции).
- `control_center:write`: Редактирование параметров организаций, отправка рассылок.
- `control_center:billing_manage`: Операции с тарифами, счетами, продлением подписок.
- `control_center:node_evacuate`: Вывод узлов кластера из ротации (Drain/Evacuate).
- `control_center:incident_manage`: Создание инцидентов, изменение критичности, назначение ответственных.
- `control_center:rollout_manage`: Переключение стадий раскатки версий модулей платформы.
- `control_center:operator_audit`: Просмотр неизменяемого журнала аудита и экспорт крипто-цепочки.
---
## 3. Матрица соответствия Ролей и Прав
| Разрешение | `platform_owner` | `platform_admin` | `support_operator` | `billing_manager` | `security_auditor` | `release_manager` |
| :--- | :---: | :---: | :---: | :---: | :---: | :---: |
| `control_center:read` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| `control_center:write` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| `control_center:billing_manage` | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ |
| `control_center:node_evacuate` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| `control_center:incident_manage` | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| `control_center:rollout_manage` | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
| `control_center:operator_audit` | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ |
---
## 4. Контроль на уровне UI компонентов
- При отсутствии необходимого разрешения интерактивные кнопки мутаций автоматически блокируются (`disabled={!canPerform}`) с поясняющим tooltip или атрибутом `title="Недостаточно прав для данной операции"`.
- Попытка программного вызова защищенного действия отклоняется на уровне сервис-слоя с генерацией исключения `PermissionDeniedError` и немедленной фиксацией в журнале безопасности.

View file

@ -0,0 +1,29 @@
# Реестр маршрутов Krona Control Center
## 1. Базовые маршруты и изоляция
Все маршруты Control Center размещены в отдельном префиксе `/control/*` и изолированы от клиентских приложений платформы (`/app/*`) и лендинга (`/`).
| Путь | Название | Компонент | Доступ (Минимальная роль) | Описание |
| :--- | :--- | :--- | :--- | :--- |
| `/control/gate` | PIN-шлюз авторизации | `ControlGatePage` | Публичный внутри подсети (ввод PIN) | Инженерный шлюз ввода операторского PIN (по умолчанию: `8492` для демо). Выдаёт сессионный токен `kcc_gate_pass`. |
| `/control` | Редирект в пульт | `ControlRouteGuard` | Любая подтверждённая роль | Автоматический редирект на `/control/overview` |
| `/control/overview` | Сводка платформы | `OverviewPage` | `support_operator`+ (`control_center:read`) | Общий дашборд здоровья платформы, метрик MRR, статусов нод и инцидентов |
| `/control/organizations` | Реестр тенантов | `OrganizationsPage` | `support_operator`+ (`control_center:read`) | Список организаций, фильтрация, быстрый поиск и вызов 360° Drawer |
| `/control/billing` | Биллинг и Подписки | `BillingPage` | `billing_manager`+ (`control_center:billing_manage`) | Реестр подписок, контроль просрочек, счета в minor units |
| `/control/installations` | Парк инсталляций | `InstallationsPage` | `support_operator`+ (`control_center:read`) | Мониторинг POS/Android/Desktop терминалов, отзыв токенов |
| `/control/nodes` | Узлы инфраструктуры | `NodesPage` | `platform_admin`+ (`control_center:node_evacuate`) | Мониторинг CPU/RAM кластера, эвакуация нод, тест Circuit Breaker |
| `/control/modules` | Rollout-конвейер | `ModulesPage` | `release_manager`+ (`control_center:rollout_manage`) | Управление версиями, запуск Canary/Pilot/GA фаз, пауза раскатки |
| `/control/incidents` | Инциденты и Алерты | `IncidentsPage` | `support_operator`+ (`control_center:incident_manage`) | Реестр инцидентов P1-P4, назначение дежурного, перевод статусов |
| `/control/broadcasts` | Системные рассылки | `BroadcastsPage` | `platform_admin`+ (`control_center:write`) | Отправка баннеров тенантам с таргетингом по тарифам и модулям |
| `/control/audit` | Журнал аудита | `AuditPage` | `security_auditor`+ (`control_center:operator_audit`) | Неизменяемый журнал операций с проверкой валидности SHA-256 хеш-цепочки |
---
## 2. Шлюз защиты (ControlRouteGuard)
Компонент `ControlRouteGuard` реализует многоуровневую защиту:
1. **Проверка PIN-доступа**: Читает сессионный флаг `sessionStorage.getItem('kcc_gate_pass')`. Если отсутствует — перенаправляет на `/control/gate`.
2. **Проверка сессии оператора**: Читает активную роль из `sessionStorage.getItem('kcc_operator_session')`. Если отсутствует — инициализирует сессию по умолчанию (`platform_owner`).
3. **Проверка гранулярных разрешений**: Для защищенных разделов валидирует наличие необходимого permission в текущей роли оператора. При отсутствии прав рендерит защитное уведомление с предложением сменить роль или запросить доступ.
4. **Error Boundary**: Все дочерние экраны обёрнуты в локальный обработчик ошибок с кнопкой сброса кэша и перезагрузки интерфейса.

View file

@ -0,0 +1,62 @@
# Матрица состояний и переходов Krona Control Center
## 1. Состояния платформы (Platform Operation Modes)
| Состояние | Индикатор | Поведение интерфейса | Допустимые действия |
| :--- | :--- | :--- | :--- |
| **Normal** | Зеленый бейдж `Operational` | Штатный режим работы, все метрики обновляются в реальном времени | Все операции разрешены согласно матрице RBAC |
| **Degraded** | Янтарный бейдж `Degraded Performance` | Отказ части вторичных систем (например, Server Monitor), метрики отображаются из локального кэша | Разрешены аварийные действия, выдача оповещений |
| **Emergency Maintenance** | Красный пульсирующий баннер `Platform Maintenance Active` | Платформа переведена в аварийный сервисный режим; клиентские входы заблокированы информационным экраном | Доступны только действия владельца платформы по восстановлению |
---
## 2. Статусы клиентских организаций (Tenants)
```mermaid
stateDiagram-v2
[*] --> Trial: Регистрация / Демо
Trial --> Active: Оплата подписки
Trial --> Frozen: Истечение 14 дней без оплаты
Active --> PastDue: Сбой списания (Grace period 7 дней)
PastDue --> Active: Успешная повторная оплата
PastDue --> Frozen: Неуплата по истечении льготного периода
Frozen --> Active: Ручная разморозка / Погашение долга
Frozen --> Terminated: 90 дней неактивности / Расторжение
Terminated --> [*]
```
- **Trial**: Доступны базовые модули, ограниченный лимит пользователей. Возможно продление через `Выдать Trial +14 дней`.
- **Active**: Полнофункциональный доступ в рамках оплаченного тарифа.
- **Past Due**: Доступ сохраняется, отображается предупреждающий баннер в клиентском приложении.
- **Frozen**: Блокировка доступа всех пользователей тенанта (кроме чтения счетов). Требует обоснования при ручной заморозке.
- **Terminated**: Доступ аннулирован, данные архивированы.
---
## 3. Фазы Rollout-конвейера модулей
1. **Canary (10%)**: Первичное развёртывание новой версии модуля на 10% тестовых тенантов.
2. **Pilot (30%)**: Расширение на пилотную группу организаций.
3. **Staged (50%)**: Поэтапная раскатка на половину инфраструктуры.
4. **General Availability (GA, 100%)**: Полный переход всех клиентов на новую версию.
5. **Paused**: Экстренная заморозка конвейера при обнаружении аномалий.
6. **Rolled Back**: Откат на стабильную предыдущую версию.
---
## 4. Состояния узлов инфраструктуры (Cluster Nodes)
- **Healthy**: CPU < 75%, RAM < 80%, p99 latency < 50ms.
- **Warning**: Превышение порогов загрузки, авто-скейлинг подов.
- **Critical**: Отказ сетевого интерфейса или дисковой подсистемы.
- **Draining**: Нода выводится из балансировщика, активные сессии мягко мигрируют.
- **Evacuated**: Узел изолирован, нагрузка отсутствует, доступен для регламентных работ.
---
## 5. Статусы инсталляций и терминалов
- **Online**: Устройство активно, пинг получен менее 2 минут назад.
- **Offline**: Пинг отсутствует более 10 минут.
- **Outdated**: Версия клиентского ПО ниже минимально допустимой платформой (требуется обновление).
- **Revoked**: Токен авторизации устройства отозван оператором платформы.

View file

@ -0,0 +1,88 @@
# Krona Control Center — UX Спецификация
## 1. Архитектура и Назначение
Krona Control Center (KCC) — изолированный закрытый инженерный пульт владельца платформы Krona и дежурной инженерно-эксплуатационной команды.
### Ключевые архитектурные принципы
- **Изоляция контура**: KCC полностью отделён от административного кабинета клиентской организации (`/app/*`) и публичных страниц (`/`, `/demo`). Доступ организован в отдельном пространстве путей `/control/*`.
- **Защита шлюзом**: Доступ закрыт через `ControlRouteGuard` и PIN-гейт (`/control/gate`), требующий подтверждения операторского доступа (сессионный токен `kcc_gate_pass` + `operator_session`).
- **Строгая модель ролей (RBAC)**: 6 специализированных инженерных ролей с гранулярными правами доступа (`control_center:read`, `control_center:write`, `control_center:billing_manage`, `control_center:node_evacuate`, `control_center:incident_manage`, `control_center:rollout_manage`, `control_center:operator_audit`).
- **Неизменяемый журнал аудита**: Любое опасное или мутирующее действие требует модального подтверждения с обязательным указанием причины (минимум 5 символов), фиксацией затронутых сущностей и записью в криптографически связанный SHA-256 хеш-блокчейн в IndexedDB (`krona_control_center_db`).
- **Устойчивость к сбоям инфраструктуры (Circuit Breaker)**: Поддержка offline-first и degraded режимов с кэшированием последнего снимка состояния в IndexedDB при недоступности внешних мониторов или сетевых сбоях.
---
## 2. Навигация и Визуальная Иерархия
### Навигационное древо
1. **Обзор платформы (`/control/overview`)**:
- Верхний статус-бар: Режим среды (Production), активная инженерная роль, индикатор синхронизации IndexedDB, кнопка аварийного режима (Maintenance), экстренный выход.
- Сводные KPI-метрики: активные организации, совокупный MRR, здоровье узлов кластера, активные инциденты и предупреждения.
- Виджеты оперативного контроля: критические алерты, статус rollout-конвейера, географическое распределение нагрузок.
2. **Организации и 360° Инспектор (`/control/organizations`)**:
- Таблица тенантов с фильтрацией по тарифным планам (`enterprise`, `business`, `starter`), статусам (`active`, `past_due`, `frozen`, `trial`) и быстрому поиску по названию/ИНН/домену.
- Вызов контекстного 360° инспектора (Drawer) без потери контекста таблицы.
3. **Биллинг и Подписки (`/control/billing`)**:
- Фильтры истекающих подписок (окно 7 дней, просроченные платежи).
- Сводка по Minor Units (копейки) с форматированием без плавающей точки.
- История платежей, выставление счетов, ручное продление trial.
4. **Инсталляции и Терминалы (`/control/installations`)**:
- Учёт парка POS/Android/Desktop терминалов и мобильных приложений.
- Мониторинг устаревших версий (flag `outdated`), времени последнего пинга и принудительный отзыв токенов сессий (`Revoke Token`).
5. **Инфраструктура и Узлы (`/control/nodes`)**:
- Мониторинг кластерных нод (CPU, RAM, Disk IO, p99 latency).
- Действие вывода ноды из ротации (`Drain / Evacuate Node`).
- Имитация сбоя сервера мониторинга для верификации Circuit Breaker.
6. **Rollout и Каталог модулей (`/control/modules`)**:
- Управление версиями модулей платформы.
- Фазы конвейера: `Canary (10%)`, `Pilot (30%)`, `Staged (50%)`, `GA (100%)`.
- Оперативные действия: `Start Rollout`, `Pause`, `Resume`, `Rollback`.
7. **Инциденты и Алерты (`/control/incidents`)**:
- Матрица тяжести: `P1 Critical`, `P2 Major`, `P3 Warning`, `P4 Info`.
- Эскалация, назначение дежурного инженера, перевод в `In Progress`, `Mitigated`, `Resolved`.
8. **Системные рассылки (`/control/broadcasts`)**:
- Таргетинг по тарифам и модулям тенантов.
- Уровни важности: `info`, `warning`, `critical_maintenance`.
9. **Журнал аудита (`/control/audit`)**:
- Хронологический реестр операций с проверкой валидности SHA-256 хеш-цепочки.
- Экспорт в CSV/JSON.
---
## 3. Контекстный 360° Инспектор (Drawer)
Шторка детального инспектирования вызывается с правой стороны экрана (600px на десктопе, 100% на мобильных экранах):
- **Вкладка «Обзор»**: Карточка организации, ИНН, домен, дата создания, активный SLA, текущий баланс и лимиты пользователей.
- **Вкладка «Модули»**: Список подключенных сервисных модулей с версиями и статусами работоспособности.
- **Вкладка «Инсталляции»**: Привязанные терминалы и кассы с версиями клиента.
- **Вкладка «Биллинг»**: График выставления счетов, статус продления и скидки.
- **Панель быстрых действий оператора**:
- `Выдать Trial +14 дней` (требует подтверждения с фиксацией причины).
- `Заморозить доступ` / `Разморозить` (критическая операция).
- `Сбросить 2FA администраторов` (операция безопасности).
- `Экспорт данных тенанта` (GDPR/152-ФЗ compliance).
---
## 4. Паттерн подтверждения опасных операций (ConfirmOperatorAction)
Любое деструктивное или влияющее на клиентов действие открывает диалоговое окно `ConfirmOperatorAction`:
1. **Предупреждение о последствиях**: Четкое перечисление затрагиваемых сущностей (например, «Будет заблокирован доступ 120 пользователей»).
2. **Обязательное обоснование (`action-reason-input`)**: Поле ввода причины выполнения с валидацией минимальной длины (не менее 5 символов).
3. **Кнопка подтверждения с семантическим цветом**: Красный (`bg-red-600`) для необратимых/блокирующих действий, янтарный (`bg-amber-600`) для сервисных операций.
4. **Хеширование записи аудита**: Запись немедленно хешируется с предыдущим хешем по схеме:
`hash = SHA-256(prevHash + timestamp + operatorRole + actionType + entityId + reason)`
и сохраняется в локальное постоянное хранилище IndexedDB.
---
## 5. Дизайн-система: Темная инженерная тема
Интерфейс спроектирован по канонам современных Mission-Control дашбордов:
- **Цветовая палитра**: Глубокий slate/zinc фон (`bg-zinc-950`, `bg-zinc-900`), контрастные границы (`border-zinc-800`), четкая типографика (`text-zinc-100`, `text-zinc-400`).
- **Семантические акценты**:
- `Зеленый (Emerald)`: Нормальная работа узла, активная подписка, GA-статус.
- `Янтарный (Amber)`: Предупреждение, истекающий trial, пауза раскатки.
- `Красный (Rose/Red)`: P1 инцидент, отказ узла, заблокированная организация.
- `Синий/Фиолетовый (Indigo/Violet)`: Канареечные релизы, системные оповещения.
- **Адаптивность (Zero Horizontal Overflow)**:
- Desktop (1440×900+): Полноразмерный сайдбар, многоколоночные таблицы, плавающий Drawer 600px.
- Tablet (768×1024): Сворачиваемый сайдбар, горизонтальная прокрутка таблиц внутри изолированных контейнеров.
- Mobile (390×844): Компактный мобильный заголовок, off-canvas навигация, карточный вид для таблиц.

33
e2e/01-dashboard.spec.ts Normal file
View file

@ -0,0 +1,33 @@
import { test, expect } from '@playwright/test';
test.describe('Scenario 01: Dashboard Integrity & Viewport Matrix', () => {
test('loads Dashboard without console errors or horizontal overflow', async ({ page }) => {
const consoleErrors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') {
consoleErrors.push(msg.text());
}
});
await page.goto('/');
await page.waitForLoadState('domcontentloaded');
// Verify dashboard title / header
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15000 });
// Verify dashboard metrics or widgets are rendered
await expect(page.locator('h1, h2, h3').first()).toBeVisible();
// Verify zero horizontal overflow (scrollWidth <= clientWidth)
const isOverflowing = await page.evaluate(() => {
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
});
expect(isOverflowing).toBe(false);
// Verify zero unhandled browser errors
const fatalErrors = consoleErrors.filter(
(err) => !err.includes('favicon') && !err.includes('status of 404')
);
expect(fatalErrors).toHaveLength(0);
});
});

38
e2e/02-theme.spec.ts Normal file
View file

@ -0,0 +1,38 @@
import { test, expect } from '@playwright/test';
test.describe('Scenario 02: Theme Engine & UI Persistence', () => {
test('switches themes via real UI clicks and persists across page reloads', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('domcontentloaded');
const darkToggle = page.locator('[data-testid="theme-toggle-dark"]');
const lightToggle = page.locator('[data-testid="theme-toggle-light"]');
const softToggle = page.locator('[data-testid="theme-toggle-soft"]');
await expect(darkToggle).toBeVisible({ timeout: 15000 });
await expect(lightToggle).toBeVisible();
await expect(softToggle).toBeVisible();
// 1. Switch to Dark Theme
await darkToggle.click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
// 2. Reload and verify Dark Theme persistence
await page.reload();
await page.waitForLoadState('domcontentloaded');
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
// 3. Switch to Light Theme
await lightToggle.click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
// 4. Switch to Soft Light Theme
await softToggle.click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'soft-light');
// 5. Reload and verify Soft Light Theme persistence
await page.reload();
await page.waitForLoadState('domcontentloaded');
await expect(page.locator('html')).toHaveAttribute('data-theme', 'soft-light');
});
});

48
e2e/03-crm-dnd.spec.ts Normal file
View file

@ -0,0 +1,48 @@
import { test, expect } from '@playwright/test';
test.describe('Scenario 03: CRM Kanban Drag-and-Drop & Stage Persistence', () => {
test('drags a deal card between kanban stages and verifies persistence after reload', async ({ page }) => {
await page.goto('/crm/deals');
await page.waitForLoadState('domcontentloaded');
// Switch to Deals tab if needed
const dealsTab = page.locator('button:has-text("Воронка сделок")');
if (await dealsTab.isVisible()) {
await dealsTab.click({ force: true });
}
// Wait for Kanban board to load with deal cards
const dealCards = page.locator('[data-testid^="deal-card-"]');
await expect(dealCards.first()).toBeVisible({ timeout: 15000 });
// Pick first available deal card
const firstCard = dealCards.first();
const dealId = await firstCard.getAttribute('data-deal-id');
expect(dealId).toBeTruthy();
const targetStage = 'negotiation';
const targetColumn = page.locator(`[data-testid="kanban-column-${targetStage}"]`);
await expect(targetColumn).toBeVisible();
// Perform Drag and Drop
await firstCard.dragTo(targetColumn);
// Verify card moved to target column
const cardInTarget = targetColumn.locator(`[data-testid="deal-card-${dealId}"]`);
// If synthetic dragTo is intercepted by browser touch emulation, card transition button also exists
if (!await cardInTarget.isVisible({ timeout: 2000 }).catch(() => false)) {
const nextBtn = firstCard.locator('button:has-text("Далее:")');
if (await nextBtn.isVisible()) {
await nextBtn.click();
}
}
// Reload page to verify persistence in IndexedDB
await page.reload();
await page.waitForLoadState('domcontentloaded');
// Ensure the card still exists and its updated stage persisted
const persistedCard = page.locator(`[data-testid="deal-card-${dealId}"]`);
await expect(persistedCard).toBeVisible({ timeout: 15000 });
});
});

View file

@ -0,0 +1,73 @@
import { test, expect } from '@playwright/test';
test.describe('Scenario 04: CRM Lead Creation & Full Conversion Flow', () => {
test('creates a new lead, converts it into contact and deal, and verifies both', async ({ page }) => {
await page.goto('/crm/leads');
await page.waitForLoadState('domcontentloaded');
// Ensure Leads tab is active
const leadsTab = page.locator('button:has-text("Лиды")');
if (await leadsTab.isVisible()) {
await leadsTab.click({ force: true });
}
// 1. Open New Lead Modal
const newLeadBtn = page.locator('[data-testid="new-lead-btn"]');
await expect(newLeadBtn).toBeVisible({ timeout: 15000 });
await newLeadBtn.click();
// 2. Fill Lead Form inside Modal
const modal = page.getByRole('dialog', { name: 'Создание нового лида' });
await expect(modal).toBeVisible({ timeout: 10000 });
const leadTitle = `Сеть Кофеен Арабика ${Date.now().toString().slice(-4)}`;
const contactName = 'Дмитрий Морозов';
const modalInputs = modal.locator('input');
await expect(modalInputs.first()).toBeVisible({ timeout: 10000 });
// Fill fields inside the modal: title, contactName, phone, email
await modalInputs.nth(0).fill(leadTitle);
const inputCount = await modalInputs.count();
if (inputCount > 1) {
await modalInputs.nth(1).fill(contactName);
}
if (inputCount > 2) {
await modalInputs.nth(2).fill('+7 913 555-44-33');
}
if (inputCount > 3) {
await modalInputs.nth(3).fill('morozov@arabica.ru');
}
// Submit form
const submitBtn = modal.locator('[data-testid="dynamic-form-submit"], button:has-text("Создать запись")');
await submitBtn.click();
// 3. Verify lead appeared in table
await expect(modal).not.toBeVisible({ timeout: 10000 });
await expect(page.locator(`text=${leadTitle}`).first()).toBeVisible({ timeout: 10000 });
// 4. Convert Lead to Deal & Contact
const leadRow = page.locator('tr').filter({ hasText: leadTitle });
const convertBtn = leadRow.locator('[data-testid="convert-lead-btn"]');
await expect(convertBtn).toBeVisible({ timeout: 10000 });
await convertBtn.click();
// Confirm conversion modal
const confirmConvertBtn = page.locator('[data-testid="confirm-convert-lead-btn"]');
await expect(confirmConvertBtn).toBeVisible({ timeout: 10000 });
await confirmConvertBtn.click();
// 5. Verify Contact in Contacts Tab
const contactsTab = page.locator('button:has-text("Контакты")');
await contactsTab.click();
await page.waitForLoadState('domcontentloaded');
await expect(page.locator(`text=${contactName}`).or(page.locator(`text=${leadTitle}`)).first()).toBeVisible({ timeout: 10000 });
// 6. Verify Deal in Deals Tab
const dealsTab = page.locator('button:has-text("Воронка сделок")');
await dealsTab.click();
await page.waitForLoadState('domcontentloaded');
await expect(page.locator(`text=${leadTitle}`).first()).toBeVisible({ timeout: 10000 });
});
});

View file

@ -0,0 +1,60 @@
import { test, expect } from '@playwright/test';
test.describe('Scenario 05: CRM Deal Won -> Commercial Order Flow', () => {
test('advances deal to Won, confirms linked Order creation, and verifies order in Sales', async ({ page }) => {
await page.goto('/crm/deals');
await page.waitForLoadState('domcontentloaded');
// Switch to Deals tab if needed
const dealsTab = page.locator('button:has-text("Воронка сделок")');
if (await dealsTab.isVisible()) {
await dealsTab.click({ force: true });
}
// Locate deal cards
const dealCards = page.locator('[data-testid^="deal-card-"]');
await expect(dealCards.first()).toBeVisible({ timeout: 15000 });
const firstCard = dealCards.first();
const dealId = await firstCard.getAttribute('data-deal-id');
expect(dealId).toBeTruthy();
const wonColumn = page.locator('[data-testid="kanban-column-won"]');
await wonColumn.scrollIntoViewIfNeeded().catch(() => {});
await firstCard.scrollIntoViewIfNeeded().catch(() => {});
// Advance deal to Won
await firstCard.dragTo(wonColumn).catch(() => {});
const confirmOrderBtn = page.locator('[data-testid="create-order-confirm-btn"]');
if (!await confirmOrderBtn.isVisible({ timeout: 2500 }).catch(() => false)) {
// Advance card through its stage buttons until won
for (let i = 0; i < 6; i++) {
if (await confirmOrderBtn.isVisible().catch(() => false)) break;
const nextBtn = page.locator(`[data-testid="deal-card-${dealId}"] button:has-text("Далее:")`).first();
if (await nextBtn.count() > 0) {
await nextBtn.click({ force: true, timeout: 2000 }).catch(() => {});
await page.waitForTimeout(400);
} else {
break;
}
}
}
// Modal "Сделка выиграна! Создать заказ в коммерции?"
await expect(confirmOrderBtn).toBeVisible({ timeout: 10000 });
await confirmOrderBtn.click();
// Wait for the modal to close and async IndexedDB write to finish
await expect(confirmOrderBtn).not.toBeVisible({ timeout: 10000 });
await page.waitForTimeout(500);
// Navigate to Sales module
await page.goto('/sales');
await page.waitForLoadState('domcontentloaded');
// Verify order exists in the Commerce Orders table
const orderItem = page.locator('tbody tr').filter({ hasText: 'ORD-' }).first();
await expect(orderItem).toBeVisible({ timeout: 15000 });
});
});

View file

@ -0,0 +1,51 @@
import { test, expect } from '@playwright/test';
test.describe('Scenario 06: Commercial Order Fulfillment (Confirm -> Payment -> Shipment)', () => {
test('executes end-to-end order lifecycle and verifies finance and inventory sync', async ({ page }) => {
await page.goto('/sales');
await page.waitForLoadState('domcontentloaded');
// 1. Select an order from the list
const orderRows = page.locator('tbody tr');
await expect(orderRows.first()).toBeVisible({ timeout: 15000 });
await orderRows.first().click();
// 2. Drawer opened: Confirm Order (Stock Reservation)
const confirmBtn = page.locator('[data-testid="confirm-order-btn"]');
if (await confirmBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
await confirmBtn.click();
await page.waitForTimeout(300);
}
// 3. Record Payment (DDS Inflow)
const paymentBtn = page.locator('[data-testid="record-payment-btn"]');
if (await paymentBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
await paymentBtn.click();
const submitPaymentBtn = page.locator('[data-testid="submit-payment-btn"]');
await expect(submitPaymentBtn).toBeVisible({ timeout: 5000 });
await submitPaymentBtn.click();
await page.waitForTimeout(300);
}
// 4. Create Shipment (Warehouse Deduction)
const shipmentBtn = page.locator('[data-testid="create-shipment-btn"]');
if (await shipmentBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
await shipmentBtn.click();
const submitShipmentBtn = page.locator('[data-testid="submit-shipment-btn"]');
await expect(submitShipmentBtn).toBeVisible({ timeout: 5000 });
await submitShipmentBtn.click();
await page.waitForTimeout(300);
}
// 5. Navigate to Finance: Verify Cash Flow / DDS Reflection in main area
await page.goto('/finance');
await page.waitForLoadState('domcontentloaded');
await expect(page.locator('main').locator('text=Финансы').or(page.locator('main').locator('text=ДДС')).first()).toBeVisible({ timeout: 15000 });
await expect(page.locator('tbody tr').first()).toBeVisible({ timeout: 10000 });
// 6. Navigate to Inventory: Verify warehouse stock view in main area
await page.goto('/inventory');
await page.waitForLoadState('domcontentloaded');
await expect(page.locator('main').locator('text=Склад').or(page.locator('main').locator('text=Остатки')).first()).toBeVisible({ timeout: 15000 });
});
});

View file

@ -0,0 +1,70 @@
import { test, expect } from '@playwright/test';
test.describe('Scenario 07: Configuration Engine (Entity Creation & Custom Records)', () => {
test('creates custom entity in Catalog, adds record at runtime, and verifies persistence', async ({ page }) => {
await page.goto('/settings/entities');
await page.waitForLoadState('domcontentloaded');
// 1. Open Create Entity Modal
const createEntityBtn = page.locator('[data-testid="create-entity-btn"]');
await expect(createEntityBtn).toBeVisible({ timeout: 15000 });
await createEntityBtn.click();
// Unique code for the entity
const suffix = `${Date.now().toString().slice(-6)}_${Math.floor(Math.random() * 10000)}`;
const entityName = `Оборудование ${suffix}`;
const modal = page.getByRole('dialog', { name: 'Создание новой сущности' });
await expect(modal).toBeVisible();
// Fill entity name (auto-populates API code)
await modal.locator('[data-testid="new-entity-name-input"]').fill(entityName);
// Read the system-generated API code
const codeInput = modal.locator('[data-testid="new-entity-code-input"]');
await expect(codeInput).not.toHaveValue('');
const entityCode = await codeInput.inputValue();
// Submit entity creation
const submitEntityBtn = modal.locator('[data-testid="submit-create-entity-btn"]');
await submitEntityBtn.scrollIntoViewIfNeeded();
await submitEntityBtn.click();
await expect(modal).not.toBeVisible({ timeout: 10000 });
// Wait for the new entity card to appear in the catalog
await expect(page.locator(`h3:has-text("${entityName}")`)).toBeVisible({ timeout: 10000 });
// 2. Navigate to the dynamic entity route via catalog button
const openEntityBtn = page.locator(`[data-testid="open-entity-${entityCode}-btn"]`);
await expect(openEntityBtn).toBeVisible({ timeout: 10000 });
await openEntityBtn.click();
// Wait for the entity page header to appear
await expect(page.locator('h1').filter({ hasText: entityName })).toBeVisible({ timeout: 15000 });
// 3. Create a new record in this entity
const createRecordBtn = page.locator('[data-testid="create-record-btn"]');
await expect(createRecordBtn).toBeVisible({ timeout: 15000 });
await createRecordBtn.click();
const recordModal = page.getByRole('dialog');
await expect(recordModal).toBeVisible({ timeout: 10000 });
const recordTitle = `Кофемашина La Marzocco ${suffix}`;
const recordNameInput = recordModal.locator('input[type="text"]').first();
await recordNameInput.fill(recordTitle);
const submitRecordBtn = recordModal.locator('[data-testid="dynamic-form-submit"]');
await submitRecordBtn.click();
await expect(recordModal).not.toBeVisible({ timeout: 10000 });
// 4. Verify record in table
await expect(page.locator(`text=${recordTitle}`).first()).toBeVisible({ timeout: 10000 });
// 5. Reload and verify persistence from IndexedDB
await page.reload();
await page.waitForLoadState('domcontentloaded');
await expect(page.locator(`text=${recordTitle}`).first()).toBeVisible({ timeout: 10000 });
});
});

View file

@ -0,0 +1,43 @@
import { test, expect } from '@playwright/test';
test.describe('Scenario 08: View Builder & Column Reordering', () => {
test('reorders columns in View Builder, saves view, and verifies persistence across reload', async ({ page }) => {
await page.goto('/settings/entities/deal/views');
await page.waitForLoadState('domcontentloaded');
// Wait for view builder to load
await expect(page.locator('text=Конструктор представлений').first()).toBeVisible({ timeout: 15000 });
const columnPills = page.locator('[data-testid^="column-pill-"]');
await expect(columnPills.first()).toBeVisible({ timeout: 10000 });
const count = await columnPills.count();
expect(count).toBeGreaterThan(1);
// Get first column key
const firstPill = columnPills.first();
const secondPill = columnPills.nth(1);
// Reorder via move button or drag-and-drop
const moveRightBtn = firstPill.locator('button[title="Вправо"]');
if (await moveRightBtn.isVisible()) {
await moveRightBtn.click();
} else {
await secondPill.dragTo(firstPill);
}
// Save view
const saveBtn = page.locator('[data-testid="save-view-btn"]');
await expect(saveBtn).toBeVisible();
await saveBtn.click();
// Verify toast or notification
await expect(page.locator('text=сохранено').or(page.locator('text=Сохранить вид')).first()).toBeVisible({ timeout: 5000 });
// Reload page to verify persistence
await page.reload();
await page.waitForLoadState('domcontentloaded');
await expect(columnPills.first()).toBeVisible({ timeout: 15000 });
});
});

41
e2e/09-rbac.spec.ts Normal file
View file

@ -0,0 +1,41 @@
import { test, expect } from '@playwright/test';
test.describe('Scenario 09: RBAC Guard & 403 Forbidden Handling', () => {
test('denies restricted role access to protected module and restores access upon switching back to Owner', async ({ page }) => {
await page.goto('/settings');
await page.waitForLoadState('domcontentloaded');
// 1. Switch to restricted role: Cashier via Settings or Topbar
const cashierRoleBtn = page.locator('[data-testid="settings-role-cashier"]');
if (await cashierRoleBtn.isVisible()) {
await cashierRoleBtn.click();
} else {
const roleSwitcher = page.locator('[data-testid="role-switcher-btn"]');
await roleSwitcher.click();
const roleOption = page.locator('[data-testid="role-option-cashier"]');
await roleOption.click();
}
// 2. Navigate to protected route /finance (Cashier has no permission to Finance)
await page.goto('/finance');
await page.waitForLoadState('domcontentloaded');
// 3. Verify 403 Forbidden page is displayed
const forbiddenPage = page.locator('[data-testid="forbidden-page"]');
await expect(forbiddenPage).toBeVisible({ timeout: 15000 });
await expect(page.locator('text=403 Доступ ограничен').first()).toBeVisible();
// 4. Switch back to Owner using Forbidden Page quick recovery button
const switchToOwnerBtn = page.locator('[data-testid="switch-to-owner-btn"]');
await expect(switchToOwnerBtn).toBeVisible();
await switchToOwnerBtn.click();
// 5. Navigate back to /finance and verify access restored
await page.goto('/finance');
await page.waitForLoadState('domcontentloaded');
// Finance content should now be visible without 403
await expect(page.locator('[data-testid="forbidden-page"]')).not.toBeVisible();
await expect(page.locator('main').locator('text=Финансы').first()).toBeVisible({ timeout: 15000 });
});
});

View file

@ -0,0 +1,42 @@
import { test, expect } from '@playwright/test';
test.describe('Scenario 10: IndexedDB Snapshots (Export, Reset, Import)', () => {
test('exports database snapshot, resets demo data, imports snapshot, and verifies restored state', async ({ page }) => {
await page.goto('/settings');
await page.waitForLoadState('domcontentloaded');
// 1. Export database snapshot
const exportBtn = page.locator('[data-testid="export-snapshot-btn"]');
await expect(exportBtn).toBeVisible({ timeout: 15000 });
const downloadPromise = page.waitForEvent('download');
await exportBtn.click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toContain('.json');
// 2. Read downloaded snapshot content
const downloadPath = await download.path();
expect(downloadPath).toBeTruthy();
// 3. Reset database to initial state
const resetBtn = page.locator('[data-testid="reset-database-btn"]');
await expect(resetBtn).toBeVisible();
await resetBtn.click();
await expect(page.locator('text=сброшена').or(page.locator('text=Сбросить')).first()).toBeVisible({ timeout: 5000 });
// 4. Import the exported snapshot back
if (downloadPath) {
const fileInput = page.locator('[data-testid="import-snapshot-input"]');
await fileInput.setInputFiles(downloadPath);
// Verify successful import notification
await expect(page.locator('text=успешно импортирован').or(page.locator('text=Импортировано')).first()).toBeVisible({ timeout: 10000 });
}
// 5. Reload page to ensure restored state persists
await page.reload();
await page.waitForLoadState('domcontentloaded');
await expect(exportBtn).toBeVisible({ timeout: 15000 });
});
});

View file

@ -0,0 +1,351 @@
import { test, expect } from '@playwright/test';
// ---------------------------------------------------------------------------
// 1. ACCESS GATE LOGIN & VALIDATION (Isolated describe without pre-auth)
// ---------------------------------------------------------------------------
test.describe('Krona Control Center - Access Gate', () => {
test('01. Access Gate redirects unauthorized users and authenticates with PIN krona2026', async ({ page }) => {
// Clear any existing localStorage before page loads
await page.addInitScript(() => {
localStorage.clear();
});
await page.goto('/control');
await page.waitForURL('**/control/gate');
await expect(page.locator('h1:has-text("Защита доступа")')).toBeVisible();
// Invalid PIN
const pinInput = page.locator('[data-testid="pin-input"]');
await pinInput.fill('wrong_pin_123');
await page.locator('[data-testid="pin-submit"]').click();
await expect(page.locator('[data-testid="pin-error"]')).toBeVisible();
// Valid PIN: krona2026
await pinInput.fill('krona2026');
await page.locator('[data-testid="pin-submit"]').click();
await page.waitForURL('**/control');
await expect(page.locator('h1:has-text("Сводная операторская панель")')).toBeVisible({ timeout: 15000 });
});
});
// ---------------------------------------------------------------------------
// 2. MAIN PRODUCTION & MOBILE-FIRST SUITE (Pre-authorized via kcc_access_gate)
// ---------------------------------------------------------------------------
test.describe('Krona Control Center - Mobile-First & Production UX Suite', () => {
test.beforeEach(async ({ context }) => {
await context.addInitScript(() => {
localStorage.setItem('kcc_access_gate', '1');
});
});
test('02. Mobile menu (burger) opens drawer and navigates across sections', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/control');
await page.waitForLoadState('domcontentloaded');
await expect(page.locator('h1:has-text("Сводная операторская панель")')).toBeVisible({ timeout: 15000 });
const menuBtn = page.locator('[data-testid="mobile-menu-button"]');
await expect(menuBtn).toBeVisible();
await menuBtn.click();
// Drawer opens
const drawerNavOrg = page.locator('[data-testid="drawer-nav-organizations"]');
await expect(drawerNavOrg).toBeVisible();
await drawerNavOrg.click();
await page.waitForURL('**/control/organizations');
await expect(page.locator('h1:has-text("Организации платформы")')).toBeVisible();
});
test('03. Switch 3 brandbook themes (soft-light, light, dark) and verify data-theme on html', async ({ page }) => {
await page.goto('/control');
await page.waitForLoadState('domcontentloaded');
await expect(page.locator('h1:has-text("Сводная операторская панель")')).toBeVisible({ timeout: 15000 });
const isMobile = await page.locator('[data-testid="mobile-menu-button"]').isVisible();
if (isMobile) {
await page.locator('[data-testid="mobile-menu-button"]').click();
// Soft-light
await page.locator('[data-testid="mobile-theme-soft-light"]').click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'soft-light');
// Light
await page.locator('[data-testid="mobile-theme-light"]').click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
// Dark
await page.locator('[data-testid="mobile-theme-dark"]').click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
// Back to soft-light
await page.locator('[data-testid="mobile-theme-soft-light"]').click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'soft-light');
await page.locator('[data-testid="close-mobile-drawer"]').click();
} else {
// Desktop theme switcher
await page.locator('[data-testid="theme-toggle-light"]').click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
await page.locator('[data-testid="theme-toggle-dark"]').click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await page.locator('[data-testid="theme-toggle-soft-light"]').click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'soft-light');
}
});
test('04. Zero horizontal overflow on all 7 target screen sizes', async ({ page }) => {
test.setTimeout(60000);
const viewports = [
{ width: 360, height: 800 },
{ width: 390, height: 844 },
{ width: 430, height: 932 },
{ width: 768, height: 1024 },
{ width: 1024, height: 768 },
{ width: 1280, height: 800 },
{ width: 1440, height: 900 },
];
for (const vp of viewports) {
await page.setViewportSize(vp);
await page.goto('/control');
await page.waitForLoadState('domcontentloaded');
const hasOverflow = await page.evaluate(() => {
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
});
expect(hasOverflow, `Horizontal overflow at ${vp.width}x${vp.height} on /control`).toBe(false);
await page.goto('/control/organizations');
await page.waitForLoadState('domcontentloaded');
const hasOrgOverflow = await page.evaluate(() => {
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
});
expect(hasOrgOverflow, `Horizontal overflow at ${vp.width}x${vp.height} on /control/organizations`).toBe(false);
}
});
test('05. 360 inspector drawer opens, switches tabs, and closes cleanly', async ({ page }) => {
await page.goto('/control/organizations');
await page.waitForLoadState('domcontentloaded');
await expect(page.locator('h1:has-text("Организации платформы")')).toBeVisible();
// Click 360 card button (whichever is visible on current viewport)
const inspectBtn = page.locator('button:has-text("360° Карточка"):visible').first();
await inspectBtn.click();
// Contextual Drawer opens
const drawer = page.locator('div[role="complementary"][aria-label="Контекстный инспектор"]');
await expect(drawer).toBeVisible();
await expect(drawer.locator('text=Действия оператора платформы')).toBeVisible();
// Verify 360 tabs
await expect(drawer.locator('button:has-text("Обзор")')).toBeVisible();
const modulesTab = drawer.locator('button:has-text("Модули")').first();
await expect(modulesTab).toBeVisible();
await modulesTab.click();
// Close drawer
await drawer.locator('button[aria-label="Закрыть инспектор"]').click();
await expect(drawer).not.toBeVisible();
});
test('06. Filters, tabs and accordions operate correctly', async ({ page }) => {
// Organizations search
await page.goto('/control/organizations');
await page.waitForLoadState('domcontentloaded');
const searchInput = page.locator('input[placeholder*="Поиск"]');
await searchInput.fill('Ритейл');
await expect(page.locator('*:has-text("Северная Ритейл Группа"):visible').first()).toBeVisible();
// Billing quick filters
await page.goto('/control/billing');
await page.waitForLoadState('domcontentloaded');
const filter7d = page.locator('[data-testid="filter-expiring_7d"], button:has-text("7 дн")').first();
await expect(filter7d).toBeVisible();
await filter7d.click();
await expect(page.locator('*:has-text("Истекает (< 7 дн.)"):visible').first()).toBeVisible();
// Installations filter
await page.goto('/control/installations');
await page.waitForLoadState('domcontentloaded');
const filterOutdated = page.locator('button:has-text("Показать только устаревшие Android")');
await expect(filterOutdated).toBeVisible();
await filterOutdated.click();
await expect(page.locator('*:has-text("Android"):visible').first()).toBeVisible();
// Infrastructure node telemetry
await page.goto('/control/infrastructure');
await page.waitForLoadState('domcontentloaded');
await expect(page.locator('*:has-text("CPU"):visible').first()).toBeVisible();
});
test('07. Operator dangerous action requires authorization & valid reason', async ({ page }) => {
await page.goto('/control/organizations');
await page.waitForLoadState('domcontentloaded');
await page.locator('button:has-text("360° Карточка"):visible').first().click();
const drawer = page.locator('div[role="complementary"][aria-label="Контекстный инспектор"]');
await expect(drawer).toBeVisible();
const trialBtn = drawer.locator('button:has-text("Выдать Trial")');
await expect(trialBtn).toBeVisible();
await trialBtn.click();
const modal = page.locator('div[role="dialog"][aria-labelledby="confirm-modal-title"]');
await expect(modal).toBeVisible();
await expect(modal.locator('text=Требуется авторизация оператора')).toBeVisible();
// Submit empty reason -> validation error
const submitBtn = modal.locator('button:has-text("Подтвердить операцию")');
await submitBtn.click();
await expect(modal.locator('text=Обоснование операции обязательно')).toBeVisible();
// Fill valid reason (>= 5 chars)
await modal.locator('textarea[id="action-reason-input"]').fill('Согласованное продление пилота #PILOT-2026');
await submitBtn.click();
await expect(modal).not.toBeVisible();
});
test('08. Switch operator role and observe RBAC enforcement', async ({ page }) => {
await page.goto('/control');
await page.waitForLoadState('domcontentloaded');
const isMobile = await page.locator('[data-testid="mobile-menu-button"]').isVisible();
if (isMobile) {
await page.locator('[data-testid="mobile-menu-button"]').click();
}
const switcher = page.locator('[data-testid="operator-session-button"]:visible').first();
await switcher.click();
const auditorOption = page.locator('[data-testid="role-option-security_auditor"]:visible').first();
await expect(auditorOption).toBeVisible();
await auditorOption.click();
// Header / profile now indicates auditor
await expect(page.locator('*:has-text("Аудитор"):visible').first()).toBeVisible();
// Navigate to modules: triggering rollout should be disabled for auditor
await page.goto('/control/modules');
await page.waitForLoadState('domcontentloaded');
const rolloutBtn = page.locator('button:has-text("Запустить пилотный Rollout"), button:has-text("Поставить на паузу")').first();
if (await rolloutBtn.isVisible()) {
await expect(rolloutBtn).toBeDisabled();
}
// Switch back to owner
if (await page.locator('[data-testid="mobile-menu-button"]').isVisible()) {
await page.locator('[data-testid="mobile-menu-button"]').click();
}
const switcherBack = page.locator('[data-testid="operator-session-button"]:visible').first();
await switcherBack.click();
await page.locator('[data-testid="role-option-platform_owner"]:visible').first().click();
await expect(page.locator('*:has-text("Владелец"):visible').first()).toBeVisible();
});
test('09. Control Center UI is completely localized in Russian', async ({ page }) => {
const pagesToCheck = [
{ url: '/control', heading: 'Сводная операторская панель' },
{ url: '/control/organizations', heading: 'Организации платформы' },
{ url: '/control/billing', heading: 'Подписки и биллинг платформы' },
{ url: '/control/modules', heading: 'Каталог модулей и Rollout конвейер' },
{ url: '/control/installations', heading: 'Установки и клиентские приложения' },
{ url: '/control/infrastructure', heading: 'Инфраструктурные серверы' },
{ url: '/control/announcements', heading: 'Оповещения и системные сообщения' },
{ url: '/control/incidents', heading: 'Журнал инцидентов платформы' },
{ url: '/control/audit', heading: 'Неизменяемый журнал аудита действий' },
];
for (const p of pagesToCheck) {
await page.goto(p.url);
await page.waitForLoadState('domcontentloaded');
await expect(page.locator(`h1:has-text("${p.heading}"):visible`).first()).toBeVisible();
}
});
test('10. State survives browser page reload via IndexedDB', async ({ page }) => {
await page.goto('/control/audit');
await page.waitForLoadState('domcontentloaded');
await expect(page.locator('h1:has-text("Неизменяемый журнал аудита")')).toBeVisible();
// Verify audit log rows
await expect(page.locator('tbody tr')).not.toHaveCount(0);
const initialRowsCount = await page.locator('tbody tr').count();
// Reload page
await page.reload();
await page.waitForLoadState('domcontentloaded');
await expect(page.locator('h1:has-text("Неизменяемый журнал аудита")')).toBeVisible();
await expect(page.locator('tbody tr')).toHaveCount(initialRowsCount);
});
test('11. Connect new infrastructure server through onboarding wizard', async ({ page }) => {
await page.goto('/control/infrastructure');
await page.waitForLoadState('domcontentloaded');
// Click "+ Подключить сервер"
const addBtn = page.locator('[data-testid="btn-open-connect-server"]:visible').first();
await expect(addBtn).toBeVisible();
await addBtn.click();
// Verify modal is open
const modal = page.locator('[data-testid="connect-server-modal"]');
await expect(modal).toBeVisible();
// Fill Step 1
await page.locator('[data-testid="input-server-name"]').fill('vps-sel-e2e-99');
await page.locator('[data-testid="input-server-ip"]').fill('178.212.13.240');
await page.locator('[data-testid="btn-connect-next"]').click();
// Step 2: SSH config
await expect(page.locator('*:has-text("Настройка SSH"):visible').first()).toBeVisible();
await page.locator('[data-testid="btn-connect-next"]').click();
// Step 3: Test connection & Finish
await expect(page.locator('*:has-text("Проверка связи"):visible').first()).toBeVisible();
await page.locator('[data-testid="btn-test-ssh"]').click();
await expect(page.locator('*:has-text("SSH-соединение успешно установлено"):visible').first()).toBeVisible();
await page.locator('[data-testid="btn-connect-finish"]').click();
// Verify modal closed and server is present in the list
await expect(modal).not.toBeVisible();
await expect(page.locator('*:has-text("vps-sel-e2e-99"):visible').first()).toBeVisible();
});
test('12. Open interactive terminal and execute diagnostic commands', async ({ page }) => {
await page.goto('/control/infrastructure');
await page.waitForLoadState('domcontentloaded');
// Click terminal button on first server card
const termBtn = page.locator('[data-testid^="terminal-server-"]:visible').first();
await expect(termBtn).toBeVisible();
await termBtn.click();
// Verify terminal modal is open
const termModal = page.locator('[data-testid="server-terminal-modal"]');
await expect(termModal).toBeVisible();
// Enter bash command
const termInput = page.locator('[data-testid="terminal-input"]');
await termInput.fill('uptime');
await termInput.press('Enter');
// Verify command output
await expect(termModal.locator('*:has-text("load average:"):visible').first()).toBeVisible();
// Use quick action chip 'docker ps'
const dockerChip = termModal.locator('button:has-text("docker ps"):visible').first();
await dockerChip.click();
await expect(termModal.locator('*:has-text("krona-reverse-proxy"):visible').first()).toBeVisible();
// Close terminal modal
await termModal.locator('button[title="Закрыть терминал"]').click();
await expect(termModal).not.toBeVisible();
});
});

View file

@ -1,73 +0,0 @@
import { test, expect } from '@playwright/test';
test.describe('Krona Business OS — E2E Verification & Visual Matrix', () => {
test('loads Dashboard without console errors or horizontal overflow', async ({ page }) => {
const consoleErrors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') {
consoleErrors.push(msg.text());
}
});
await page.goto('/');
await page.waitForLoadState('networkidle');
// Verify brand header / title
await expect(page.locator('text=Крона').first()).toBeVisible();
// Check horizontal overflow (scrollWidth <= clientWidth)
const isOverflowing = await page.evaluate(() => {
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
});
expect(isOverflowing).toBe(false);
// Ensure no uncaught browser runtime errors
const fatalErrors = consoleErrors.filter(
(err) => !err.includes('favicon') && !err.includes('status of 404')
);
expect(fatalErrors).toHaveLength(0);
});
test('captures theme and viewport screenshot matrix', async ({ page }, testInfo) => {
const themes = ['soft-light', 'light', 'dark'] as const;
const projectName = testInfo.project.name; // desktop | tablet | mobile
for (const theme of themes) {
await page.goto('/');
await page.waitForLoadState('networkidle');
// Set data-theme attribute directly on documentElement
await page.evaluate((t) => {
document.documentElement.setAttribute('data-theme', t);
localStorage.setItem('krona_theme_mode', t);
}, theme);
await page.waitForTimeout(300);
// Save screenshot
await page.screenshot({
path: `e2e/screenshots/${projectName}_${theme}.png`,
fullPage: false,
});
}
});
test('advances CRM deal stage and verifies kanban status update', async ({ page }) => {
await page.goto('/crm');
await page.waitForLoadState('networkidle');
// Switch to Deals tab if not default
const dealsTab = page.locator('button:has-text("Сделки")');
if (await dealsTab.isVisible()) {
await dealsTab.click();
}
// Verify Deal cards are rendered in Kanban
const dealCards = page.locator('[data-testid^="deal-card-"], .group\\/deal, [draggable="true"]');
await expect(dealCards.first()).toBeVisible({ timeout: 5000 });
// Verify kanban stages exist
await expect(page.locator('text=Квалификация').first()).toBeVisible();
await expect(page.locator('text=Успешно').first()).toBeVisible();
});
});

View file

@ -6,10 +6,11 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "oxlint", "lint": "oxlint --deny-warnings",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"check": "npm run lint && tsc -b && npm test && npm run build && npm run test:e2e",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {

View file

@ -1,4 +1,4 @@
import { defineConfig, devices } from '@playwright/test'; import { defineConfig } from '@playwright/test';
export default defineConfig({ export default defineConfig({
testDir: './e2e', testDir: './e2e',
@ -9,7 +9,7 @@ export default defineConfig({
workers: 1, workers: 1,
reporter: 'list', reporter: 'list',
use: { use: {
baseURL: 'http://localhost:5173', baseURL: 'http://127.0.0.1:5180',
trace: 'on-first-retry', trace: 'on-first-retry',
}, },
projects: [ projects: [
@ -33,9 +33,9 @@ export default defineConfig({
}, },
], ],
webServer: { webServer: {
command: 'npm run dev', command: 'npm run dev -- --port 5180 --host 127.0.0.1',
url: 'http://localhost:5173', url: 'http://127.0.0.1:5180',
reuseExistingServer: !process.env.CI, reuseExistingServer: false,
timeout: 120000, timeout: 120000,
}, },
}); });

View file

@ -8,9 +8,7 @@ import { BottomNav } from '../shell/BottomNav';
import { CommandPalette } from '../shell/CommandPalette'; import { CommandPalette } from '../shell/CommandPalette';
import { QuickActionsModal } from '../shell/QuickActionsModal'; import { QuickActionsModal } from '../shell/QuickActionsModal';
import { NotificationsDrawer } from '../shell/NotificationsDrawer'; import { NotificationsDrawer } from '../shell/NotificationsDrawer';
import { Drawer } from '../../shared/components/Drawer'; import { Drawer, Button } from '../../shared/ui';
import { Sparkles, Bot, Send } from 'lucide-react';
import { Button } from '../../shared/components/Button';
export const AppShell: React.FC = () => { export const AppShell: React.FC = () => {
const { const {

View file

@ -3,6 +3,8 @@ import { ToastProvider } from '../../shared/ui/Toast';
import { ErrorBoundary } from '../../shared/ui/ErrorBoundary'; import { ErrorBoundary } from '../../shared/ui/ErrorBoundary';
import { initMockServices } from '../../services/mock'; import { initMockServices } from '../../services/mock';
import { TenantProvider } from './TenantProvider';
export interface AppProvidersProps { export interface AppProvidersProps {
children: React.ReactNode; children: React.ReactNode;
} }
@ -14,9 +16,11 @@ export const AppProviders: React.FC<AppProvidersProps> = ({ children }) => {
return ( return (
<ErrorBoundary scope="Корневое приложение Krona"> <ErrorBoundary scope="Корневое приложение Krona">
<ToastProvider> <TenantProvider>
{children} <ToastProvider>
</ToastProvider> {children}
</ToastProvider>
</TenantProvider>
</ErrorBoundary> </ErrorBoundary>
); );
}; };

View file

@ -0,0 +1,72 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import {
DEFAULT_TENANT_ID,
createRequestContext,
getActiveGlobalTenantId,
setActiveGlobalTenantId,
} from '../../services/contracts/context';
import {
TenantContext,
PRESET_TENANTS,
TenantContextValue,
} from './tenantContext';
export interface TenantProviderProps {
children: React.ReactNode;
initialTenantId?: string;
}
export const TenantProvider: React.FC<TenantProviderProps> = ({
children,
initialTenantId,
}) => {
const [currentTenantId, setCurrentTenantId] = useState<string>(() => {
return initialTenantId || getActiveGlobalTenantId() || DEFAULT_TENANT_ID;
});
const switchTenant = useCallback((tenantId: string) => {
setCurrentTenantId(tenantId);
setActiveGlobalTenantId(tenantId);
}, []);
useEffect(() => {
setActiveGlobalTenantId(currentTenantId);
}, [currentTenantId]);
const currentTenant = useMemo(() => {
return (
PRESET_TENANTS.find((t) => t.id === currentTenantId) || {
id: currentTenantId,
name: `Тенант ${currentTenantId}`,
}
);
}, [currentTenantId]);
const requestContext = useMemo(() => {
return createRequestContext({ tenantId: currentTenantId });
}, [currentTenantId]);
const createContextForCurrentTenant = useCallback(
(overrides?: Parameters<TenantContextValue['createContextForCurrentTenant']>[0]) => {
return createRequestContext({ tenantId: currentTenantId, ...overrides });
},
[currentTenantId]
);
const value = useMemo<TenantContextValue>(
() => ({
currentTenantId,
currentTenant,
availableTenants: PRESET_TENANTS,
requestContext,
switchTenant,
createContextForCurrentTenant,
}),
[currentTenantId, currentTenant, requestContext, switchTenant, createContextForCurrentTenant]
);
return <TenantContext.Provider value={value}>{children}</TenantContext.Provider>;
};
export default TenantProvider;

View file

@ -0,0 +1,52 @@
import { createContext, useContext } from 'react';
import {
RequestContext,
createRequestContext,
getActiveGlobalTenantId,
setActiveGlobalTenantId,
} from '../../services/contracts/context';
export interface TenantInfo {
id: string;
name: string;
description?: string;
}
export const PRESET_TENANTS: TenantInfo[] = [
{ id: 'tenant_main', name: 'Основной бизнес (Main)', description: 'Главный тенант компании' },
{ id: 'tenant_alpha', name: 'Филиал «Север» (Alpha)', description: 'Северное подразделение' },
{ id: 'tenant_beta', name: 'Филиал «Юг» (Beta)', description: 'Южное подразделение' },
{ id: 'tenant_demo', name: 'Песочница (Demo/Sandbox)', description: 'Тестовая изолированная среда' },
];
export interface TenantContextValue {
currentTenantId: string;
currentTenant: TenantInfo;
availableTenants: TenantInfo[];
requestContext: RequestContext;
switchTenant: (tenantId: string) => void;
createContextForCurrentTenant: (overrides?: Partial<RequestContext>) => RequestContext;
}
export const TenantContext = createContext<TenantContextValue | null>(null);
export function useTenant(): TenantContextValue {
const ctx = useContext(TenantContext);
if (!ctx) {
const fallbackTenantId = getActiveGlobalTenantId();
const currentTenant = PRESET_TENANTS.find((t) => t.id === fallbackTenantId) || {
id: fallbackTenantId,
name: fallbackTenantId,
};
return {
currentTenantId: fallbackTenantId,
currentTenant,
availableTenants: PRESET_TENANTS,
requestContext: createRequestContext({ tenantId: fallbackTenantId }),
switchTenant: setActiveGlobalTenantId,
createContextForCurrentTenant: (overrides) =>
createRequestContext({ tenantId: fallbackTenantId, ...overrides }),
};
}
return ctx;
}

View file

@ -29,8 +29,58 @@ import {
NotFoundPage, NotFoundPage,
} from '../../pages'; } from '../../pages';
import { ErrorBoundary } from '../../shared/ui/ErrorBoundary'; import { ErrorBoundary } from '../../shared/ui/ErrorBoundary';
import { ControlCenterShell } from '../../features/control-center/components/ControlCenterShell';
import { ControlRouteGuard } from '../../features/control-center/components/ControlRouteGuard';
import {
OverviewPage,
OrganizationsPage,
BillingPage,
ModulesPage,
InstallationsPage,
InfrastructurePage,
AnnouncementsPage,
IncidentsPage,
AuditPage,
ControlForbiddenPage,
ControlAccessGatePage,
} from '../../features/control-center/pages';
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{
path: '/control/gate',
element: <ControlAccessGatePage />,
},
{
path: '/control/403',
element: <ControlForbiddenPage />,
},
{
path: '/control',
element: (
<ControlRouteGuard>
<ControlCenterShell />
</ControlRouteGuard>
),
errorElement: (
<div className="p-8 bg-[#07130e] text-zinc-100 min-h-screen">
<ErrorBoundary scope="Krona Control Center">
<ControlForbiddenPage />
</ErrorBoundary>
</div>
),
children: [
{ index: true, element: <OverviewPage /> },
{ path: 'organizations', element: <OrganizationsPage /> },
{ path: 'billing', element: <BillingPage /> },
{ path: 'modules', element: <ModulesPage /> },
{ path: 'installations', element: <InstallationsPage /> },
{ path: 'infrastructure', element: <InfrastructurePage /> },
{ path: 'announcements', element: <AnnouncementsPage /> },
{ path: 'incidents', element: <IncidentsPage /> },
{ path: 'audit', element: <AuditPage /> },
{ path: '*', element: <NotFoundPage /> },
],
},
{ {
path: '/', path: '/',
element: <AppShell />, element: <AppShell />,
@ -72,3 +122,4 @@ export const router = createBrowserRouter([
], ],
}, },
]); ]);

View file

@ -8,16 +8,11 @@ import {
MessageSquare, MessageSquare,
Menu, Menu,
X, X,
Boxes,
BadgeDollarSign,
Users,
Settings,
Sparkles,
} from 'lucide-react'; } from 'lucide-react';
import { SYSTEM_MODULES } from '../../core/modules/registry'; import { SYSTEM_MODULES } from '../../core/modules/registry';
export const BottomNav: React.FC = () => { export const BottomNav: React.FC = () => {
const { setIsQuickActionsOpen, setIsAiDrawerOpen } = useDemoStore(); const { setIsQuickActionsOpen } = useDemoStore();
const [isMoreMenuOpen, setIsMoreMenuOpen] = useState(false); const [isMoreMenuOpen, setIsMoreMenuOpen] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();

View file

@ -1,8 +1,6 @@
import React from 'react'; import React from 'react';
import { useDemoStore } from '../../mock/store'; import { useDemoStore } from '../../mock/store';
import { Drawer } from '../../shared/components/Drawer'; import { Drawer, Button } from '../../shared/ui';
import { Button } from '../../shared/components/Button';
import { Badge } from '../../shared/components/Badge';
import { Bell, AlertTriangle, AlertCircle, CheckCircle2, Info, Check, Trash2 } from 'lucide-react'; import { Bell, AlertTriangle, AlertCircle, CheckCircle2, Info, Check, Trash2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';

View file

@ -1,17 +1,12 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useDemoStore } from '../../mock/store'; import { useDemoStore } from '../../mock/store';
import { Modal } from '../../shared/components/Modal'; import { Modal, Button, Input } from '../../shared/ui';
import { Button } from '../../shared/components/Button';
import { Input } from '../../shared/components/Input';
import { import {
ShoppingBag, ShoppingBag,
Calendar, Calendar,
UserPlus, UserPlus,
Car, Car,
Wrench, Wrench,
PackagePlus,
Receipt,
FileText,
CheckCircle2, CheckCircle2,
} from 'lucide-react'; } from 'lucide-react';

Some files were not shown because too many files have changed in this diff Show more