Compare commits
No commits in common. "codex/control-center-demo" and "main" have entirely different histories.
codex/cont
...
main
206 changed files with 774 additions and 16852 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -22,9 +22,3 @@ dist-ssr
|
|||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Test artifacts
|
||||
test-results/
|
||||
playwright-report/
|
||||
.playwright/
|
||||
e2e/screenshots/
|
||||
|
|
|
|||
|
|
@ -1,156 +0,0 @@
|
|||
# Задание 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 самостоятельно без отдельной команды владельца.
|
||||
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
#!/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 "================================================================="
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
# ==============================================================================
|
||||
# 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;
|
||||
}
|
||||
1
dist/assets/AiCenterModule-BBao0YAQ.js
vendored
1
dist/assets/AiCenterModule-BBao0YAQ.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/AnalyticsModule-xduYpRUy.js
vendored
1
dist/assets/AnalyticsModule-xduYpRUy.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/AppsCatalogModule-rSx-Kisy.js
vendored
1
dist/assets/AppsCatalogModule-rSx-Kisy.js
vendored
|
|
@ -1 +0,0 @@
|
|||
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
53
dist/assets/AreaChart-tvNE-d9G.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/AutomationModule-DO2GmeS1.js
vendored
1
dist/assets/AutomationModule-DO2GmeS1.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/CardLayoutBuilder-CiBozXyO.js
vendored
1
dist/assets/CardLayoutBuilder-CiBozXyO.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/CmsModule-DuJnf1QM.js
vendored
1
dist/assets/CmsModule-DuJnf1QM.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/CommunicationsModule-gCjT_vNw.js
vendored
1
dist/assets/CommunicationsModule-gCjT_vNw.js
vendored
File diff suppressed because one or more lines are too long
12
dist/assets/CrmModule-l7PoMLhA.js
vendored
12
dist/assets/CrmModule-l7PoMLhA.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/CustomEntityPage-B_J0Fvzq.js
vendored
1
dist/assets/CustomEntityPage-B_J0Fvzq.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/DynamicDashboard-BWfBzitG.js
vendored
1
dist/assets/DynamicDashboard-BWfBzitG.js
vendored
File diff suppressed because one or more lines are too long
3
dist/assets/DynamicFormRenderer-0ioSXYKX.js
vendored
3
dist/assets/DynamicFormRenderer-0ioSXYKX.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/EmployeesModule-70hl9DMi.js
vendored
1
dist/assets/EmployeesModule-70hl9DMi.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/EntityCatalog-CAg8Pzny.js
vendored
1
dist/assets/EntityCatalog-CAg8Pzny.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/FieldBuilder-CLdLCeVX.js
vendored
1
dist/assets/FieldBuilder-CLdLCeVX.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/FinanceModule-D-3_6PUg.js
vendored
1
dist/assets/FinanceModule-D-3_6PUg.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/FormBuilder-d4z6JEl5.js
vendored
1
dist/assets/FormBuilder-d4z6JEl5.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/HelpDeskModule-BIrYP26C.js
vendored
1
dist/assets/HelpDeskModule-BIrYP26C.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/InventoryModule-VpTxuRuh.js
vendored
1
dist/assets/InventoryModule-VpTxuRuh.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/MarketingModule-DyvYzuoi.js
vendored
1
dist/assets/MarketingModule-DyvYzuoi.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/OperationsModule-DlDaJDmo.js
vendored
1
dist/assets/OperationsModule-DlDaJDmo.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/SalesModule-Cf0nSO8B.js
vendored
1
dist/assets/SalesModule-Cf0nSO8B.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/SettingsModule-BbqUjWn1.js
vendored
1
dist/assets/SettingsModule-BbqUjWn1.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/TasksModule-D4E78liD.js
vendored
1
dist/assets/TasksModule-D4E78liD.js
vendored
File diff suppressed because one or more lines are too long
2
dist/assets/ViewBuilder-zR702NRL.js
vendored
2
dist/assets/ViewBuilder-zR702NRL.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/arrow-down-CWbiHuZO.js
vendored
1
dist/assets/arrow-down-CWbiHuZO.js
vendored
|
|
@ -1 +0,0 @@
|
|||
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
1
dist/assets/arrow-up-B4Ca-HNG.js
vendored
|
|
@ -1 +0,0 @@
|
|||
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
1
dist/assets/arrow-up-down--M136cK3.js
vendored
|
|
@ -1 +0,0 @@
|
|||
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
1
dist/assets/building-BdUpGLM5.js
vendored
|
|
@ -1 +0,0 @@
|
|||
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
1
dist/assets/currency-DdDs82F5.js
vendored
|
|
@ -1 +0,0 @@
|
|||
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
1
dist/assets/demo-D1nHL60J.js
vendored
|
|
@ -1 +0,0 @@
|
|||
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
1
dist/assets/dollar-sign-Bf6JneNn.js
vendored
|
|
@ -1 +0,0 @@
|
|||
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
1
dist/assets/eye-CkF1gwUX.js
vendored
|
|
@ -1 +0,0 @@
|
|||
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};
|
||||
1
dist/assets/fieldTypesMeta-BCSfJ-2N.js
vendored
1
dist/assets/fieldTypesMeta-BCSfJ-2N.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/file-text-DR21jZDS.js
vendored
1
dist/assets/file-text-DR21jZDS.js
vendored
|
|
@ -1 +0,0 @@
|
|||
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
1
dist/assets/formatters-WvGQLZg0.js
vendored
|
|
@ -1 +0,0 @@
|
|||
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
1
dist/assets/image-B_RO1I7c.js
vendored
|
|
@ -1 +0,0 @@
|
|||
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
1
dist/assets/index-Bb0d9GEE.css
vendored
File diff suppressed because one or more lines are too long
45
dist/assets/index-CrJKtV0u.js
vendored
45
dist/assets/index-CrJKtV0u.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/indexedDb-D30TbhA6.js
vendored
1
dist/assets/indexedDb-D30TbhA6.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/link-DOg0dLYA.js
vendored
1
dist/assets/link-DOg0dLYA.js
vendored
|
|
@ -1 +0,0 @@
|
|||
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
1
dist/assets/list-XDoRqfJ2.js
vendored
|
|
@ -1 +0,0 @@
|
|||
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
1
dist/assets/mail-DOD5KO1v.js
vendored
|
|
@ -1 +0,0 @@
|
|||
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
1
dist/assets/mockData-DAqQG5te.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/move-up-qiRY8MuU.js
vendored
1
dist/assets/move-up-qiRY8MuU.js
vendored
|
|
@ -1 +0,0 @@
|
|||
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};
|
||||
1
dist/assets/panels-top-left-BomsDlf9.js
vendored
1
dist/assets/panels-top-left-BomsDlf9.js
vendored
|
|
@ -1 +0,0 @@
|
|||
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
1
dist/assets/pen-D8SS70u3.js
vendored
|
|
@ -1 +0,0 @@
|
|||
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
1
dist/assets/phone-DpnQw4if.js
vendored
|
|
@ -1 +0,0 @@
|
|||
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
1
dist/assets/save-D8euL60I.js
vendored
|
|
@ -1 +0,0 @@
|
|||
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
1
dist/assets/settings-2-CSojebrG.js
vendored
|
|
@ -1 +0,0 @@
|
|||
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
1
dist/assets/star-B6hvSjuv.js
vendored
|
|
@ -1 +0,0 @@
|
|||
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
1
dist/assets/table-_fqSCPRE.js
vendored
|
|
@ -1 +0,0 @@
|
|||
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
1
dist/assets/truck-D5o8mTsH.js
vendored
|
|
@ -1 +0,0 @@
|
|||
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
1
dist/assets/types-CTcNpN0z.js
vendored
|
|
@ -1 +0,0 @@
|
|||
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
1
dist/assets/types-DMl_5q2N.js
vendored
|
|
@ -1 +0,0 @@
|
|||
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
1
dist/assets/upload-CY2IsfiN.js
vendored
|
|
@ -1 +0,0 @@
|
|||
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
1
dist/assets/useInventory-C7Fo8GE1.js
vendored
|
|
@ -1 +0,0 @@
|
|||
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
1
dist/assets/useOrders-TRZWcao8.js
vendored
|
|
@ -1 +0,0 @@
|
|||
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
1
dist/assets/useToast-CS8pZhce.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/user-CuEEaB8a.js
vendored
1
dist/assets/user-CuEEaB8a.js
vendored
|
|
@ -1 +0,0 @@
|
|||
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
1
dist/assets/utensils-BhILG_B7.js
vendored
|
|
@ -1 +0,0 @@
|
|||
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
BIN
dist/branding/krona-logo.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
BIN
dist/branding/krona-symbol.png
vendored
BIN
dist/branding/krona-symbol.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 406 KiB |
1
dist/favicon.svg
vendored
1
dist/favicon.svg
vendored
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB |
24
dist/icons.svg
vendored
24
dist/icons.svg
vendored
|
|
@ -1,24 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 4.9 KiB |
20
dist/index.html
vendored
20
dist/index.html
vendored
|
|
@ -1,20 +0,0 @@
|
|||
<!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>
|
||||
|
|
@ -1,388 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
# Реестр приёмочных сценариев (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 хеши сохраняются неизменными.
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
# Руководство по развёртыванию 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
|
||||
```
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
# Фронтенд-контракты и Модели данных 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`.
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
# Матрица ролей и разрешений (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` и немедленной фиксацией в журнале безопасности.
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
# Реестр маршрутов 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**: Все дочерние экраны обёрнуты в локальный обработчик ошибок с кнопкой сброса кэша и перезагрузки интерфейса.
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
# Матрица состояний и переходов 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**: Токен авторизации устройства отозван оператором платформы.
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
# 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 навигация, карточный вид для таблиц.
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
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');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,351 +0,0 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
||||
73
e2e/app.spec.ts
Normal file
73
e2e/app.spec.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -6,11 +6,10 @@
|
|||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint --deny-warnings",
|
||||
"lint": "oxlint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"check": "npm run lint && tsc -b && npm test && npm run build && npm run test:e2e",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { defineConfig } from '@playwright/test';
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
|
|
@ -9,7 +9,7 @@ export default defineConfig({
|
|||
workers: 1,
|
||||
reporter: 'list',
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:5180',
|
||||
baseURL: 'http://localhost:5173',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [
|
||||
|
|
@ -33,9 +33,9 @@ export default defineConfig({
|
|||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run dev -- --port 5180 --host 127.0.0.1',
|
||||
url: 'http://127.0.0.1:5180',
|
||||
reuseExistingServer: false,
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:5173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120000,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ import { BottomNav } from '../shell/BottomNav';
|
|||
import { CommandPalette } from '../shell/CommandPalette';
|
||||
import { QuickActionsModal } from '../shell/QuickActionsModal';
|
||||
import { NotificationsDrawer } from '../shell/NotificationsDrawer';
|
||||
import { Drawer, Button } from '../../shared/ui';
|
||||
import { Drawer } from '../../shared/components/Drawer';
|
||||
import { Sparkles, Bot, Send } from 'lucide-react';
|
||||
import { Button } from '../../shared/components/Button';
|
||||
|
||||
export const AppShell: React.FC = () => {
|
||||
const {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ import { ToastProvider } from '../../shared/ui/Toast';
|
|||
import { ErrorBoundary } from '../../shared/ui/ErrorBoundary';
|
||||
import { initMockServices } from '../../services/mock';
|
||||
|
||||
import { TenantProvider } from './TenantProvider';
|
||||
|
||||
export interface AppProvidersProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
|
@ -16,11 +14,9 @@ export const AppProviders: React.FC<AppProvidersProps> = ({ children }) => {
|
|||
|
||||
return (
|
||||
<ErrorBoundary scope="Корневое приложение Krona">
|
||||
<TenantProvider>
|
||||
<ToastProvider>
|
||||
{children}
|
||||
</ToastProvider>
|
||||
</TenantProvider>
|
||||
<ToastProvider>
|
||||
{children}
|
||||
</ToastProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
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;
|
||||
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -29,58 +29,8 @@ import {
|
|||
NotFoundPage,
|
||||
} from '../../pages';
|
||||
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([
|
||||
{
|
||||
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: '/',
|
||||
element: <AppShell />,
|
||||
|
|
@ -122,4 +72,3 @@ export const router = createBrowserRouter([
|
|||
],
|
||||
},
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -8,11 +8,16 @@ import {
|
|||
MessageSquare,
|
||||
Menu,
|
||||
X,
|
||||
Boxes,
|
||||
BadgeDollarSign,
|
||||
Users,
|
||||
Settings,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { SYSTEM_MODULES } from '../../core/modules/registry';
|
||||
|
||||
export const BottomNav: React.FC = () => {
|
||||
const { setIsQuickActionsOpen } = useDemoStore();
|
||||
const { setIsQuickActionsOpen, setIsAiDrawerOpen } = useDemoStore();
|
||||
const [isMoreMenuOpen, setIsMoreMenuOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import React from 'react';
|
||||
import { useDemoStore } from '../../mock/store';
|
||||
import { Drawer, Button } from '../../shared/ui';
|
||||
import { Drawer } from '../../shared/components/Drawer';
|
||||
import { Button } from '../../shared/components/Button';
|
||||
import { Badge } from '../../shared/components/Badge';
|
||||
import { Bell, AlertTriangle, AlertCircle, CheckCircle2, Info, Check, Trash2 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useDemoStore } from '../../mock/store';
|
||||
import { Modal, Button, Input } from '../../shared/ui';
|
||||
import { Modal } from '../../shared/components/Modal';
|
||||
import { Button } from '../../shared/components/Button';
|
||||
import { Input } from '../../shared/components/Input';
|
||||
import {
|
||||
ShoppingBag,
|
||||
Calendar,
|
||||
UserPlus,
|
||||
Car,
|
||||
Wrench,
|
||||
PackagePlus,
|
||||
Receipt,
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue